mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 11:05:58 -10:00
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:
15 files changed
+1446
No files matched your search
Generated
+107
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" },
|
||||
]
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user