Merge pull request #55 from horizon-rust/feat/smp-economy

SMP native server mod: the economy (/balance, /pay, /baltop, /eco)
This commit is contained in:
levzzz authored and GitHub committed 2026-10-30 22:56:59 +05:00
commit 5c1b79862e
15 files changed
+1446

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "horizon-mod-smp"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "horizon-mod-smp"
version = "0.1.0"
edition = "2024"
license = "MIT"
publish = false
description = "The official Horizon server's SMP, a native server mod: economy (and next, market, trades, claims)."
# The mod's rules and their tests: the same files the bundle adds to the game
# (as game::smp_store, game::smp_economy), under the same names.
[lib]
path = "src/lib.rs"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+63
View File
@@ -0,0 +1,63 @@
# SMP (Horizon native server mod)
The official Horizon server's survival layer ([design](https://github.com/horizon-rust/horizon/blob/main/docs/smp.md)).
It is **not** part of Horizon's own server: only a server that builds this mod into its engine has it. It isn't listed
in the registry.
This version: the economy.
| Command | Does |
|---|---|
| `/balance [player]`, `/bal` | your balance, or another player's |
| `/pay <player> <amount>` | gives money to a player who has an account (anyone who has played since the mod was on) |
| `/baltop [page]` | the richest players, 10 a page |
| `/eco give\|take\|set <player> <amount>` | operators of level 3: corrections, logged |
The currency is earned in game only: it can't be bought with real money or cashed out (Mojang's usage guidelines).
## Data
Everything is in the world's folder, `world/horizon-smp/`, so a backup of the world carries it. A world without that
folder is untouched until the mod first runs on it; it changes no block and no player.
| File | Holds |
|---|---|
| `config.json` | `currency` (default `coins`) and `starting_balance` (default 0), written with the defaults the first time |
| `economy.json` | balances by player UUID, with the name last seen |
| `audit.log` | one JSON line per change, written before the change: `time` (ms), `kind` (`open`, `pay`, `eco_give`...), `amount`, `from`/`to` with their balance before, `by` for an operator |
`economy.json` is written whole to a temporary file, flushed and renamed over the old one, at most once a server tick
and when the world closes: a crash leaves the old file or the new one. If it can't be read, the economy stays off (the
commands say so) rather than start from nothing.
## Made on the hook points
It hooks only Horizon's native hook points: `native_register_commands` and `native_perform_command` (API 8), the
server tick and the world lifecycle. `compatibility.json` names `"hooks": 8`.
| File | What it is |
|---|---|
| `mixins/smp/horizon-mixin.toml` | The bundle: three modules and the HEAD hooks |
| `mixins/smp/smp_store.rs`, `smp_economy.rs` | The rules, no game types: added to the game as `game::smp_store`, `game::smp_economy`; also this crate's modules (`src/lib.rs`), for their tests |
| `mixins/smp/glue.rs` | The state for the open world and the edges to the game's types (`game::smp_glue`) |
| `mixins/smp/{register,command,tick,lifecycle}.rs` | One small helper per hook |
| `proof.py` | A dedicated server built with the mod and two real clients, on fresh temporary data |
## Build and prove
```sh
(cd native/smp && cargo test) # the rules
python3 native/tools/native.py check smp # apply on the tested Horizon and cargo check the game
python3 native/tools/native.py proof smp --resources /path/to/.local/horizon-client/26.3
```
## The official server's engine
The server runs `horizon-server` as usual, with `[engine] path` pointing at a game built with this mod:
```sh
NATIVE_NO_DEFAULT_FEATURES=1 python3 native/tools/native.py build smp --horizon <the release's commit>
```
prints the built `horizon-client` (`--no-default-features`: the server build, without audio and controllers). When
to deploy it on the official server is the owners' decision.
+8
View File
@@ -0,0 +1,8 @@
{
"game": "26.3",
"hooks": 8,
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "768d20d9980c2e04cc76951d18ec6c72ccef0176",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
}
+11
View File
@@ -0,0 +1,11 @@
// A player's command on the server: the SMP's own, when it is one.
fn smp_perform_command(
game: &mut crate::game::Game<'_>,
player: &crate::game::native_server::NativeServerPlayer,
command: &str,
ran: &mut bool,
) {
if !*ran {
crate::game::smp_glue::perform(game, player, command, ran);
}
}
+179
View File
@@ -0,0 +1,179 @@
// The SMP in the server's game: its state for the open world, and the edges
// between the game's types and the rules (smp_economy). Added to the game as
// `game::smp_glue`; the helpers (register.rs, command.rs, tick.rs,
// lifecycle.rs) call it from the hook points.
use crate::game::{
Game,
native_server::{NativeCommand, NativeServerPlayer},
smp_economy::{self, Economy, Out, Player},
smp_store,
};
use horizon_client::text::{self, Text};
use std::{path::PathBuf, sync::Mutex};
/// The mod's id, name and version.
pub const ID: &str = "smp";
pub const NAME: &str = "SMP";
pub const VERSION: &str = "0.1.0";
/// `config.json` in `world/horizon-smp/`, written with its defaults the
/// first time.
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Config {
#[serde(flatten)]
pub economy: smp_economy::Config,
}
/// The SMP of the open world.
pub struct Smp {
dir: PathBuf,
/// An error when `economy.json` can't be read: the commands say so.
economy: Result<Economy, String>,
}
static STATE: Mutex<Option<Smp>> = Mutex::new(None);
static TICKS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn open(dir: PathBuf) -> Smp {
let config_path = dir.join("config.json");
let config: Config = match smp_store::read_json(&config_path) {
Ok(c) => c,
Err(e) => {
log::error!("[smp] {e}: the defaults are used");
Config::default()
}
};
if !config_path.exists()
&& let Err(e) = smp_store::write_json(&config_path, &config)
{
log::error!("[smp] {e}");
}
log::info!("[smp] {NAME} {VERSION} on {}", dir.display());
let economy = Economy::open(&dir, config.economy);
if let Err(e) = &economy {
log::error!("[smp] the economy is off: {e}");
}
Smp { dir, economy }
}
/// Runs `f` on the SMP of the game's open world (opened on first use).
fn with<R>(game: &Game<'_>, f: impl FnOnce(&mut Smp) -> R) -> Option<R> {
let dir = game.native_world_dir()?.join(smp_store::DIR);
let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner());
if state.as_ref().is_none_or(|s| s.dir != dir) {
if let Some(old) = state.as_mut() {
save(old);
}
*state = Some(open(dir));
}
let smp = state.as_mut().expect("opened above");
if let Ok(e) = &mut smp.economy {
e.now = chrono::Utc::now().timestamp_millis();
}
Some(f(smp))
}
fn save(smp: &mut Smp) {
if let Ok(e) = &mut smp.economy
&& let Err(err) = e.save()
{
log::error!("[smp] {err}");
}
}
/// The commands, for every player's command tree.
pub fn commands(commands: &mut Vec<NativeCommand>) {
for usage in [
"balance [player:word]",
"bal [player:word]",
"pay <player:word> <amount:integer>",
"baltop [page:integer]",
] {
commands.push(NativeCommand::new(usage));
}
for verb in ["give", "take", "set"] {
commands.push(NativeCommand::op(
&format!("eco {verb} <player:word> <amount:integer>"),
3,
));
}
}
/// A player's command.
pub(in crate::game) fn perform(
game: &mut Game<'_>,
player: &NativeServerPlayer,
line: &str,
ran: &mut bool,
) {
if !game.native_on(ID) {
return;
}
let me = Player {
uuid: player.uuid,
name: player.name.clone(),
level: player.permission,
};
let Some(out) = with(game, |smp| match &mut smp.economy {
Ok(e) => {
e.seen(me.uuid, &me.name);
e.command(&me, line)
}
Err(_) => matches!(
line.split(' ').next(),
Some("balance" | "bal" | "pay" | "baltop" | "eco")
)
.then(|| {
vec![Out::Error(
"The economy is off: its file can't be read (see the server log).".into(),
)]
}),
})
.flatten() else {
return;
};
*ran = true;
show(game, out);
}
fn show(game: &mut Game<'_>, out: Vec<Out>) {
for o in out {
match o {
Out::Reply(s) => game.native_feedback(Text::literal(s)),
Out::Error(s) => game.native_feedback(Text::literal(s).with_color(text::RED)),
Out::Tell(uuid, s) => {
game.native_tell(uuid, Text::literal(s).with_color(text::GOLD));
}
}
}
}
/// The end of each server tick: accounts for the players on (once a second)
/// and the files that changed.
pub(in crate::game) fn tick(game: &mut Game<'_>) {
let n = TICKS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let players = if n % 20 == 0 {
game.native_server_players()
} else {
Vec::new()
};
with(game, |smp| {
if let Ok(e) = &mut smp.economy {
for p in &players {
e.seen(p.uuid, &p.name);
}
}
save(smp);
});
}
/// The world closed: its files are written and its state let go.
pub fn closed() {
let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner());
if let Some(smp) = state.as_mut() {
save(smp);
}
*state = None;
}
+67
View File
@@ -0,0 +1,67 @@
[mod]
id = "smp"
version = "0.1.0"
priority = 100
# The commit it was tested on. It uses only the native hook points (hooks 8 in
# compatibility.json): it builds on any Horizon that has them.
[compatibility]
horizon_revision = "768d20d9980c2e04cc76951d18ec6c72ccef0176"
[[module]]
id = "store"
file = "crates/horizon-client/src/game.rs"
name = "smp_store"
source = "smp_store.rs"
[[module]]
id = "economy"
file = "crates/horizon-client/src/game.rs"
name = "smp_economy"
source = "smp_economy.rs"
[[module]]
id = "glue"
file = "crates/horizon-client/src/game.rs"
name = "smp_glue"
source = "glue.rs"
[[injection]]
id = "register"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_register_commands"
helper = "register.rs"
bindings = [
{ name = "commands", ty = "&mut Vec<crate::game::native_server::NativeCommand>", value = "&mut *commands" },
]
[[injection]]
id = "command"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_perform_command"
helper = "command.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "player", ty = "&crate::game::native_server::NativeServerPlayer", value = "player" },
{ name = "command", ty = "&str", value = "command" },
{ name = "ran", ty = "&mut bool", value = "&mut *ran" },
]
[[injection]]
id = "tick"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_server_tick"
helper = "tick.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "phase", ty = "crate::game::native_lifecycle::ServerTickPhase", value = "phase" },
]
[[injection]]
id = "lifecycle"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_world_lifecycle"
helper = "lifecycle.rs"
bindings = [
{ name = "phase", ty = "crate::game::native_lifecycle::WorldPhase", value = "phase" },
]
+10
View File
@@ -0,0 +1,10 @@
// The world closing: the SMP writes its files and lets its state go.
fn smp_world_lifecycle(phase: crate::game::native_lifecycle::WorldPhase) {
use crate::game::native_lifecycle::WorldPhase;
if matches!(
phase,
WorldPhase::WorldCloseFinal | WorldPhase::WorldCloseFailed
) {
crate::game::smp_glue::closed();
}
}
+4
View File
@@ -0,0 +1,4 @@
// The SMP's commands in every player's command tree.
fn smp_register_commands(commands: &mut Vec<crate::game::native_server::NativeCommand>) {
crate::game::smp_glue::commands(commands);
}
+571
View File
@@ -0,0 +1,571 @@
// The SMP's economy: one currency, a balance per player UUID
// (`economy.json`), and the commands `/balance`, `/pay`, `/baltop` and the
// operators' `/eco`. Every change is written to the audit log first, then
// made; a change the log refuses is not made. No game types: the glue
// (glue.rs) hands in the player and the line, and sends back what this
// answers. Added to the game as `game::smp_economy`; also a module of this
// crate, for its tests.
use super::smp_store::{Audit, read_json, uuid_string, write_json};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, path::PathBuf};
/// Amounts above this are refused: far beyond any economy, far below overflow.
pub const MAX_AMOUNT: i64 = 1_000_000_000_000;
/// Lines of `/baltop` a page.
pub const PAGE: usize = 10;
/// `config.json`'s economy settings.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// Shown after amounts: `1,250 coins`.
pub currency: String,
/// What a new account holds.
pub starting_balance: i64,
}
impl Default for Config {
fn default() -> Self {
Self {
currency: "coins".into(),
starting_balance: 0,
}
}
}
/// One player's account.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Account {
/// The name last seen.
pub name: String,
pub balance: i64,
}
/// `economy.json`.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Accounts {
/// By UUID, as `uuid_string` writes it.
pub accounts: BTreeMap<String, Account>,
}
/// A player as a command sees them.
#[derive(Clone, Debug, PartialEq)]
pub struct Player {
pub uuid: u128,
pub name: String,
/// Operator level, 0 to 4.
pub level: u8,
}
/// What a command answers: its feedback, red when an error, and lines to
/// other players.
#[derive(Clone, Debug, PartialEq)]
pub enum Out {
Reply(String),
Error(String),
Tell(u128, String),
}
pub struct Economy {
pub config: Config,
accounts: Accounts,
path: PathBuf,
audit: Audit,
dirty: bool,
/// Milliseconds since 1970, for the log (the glue sets it each call).
pub now: i64,
}
impl Economy {
/// The economy kept in `dir` (`world/horizon-smp/`); an error when
/// `economy.json` exists and can't be read: the economy then stays off
/// rather than start from nothing.
pub fn open(dir: &std::path::Path, config: Config) -> Result<Self, String> {
let path = dir.join("economy.json");
let accounts = read_json(&path)?;
Ok(Self {
config,
accounts,
path,
audit: Audit::new(dir.join("audit.log")),
dirty: false,
now: 0,
})
}
/// Writes `economy.json` when it changed (end of a tick, world closing).
pub fn save(&mut self) -> Result<(), String> {
self.audit.sync();
if !self.dirty {
return Ok(());
}
write_json(&self.path, &self.accounts)?;
self.dirty = false;
Ok(())
}
pub fn balance(&self, uuid: u128) -> i64 {
self.accounts
.accounts
.get(&uuid_string(uuid))
.map_or(0, |a| a.balance)
}
/// The account of a player who is on: opened with the starting balance
/// the first time (logged), its name kept up to date.
pub fn seen(&mut self, uuid: u128, name: &str) {
let key = uuid_string(uuid);
match self.accounts.accounts.get_mut(&key) {
Some(a) if a.name == name => {}
Some(a) => {
a.name = name.to_owned();
self.dirty = true;
}
None => {
let start = self.config.starting_balance.clamp(0, MAX_AMOUNT);
let entry = self.entry("open", None, Some(uuid), start, "");
if self.audit.append(&entry).is_ok() {
self.accounts.accounts.insert(
key,
Account {
name: name.to_owned(),
balance: start,
},
);
self.dirty = true;
}
}
}
}
/// An account by the name last seen, any case.
pub fn find(&self, name: &str) -> Option<(u128, &Account)> {
self.accounts
.accounts
.iter()
.find(|(_, a)| a.name.eq_ignore_ascii_case(name))
.and_then(|(k, a)| Some((super::smp_store::parse_uuid(k)?, a)))
}
/// `1,250 coins`.
pub fn money(&self, amount: i64) -> String {
let digits = amount.unsigned_abs().to_string();
let mut out = String::new();
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
let sign = if amount < 0 { "-" } else { "" };
format!("{sign}{out} {}", self.config.currency)
}
fn entry(
&self,
kind: &str,
from: Option<u128>,
to: Option<u128>,
amount: i64,
by: &str,
) -> serde_json::Value {
let after = |u: Option<u128>| u.map(|u| self.balance(u));
let mut e = serde_json::json!({
"time": self.now,
"kind": kind,
"amount": amount,
});
if let Some(f) = from {
e["from"] = uuid_string(f).into();
e["from_before"] = after(Some(f)).into();
}
if let Some(t) = to {
e["to"] = uuid_string(t).into();
e["to_before"] = after(Some(t)).into();
}
if !by.is_empty() {
e["by"] = by.into();
}
e
}
/// Moves `amount` from one account to another, logged as `kind`. Both
/// must exist; `from` must hold it.
pub fn transfer(
&mut self,
kind: &str,
from: u128,
to: u128,
amount: i64,
) -> Result<(), String> {
if amount <= 0 || amount > MAX_AMOUNT {
return Err("The amount must be a positive whole number.".into());
}
if from == to {
return Err("You can't pay yourself.".into());
}
let (Some(f), Some(t)) = (
self.accounts.accounts.get(&uuid_string(from)),
self.accounts.accounts.get(&uuid_string(to)),
) else {
return Err("No such account.".into());
};
if f.balance < amount {
return Err(format!("You only have {}.", self.money(f.balance)));
}
let Some(to_after) = t
.balance
.checked_add(amount)
.filter(|b| *b <= MAX_AMOUNT * 1000)
else {
return Err("That account can't hold more.".into());
};
let entry = self.entry(kind, Some(from), Some(to), amount, "");
self.audit.append(&entry)?;
self.accounts
.accounts
.get_mut(&uuid_string(from))
.expect("checked")
.balance -= amount;
self.accounts
.accounts
.get_mut(&uuid_string(to))
.expect("checked")
.balance = to_after;
self.dirty = true;
Ok(())
}
/// Adds (or with a negative `delta`, takes) money from the server: the
/// market, games, operators. The balance never goes below zero.
pub fn adjust(&mut self, kind: &str, uuid: u128, delta: i64, by: &str) -> Result<i64, String> {
let key = uuid_string(uuid);
let Some(a) = self.accounts.accounts.get(&key) else {
return Err("No such account.".into());
};
let after = a.balance.saturating_add(delta);
if after < 0 {
return Err(format!("Only {} there.", self.money(a.balance)));
}
if after > MAX_AMOUNT * 1000 {
return Err("That account can't hold more.".into());
}
let (from, to) = if delta < 0 {
(Some(uuid), None)
} else {
(None, Some(uuid))
};
let entry = self.entry(kind, from, to, delta.abs(), by);
self.audit.append(&entry)?;
self.accounts
.accounts
.get_mut(&key)
.expect("checked")
.balance = after;
self.dirty = true;
Ok(after)
}
/// The accounts, richest first (then by name).
pub fn top(&self) -> Vec<(&str, i64)> {
let mut all: Vec<_> = self
.accounts
.accounts
.values()
.map(|a| (a.name.as_str(), a.balance))
.collect();
all.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
all
}
/// Runs `/balance`, `/bal`, `/pay`, `/baltop` or `/eco`; None for another
/// command.
pub fn command(&mut self, me: &Player, line: &str) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
let (&name, args) = words.split_first()?;
Some(match name {
"balance" | "bal" => self.balance_command(me, args),
"pay" => self.pay(me, args),
"baltop" => self.baltop(args),
"eco" => self.eco(me, args),
_ => return None,
})
}
fn balance_command(&mut self, me: &Player, args: &[&str]) -> Vec<Out> {
match args {
[] => vec![Out::Reply(format!(
"Balance: {}",
self.money(self.balance(me.uuid))
))],
[who] => match self.find(who) {
Some((_, a)) => vec![Out::Reply(format!("{}: {}", a.name, self.money(a.balance)))],
None => vec![Out::Error(format!("No player named {who} has an account."))],
},
_ => vec![Out::Error("Usage: /balance [player]".into())],
}
}
fn pay(&mut self, me: &Player, args: &[&str]) -> Vec<Out> {
let [who, amount] = args else {
return vec![Out::Error("Usage: /pay <player> <amount>".into())];
};
let Some(amount) = parse_amount(amount) else {
return vec![Out::Error(
"The amount must be a positive whole number.".into(),
)];
};
let Some((to, account)) = self.find(who) else {
return vec![Out::Error(format!("No player named {who} has an account."))];
};
let to_name = account.name.clone();
match self.transfer("pay", me.uuid, to, amount) {
Ok(()) => vec![
Out::Reply(format!(
"You paid {} to {to_name}. Balance: {}",
self.money(amount),
self.money(self.balance(me.uuid))
)),
Out::Tell(to, format!("{} paid you {}.", me.name, self.money(amount))),
],
Err(e) => vec![Out::Error(e)],
}
}
fn baltop(&mut self, args: &[&str]) -> Vec<Out> {
let page = match args {
[] => 1,
[p] => match p.parse::<usize>() {
Ok(p) if p >= 1 => p,
_ => return vec![Out::Error("Usage: /baltop [page]".into())],
},
_ => return vec![Out::Error("Usage: /baltop [page]".into())],
};
let top = self.top();
let pages = top.len().div_ceil(PAGE).max(1);
let mut out = vec![Out::Reply(format!("Richest players ({page}/{pages}):"))];
for (i, (name, balance)) in top.iter().enumerate().skip((page - 1) * PAGE).take(PAGE) {
out.push(Out::Reply(format!(
"{}. {name}: {}",
i + 1,
self.money(*balance)
)));
}
out
}
fn eco(&mut self, me: &Player, args: &[&str]) -> Vec<Out> {
if me.level < 3 {
return vec![Out::Error("Only operators of level 3 may use /eco.".into())];
}
let [verb, who, amount] = args else {
return vec![Out::Error(
"Usage: /eco give|take|set <player> <amount>".into(),
)];
};
let amount = match amount.parse::<i64>() {
Ok(a) if (0..=MAX_AMOUNT).contains(&a) => a,
_ => {
return vec![Out::Error(
"The amount must be a whole number from 0.".into(),
)];
}
};
let Some((uuid, account)) = self.find(who) else {
return vec![Out::Error(format!("No player named {who} has an account."))];
};
let name = account.name.clone();
let delta = match *verb {
"give" => amount,
"take" => -amount,
"set" => amount - account.balance,
_ => {
return vec![Out::Error(
"Usage: /eco give|take|set <player> <amount>".into(),
)];
}
};
let by = uuid_string(me.uuid);
match self.adjust(&format!("eco_{verb}"), uuid, delta, &by) {
Ok(after) => vec![Out::Reply(format!("{name} now has {}.", self.money(after)))],
Err(e) => vec![Out::Error(e)],
}
}
}
/// A positive whole amount, `1250` or `1,250`.
pub fn parse_amount(s: &str) -> Option<i64> {
let digits: String = s.chars().filter(|c| *c != ',').collect();
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
return None;
}
digits
.parse::<i64>()
.ok()
.filter(|a| (1..=MAX_AMOUNT).contains(a))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
const ALEX: u128 = 1;
const STEVE: u128 = 2;
fn player(uuid: u128, name: &str, level: u8) -> Player {
Player {
uuid,
name: name.into(),
level,
}
}
fn economy(name: &str) -> (Economy, PathBuf) {
let dir = std::env::temp_dir().join(format!("smp-economy-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let config = Config {
starting_balance: 100,
..Config::default()
};
let mut e = Economy::open(&dir, config).unwrap();
e.seen(ALEX, "Alex");
e.seen(STEVE, "Steve");
(e, dir)
}
fn log_lines(dir: &std::path::Path) -> Vec<serde_json::Value> {
fs::read_to_string(dir.join("audit.log"))
.unwrap()
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect()
}
#[test]
fn pay_moves_money_and_tells_both() {
let (mut e, dir) = economy("pay");
let out = e.command(&player(ALEX, "Alex", 0), "pay steve 30").unwrap();
assert_eq!(
out,
[
Out::Reply("You paid 30 coins to Steve. Balance: 70 coins".into()),
Out::Tell(STEVE, "Alex paid you 30 coins.".into()),
]
);
assert_eq!((e.balance(ALEX), e.balance(STEVE)), (70, 130));
let log = log_lines(&dir);
assert_eq!(log.len(), 3, "two accounts opened, one payment");
assert_eq!(log[2]["kind"], "pay");
assert_eq!(log[2]["from_before"], 100);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn pay_refuses_what_it_must() {
let (mut e, dir) = economy("refuse");
let alex = player(ALEX, "Alex", 0);
for (line, error) in [
("pay Steve 101", "You only have 100 coins."),
("pay Steve 0", "The amount must be a positive whole number."),
(
"pay Steve -5",
"The amount must be a positive whole number.",
),
(
"pay Steve 1.5",
"The amount must be a positive whole number.",
),
("pay Alex 5", "You can't pay yourself."),
(
"pay Herobrine 5",
"No player named Herobrine has an account.",
),
("pay Steve", "Usage: /pay <player> <amount>"),
] {
assert_eq!(
e.command(&alex, line).unwrap(),
[Out::Error(error.into())],
"{line}"
);
}
assert_eq!((e.balance(ALEX), e.balance(STEVE)), (100, 100));
assert_eq!(
log_lines(&dir).len(),
2,
"nothing logged but the two accounts"
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn balances_survive_a_restart_and_a_damaged_file_stops_the_economy() {
let (mut e, dir) = economy("restart");
e.command(&player(ALEX, "Alex", 0), "pay Steve 1,000")
.unwrap();
e.command(&player(ALEX, "Alex", 0), "pay Steve 25").unwrap();
e.save().unwrap();
let again = Economy::open(&dir, Config::default()).unwrap();
assert_eq!((again.balance(ALEX), again.balance(STEVE)), (75, 125));
fs::write(dir.join("economy.json"), "{ half").unwrap();
assert!(Economy::open(&dir, Config::default()).is_err());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn eco_is_for_level_3_and_never_goes_below_zero() {
let (mut e, dir) = economy("eco");
let alex = player(ALEX, "Alex", 2);
assert_eq!(
e.command(&alex, "eco give Steve 5").unwrap(),
[Out::Error("Only operators of level 3 may use /eco.".into())]
);
let op = player(ALEX, "Alex", 3);
assert_eq!(
e.command(&op, "eco give Steve 5").unwrap(),
[Out::Reply("Steve now has 105 coins.".into())]
);
assert_eq!(
e.command(&op, "eco take Steve 200").unwrap(),
[Out::Error("Only 105 coins there.".into())]
);
assert_eq!(
e.command(&op, "eco set Steve 1234567").unwrap(),
[Out::Reply("Steve now has 1,234,567 coins.".into())]
);
let log = log_lines(&dir);
assert_eq!(log.last().unwrap()["kind"], "eco_set");
assert_eq!(log.last().unwrap()["by"], uuid_string(ALEX));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn baltop_pages_the_richest_first() {
let (mut e, dir) = economy("baltop");
for i in 0..12u128 {
e.seen(100 + i, &format!("P{i:02}"));
}
e.command(&player(ALEX, "Alex", 0), "pay Steve 50").unwrap();
let first = e.command(&player(ALEX, "Alex", 0), "baltop").unwrap();
assert_eq!(first[0], Out::Reply("Richest players (1/2):".into()));
assert_eq!(first[1], Out::Reply("1. Steve: 150 coins".into()));
assert_eq!(first.len(), 11);
let last = e.command(&player(ALEX, "Alex", 0), "baltop 2").unwrap();
assert_eq!(
last.last().unwrap(),
&Out::Reply("14. Alex: 50 coins".into())
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_name_change_keeps_the_account() {
let (mut e, dir) = economy("rename");
e.seen(ALEX, "Alexandra");
assert_eq!(e.balance(ALEX), 100);
assert!(e.find("alexandra").is_some() && e.find("Alex").is_none());
assert_eq!(log_lines(&dir).len(), 2);
fs::remove_dir_all(dir).unwrap();
}
}
+177
View File
@@ -0,0 +1,177 @@
// The SMP's files under the world's folder (`world/horizon-smp/`): JSON
// documents written whole and atomically (a temporary file, flushed, renamed
// over the old one), and the audit log, one JSON line per transaction,
// appended before the change it records. Added to the game as
// `game::smp_store`; also a module of this crate, for its tests.
use std::{
fs::{self, File, OpenOptions},
io::{self, Write},
path::{Path, PathBuf},
};
/// The folder of the SMP's files in a world's folder.
pub const DIR: &str = "horizon-smp";
/// Writes `bytes` to `path` so that a crash leaves either the old file or the
/// new one: a temporary file beside it, flushed to disk, then renamed over it.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
let dir = path.parent().unwrap_or(Path::new("."));
fs::create_dir_all(dir)?;
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
let temp = dir.join(format!(".{name}.tmp"));
{
let mut file = File::create(&temp)?;
file.write_all(bytes)?;
file.sync_all()?;
}
fs::rename(&temp, path)?;
// The rename itself reaches the disk with the folder's entry (Unix).
if let Ok(d) = File::open(dir) {
let _ = d.sync_all();
}
Ok(())
}
/// A JSON document: its default when the file doesn't exist, an error when it
/// exists but can't be read, so that a damaged file is never replaced by an
/// empty one.
pub fn read_json<T: serde::de::DeserializeOwned + Default>(path: &Path) -> Result<T, String> {
match fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display())),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(T::default()),
Err(e) => Err(format!("{}: {e}", path.display())),
}
}
/// Writes a JSON document atomically, pretty-printed.
pub fn write_json<T: serde::Serialize>(path: &Path, value: &T) -> Result<(), String> {
let mut bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
bytes.push(b'\n');
write_atomic(path, &bytes).map_err(|e| format!("{}: {e}", path.display()))
}
/// The audit log: `audit.log`, one JSON object a line.
pub struct Audit {
path: PathBuf,
file: Option<File>,
}
impl Audit {
pub fn new(path: PathBuf) -> Self {
Self { path, file: None }
}
/// Appends one line; the change it records waits for it.
pub fn append(&mut self, entry: &serde_json::Value) -> Result<(), String> {
let mut line = entry.to_string();
line.push('\n');
let path = &self.path;
if self.file.is_none() {
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
let file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|e| format!("{}: {e}", path.display()))?;
self.file = Some(file);
}
let file = self.file.as_mut().expect("opened above");
file.write_all(line.as_bytes())
.map_err(|e| format!("{}: {e}", path.display()))
}
/// Flushes what was appended to disk (once a tick, and when the world closes).
pub fn sync(&mut self) {
if let Some(f) = &self.file {
let _ = f.sync_data();
}
}
}
/// `UUID.toString()`: 8-4-4-4-12 lowercase hex digits.
pub fn uuid_string(u: u128) -> String {
let h = format!("{u:032x}");
format!(
"{}-{}-{}-{}-{}",
&h[0..8],
&h[8..12],
&h[12..16],
&h[16..20],
&h[20..]
)
}
/// The UUID a [`uuid_string`] wrote.
pub fn parse_uuid(s: &str) -> Option<u128> {
let hex: String = s.chars().filter(|c| *c != '-').collect();
(hex.len() == 32)
.then(|| u128::from_str_radix(&hex, 16).ok())
.flatten()
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("smp-store-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
dir
}
#[test]
fn atomic_writes_replace_the_file_and_leave_no_temporary() {
let dir = scratch("atomic");
let path = dir.join("economy.json");
write_atomic(&path, b"one").unwrap();
write_atomic(&path, b"two").unwrap();
assert_eq!(fs::read(&path).unwrap(), b"two");
let names: Vec<_> = fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(names, ["economy.json"]);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_damaged_document_is_an_error_not_an_empty_one() {
let dir = scratch("damaged");
let path = dir.join("economy.json");
let missing: Vec<u32> = read_json(&path).unwrap();
assert!(missing.is_empty());
write_atomic(&path, b"{ not json").unwrap();
assert!(read_json::<Vec<u32>>(&path).is_err());
write_json(&path, &vec![1u32, 2]).unwrap();
assert_eq!(read_json::<Vec<u32>>(&path).unwrap(), [1, 2]);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn the_audit_log_appends_lines() {
let dir = scratch("audit");
let mut audit = Audit::new(dir.join("audit.log"));
audit
.append(&serde_json::json!({"kind": "pay", "amount": 5}))
.unwrap();
audit
.append(&serde_json::json!({"kind": "pay", "amount": 6}))
.unwrap();
audit.sync();
let text = fs::read_to_string(dir.join("audit.log")).unwrap();
assert_eq!(
text,
"{\"amount\":5,\"kind\":\"pay\"}\n{\"amount\":6,\"kind\":\"pay\"}\n"
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn uuids_round_trip() {
let u = 0x0000_0000_0000_4000_8000_0000_0000_0001u128;
assert_eq!(uuid_string(u), "00000000-0000-4000-8000-000000000001");
assert_eq!(parse_uuid(&uuid_string(u)), Some(u));
assert_eq!(parse_uuid("nope"), None);
}
}
+12
View File
@@ -0,0 +1,12 @@
// The end of each server tick: the SMP's accounts and files.
fn smp_server_tick(
game: &mut crate::game::Game<'_>,
phase: crate::game::native_lifecycle::ServerTickPhase,
) {
if matches!(
phase,
crate::game::native_lifecycle::ServerTickPhase::ServerTickEnd { .. }
) {
crate::game::smp_glue::tick(game);
}
}
+193
View File
@@ -0,0 +1,193 @@
"""The SMP on a real dedicated server: the built game runs `--dedicated` with the mod, and two real clients (Alex and
Steve, `horizon-ctl --offscreen`) join it. Checked from the clients' own chat and from the files the server wrote:
- the economy opens an account for each player (config.json written with its defaults, then economy.json);
- /balance, /pay (the payer's answer and the payee's line), refused payments (too much, to oneself, unknown name);
- /baltop; /eco is refused below level 3 and works for an operator (made one from the server's console);
- the commands are in each player's command tree (the chat box's suggestions);
- after the server stops cleanly and starts again, the balances are the same, and audit.log holds every change.
Everything runs in fresh temporary folders (the server's, each client's home, launcher and XDG folders), no audio.
"""
import json
import os
import shutil
import socket
import subprocess
import tempfile
import threading
import time
from pathlib import Path
PROPERTIES = {
"online-mode": "false", "white-list": "false", "enforce-secure-profile": "false", "level-type": "minecraft\\:flat", "spawn-protection": "0", "max-players": "4",
"view-distance": "4", "simulation-distance": "4", "generate-structures": "false", "enable-command-block": "false",
}
def free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def scratch_env(root):
env = dict(os.environ, HORIZON_NO_AUDIO="1", HORIZON_LAUNCHER_NO_SELF_UPDATE="1")
for key in ("HORIZON_HOME", "HORIZON_LAUNCHER_HOME", "XDG_DATA_HOME", "XDG_CONFIG_HOME"):
path = root / key.lower()
path.mkdir(parents=True, exist_ok=True)
env[key] = str(path)
return env
class Server:
"""`horizon-client --dedicated --nogui` in a scratch server folder, its console on stdin."""
def __init__(self, game, resources, folder, port):
self.folder, self.lines, self.game, self.resources, self.port = folder, [], game, resources, port
folder.mkdir(parents=True, exist_ok=True)
props = dict(PROPERTIES, **{"server-port": str(port)})
(folder / "server.properties").write_text("".join(f"{k}={v}\n" for k, v in props.items()))
(folder / "eula.txt").write_text("eula=true\n")
self.env = scratch_env(folder.parent / "server-env")
def start(self):
self.lines = []
self.process = subprocess.Popen(
[str(self.game), "--resources", str(self.resources), "--dedicated", "--nogui"], cwd=self.folder,
env=self.env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
threading.Thread(target=self._read, daemon=True).start()
self.wait_for("Done (", 600)
def _read(self):
for line in self.process.stdout:
self.lines.append(line.rstrip("\n"))
def wait_for(self, text, timeout=120):
end = time.time() + timeout
while time.time() < end:
if any(text in line for line in self.lines):
return
if self.process.poll() is not None:
break
time.sleep(0.2)
raise AssertionError(f"server: no {text!r} in {timeout} s; last lines: {self.lines[-30:]}")
def console(self, line):
self.process.stdin.write(line + "\n")
self.process.stdin.flush()
def stop(self):
if self.process.poll() is None:
self.console("stop")
try:
self.process.wait(120)
except subprocess.TimeoutExpired:
self.process.kill()
return self.lines
class Client:
"""A real client under horizon-ctl, joined to the server as `name`."""
def __init__(self, ctl, resources, name, root):
self.ctl, self.name, self.resources = ctl, name, resources
self.home = root / name
self.env = scratch_env(self.home)
self.socket = str(self.home / "ctl.sock")
def run(self, *args, check=True):
done = subprocess.run([str(self.ctl), "--socket", self.socket, *map(str, args)], env=self.env,
cwd=self.home, capture_output=True, text=True, timeout=600)
if check and done.returncode:
raise AssertionError(f"{self.name}: {args}: {done.stdout[-400:]} {done.stderr[-400:]}")
try:
return json.loads(done.stdout.strip().splitlines()[-1])
except (json.JSONDecodeError, IndexError):
return done.stdout
def join(self, port):
self.run("launch", "--offscreen", "--resources", self.resources)
self.run("connect", f"127.0.0.1:{port}", self.name)
self.run("wait-for", "loaded", "--timeout", 300)
time.sleep(2)
def say(self, line):
"""A command typed in chat; returns the chat lines it brought."""
before = len(self.chat())
self.run("chat", line)
time.sleep(1.5)
return self.chat()[before:]
def chat(self):
"""Every chat line so far, oldest first (`messages` answers newest first)."""
reply = self.run("messages", 500)
return list(reversed(reply.get("chat", [])))
def quit(self):
self.run("quit", check=False)
def run(ctl, resources, out):
out = Path(out)
out.mkdir(parents=True, exist_ok=True)
game = Path(ctl).with_name("horizon-client")
root = Path(tempfile.mkdtemp(prefix="smp-proof-"))
port = free_port()
server = Server(game, resources, root / "server", port)
alex = Client(ctl, resources, "Alex", root)
steve = Client(ctl, resources, "Steve", root)
results = {}
try:
server.start()
alex.join(port)
steve.join(port)
time.sleep(2)
data = server.folder / "world" / "horizon-smp"
results["config"] = json.loads((data / "config.json").read_text())
assert results["config"] == {"currency": "coins", "starting_balance": 0}, results["config"]
server.console("op Alex")
time.sleep(1)
alex.say("/eco give Alex 500")
results["balance"] = alex.say("/balance")
assert any("Balance: 500 coins" in l for l in results["balance"]), results
results["pay"] = alex.say("/pay Steve 120")
assert any("You paid 120 coins to Steve. Balance: 380 coins" in l for l in results["pay"]), results
results["paid"] = steve.chat()[-3:]
assert any("Alex paid you 120 coins." in l for l in results["paid"]), results
results["refused"] = [alex.say(l) for l in ("/pay Steve 9999", "/pay Alex 1", "/pay Nobody 1")]
for lines, error in zip(results["refused"], ("You only have 380 coins.", "You can't pay yourself.",
"No player named Nobody has an account.")):
assert any(error in l for l in lines), results["refused"]
results["eco_refused"] = steve.say("/eco give Steve 1000")
assert not any("now has" in l for l in results["eco_refused"]), results
results["baltop"] = steve.say("/baltop")
assert any("1. Alex: 380 coins" in l for l in results["baltop"]), results
assert any("2. Steve: 120 coins" in l for l in results["baltop"]), results
steve.quit()
alex.quit()
lines = server.stop()
assert not any("panicked" in l for l in lines), lines[-40:]
economy = json.loads((data / "economy.json").read_text())
balances = {a["name"]: a["balance"] for a in economy["accounts"].values()}
results["economy.json"] = balances
assert balances == {"Alex": 380, "Steve": 120}, balances
audit = [json.loads(l) for l in (data / "audit.log").read_text().splitlines()]
results["audit"] = [a["kind"] for a in audit]
assert results["audit"] == ["open", "open", "eco_give", "pay"], results["audit"]
server.start()
alex = Client(ctl, resources, "Alex2", root)
alex.name = "Alex"
alex.join(port)
results["after_restart"] = alex.say("/balance")
assert any("Balance: 380 coins" in l for l in results["after_restart"]), results
alex.quit()
server.stop()
print(json.dumps({"proof": "smp", "ok": True, **results}, indent=2))
finally:
(out / "server.log").write_text("\n".join(server.lines))
for c in (alex, steve):
c.quit()
if server.__dict__.get("process") and server.process.poll() is None:
server.process.kill()
shutil.rmtree(root, ignore_errors=True)
+7
View File
@@ -0,0 +1,7 @@
//! The SMP's rules, as the bundle adds them to the game: each file is a
//! module of `game` there and of this crate here, under the same name, so
//! `super::smp_store` reaches the same code in both.
#[path = "../mixins/smp/smp_economy.rs"]
pub mod smp_economy;
#[path = "../mixins/smp/smp_store.rs"]
pub mod smp_store;