diff --git a/native/smp/Cargo.lock b/native/smp/Cargo.lock new file mode 100644 index 0000000..7191b60 --- /dev/null +++ b/native/smp/Cargo.lock @@ -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" diff --git a/native/smp/Cargo.toml b/native/smp/Cargo.toml new file mode 100644 index 0000000..c11154a --- /dev/null +++ b/native/smp/Cargo.toml @@ -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" diff --git a/native/smp/LICENSE b/native/smp/LICENSE new file mode 100644 index 0000000..e959a07 --- /dev/null +++ b/native/smp/LICENSE @@ -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. diff --git a/native/smp/README.md b/native/smp/README.md new file mode 100644 index 0000000..4b806e5 --- /dev/null +++ b/native/smp/README.md @@ -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 ` | 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 ` | 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 +``` + +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. diff --git a/native/smp/compatibility.json b/native/smp/compatibility.json new file mode 100644 index 0000000..dc321c0 --- /dev/null +++ b/native/smp/compatibility.json @@ -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" +} diff --git a/native/smp/mixins/smp/command.rs b/native/smp/mixins/smp/command.rs new file mode 100644 index 0000000..a6dd58b --- /dev/null +++ b/native/smp/mixins/smp/command.rs @@ -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); + } +} diff --git a/native/smp/mixins/smp/glue.rs b/native/smp/mixins/smp/glue.rs new file mode 100644 index 0000000..afd43a0 --- /dev/null +++ b/native/smp/mixins/smp/glue.rs @@ -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, +} + +static STATE: Mutex> = 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(game: &Game<'_>, f: impl FnOnce(&mut Smp) -> R) -> Option { + 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) { + for usage in [ + "balance [player:word]", + "bal [player:word]", + "pay ", + "baltop [page:integer]", + ] { + commands.push(NativeCommand::new(usage)); + } + for verb in ["give", "take", "set"] { + commands.push(NativeCommand::op( + &format!("eco {verb} "), + 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) { + 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; +} diff --git a/native/smp/mixins/smp/horizon-mixin.toml b/native/smp/mixins/smp/horizon-mixin.toml new file mode 100644 index 0000000..bb1c183 --- /dev/null +++ b/native/smp/mixins/smp/horizon-mixin.toml @@ -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", 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" }, +] diff --git a/native/smp/mixins/smp/lifecycle.rs b/native/smp/mixins/smp/lifecycle.rs new file mode 100644 index 0000000..ca4f2d8 --- /dev/null +++ b/native/smp/mixins/smp/lifecycle.rs @@ -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(); + } +} diff --git a/native/smp/mixins/smp/register.rs b/native/smp/mixins/smp/register.rs new file mode 100644 index 0000000..8d19650 --- /dev/null +++ b/native/smp/mixins/smp/register.rs @@ -0,0 +1,4 @@ +// The SMP's commands in every player's command tree. +fn smp_register_commands(commands: &mut Vec) { + crate::game::smp_glue::commands(commands); +} diff --git a/native/smp/mixins/smp/smp_economy.rs b/native/smp/mixins/smp/smp_economy.rs new file mode 100644 index 0000000..b007f77 --- /dev/null +++ b/native/smp/mixins/smp/smp_economy.rs @@ -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, +} + +/// 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 { + 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, + to: Option, + amount: i64, + by: &str, + ) -> serde_json::Value { + let after = |u: Option| 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 { + 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> { + 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 { + 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 { + let [who, amount] = args else { + return vec![Out::Error("Usage: /pay ".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 { + let page = match args { + [] => 1, + [p] => match p.parse::() { + 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 { + 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 ".into(), + )]; + }; + let amount = match amount.parse::() { + 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 ".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 { + 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::() + .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 { + 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 "), + ] { + 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(); + } +} diff --git a/native/smp/mixins/smp/smp_store.rs b/native/smp/mixins/smp/smp_store.rs new file mode 100644 index 0000000..86a7bc7 --- /dev/null +++ b/native/smp/mixins/smp/smp_store.rs @@ -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(path: &Path) -> Result { + 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(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, +} + +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 { + 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 = read_json(&path).unwrap(); + assert!(missing.is_empty()); + write_atomic(&path, b"{ not json").unwrap(); + assert!(read_json::>(&path).is_err()); + write_json(&path, &vec![1u32, 2]).unwrap(); + assert_eq!(read_json::>(&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); + } +} diff --git a/native/smp/mixins/smp/tick.rs b/native/smp/mixins/smp/tick.rs new file mode 100644 index 0000000..bd4a171 --- /dev/null +++ b/native/smp/mixins/smp/tick.rs @@ -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); + } +} diff --git a/native/smp/proof.py b/native/smp/proof.py new file mode 100644 index 0000000..542e14f --- /dev/null +++ b/native/smp/proof.py @@ -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) diff --git a/native/smp/src/lib.rs b/native/smp/src/lib.rs new file mode 100644 index 0000000..a4ee4a9 --- /dev/null +++ b/native/smp/src/lib.rs @@ -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;