SMP 0.34.0: /rtp 500-10,000 blocks every 5 minutes, /report, /ledger

- /rtp: rtp_max_distance 5000 -> 10000, rtp_cooldown_seconds 1800 -> 300
  (defaults; the official config.json has no rtp_* keys, so they apply).
  Same safety: no water, lava or void, chunks loaded with forceload.
- /report <player> <reason> for everyone: a reason is required, one report
  per player every reports.cooldown_seconds (120). Appended to reports.log,
  told at once to the level-3 operators on, logged in the server log; an
  operator who joins later hears how many came in the last 24 hours.
  /reports [page] lists them for operators. (No Kira route exists for
  messages to Discord; Kira only takes the market/economy pages.)
- /ledger for level-3 operators: audit.log already records every coin
  movement before it happens; /ledger [page], /ledger <player> [page] and
  /ledger minted (coins made and destroyed per hour, last 6 h) read the
  end of it (1 MB) when asked, nothing on the tick. Economy::adjust lines
  now carry flow: minted|burned so the hourly count is exact.
- utc_minute and tail() moved to smp_store for both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Horizon agentandClaude Opus 5.5 committed 2026-11-05 12:39:24 +01:00
1 parent 78853c19a6
commit e44816b65a
13 files changed
+940 -45

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "horizon-mod-smp"
version = "0.33.0"
version = "0.34.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "horizon-mod-smp"
version = "0.33.0"
version = "0.34.0"
edition = "2024"
license = "MIT"
publish = false
+8 -4
View File
@@ -43,7 +43,7 @@ header and footer).
| `/home [name]`, `/sethome [name]`, `/delhome [name]`, `/homes` | your homes (`home` when unnamed): go, set or move one where you stand, delete, list |
| `/warp <name>`, `/warps` | to a warp, the list; `/setwarp <name>`, `/delwarp <name>` for operators of level 3 |
| `/tpa <player>`, `/tpaccept [player]`, `/tpdeny [player]` | asks to go to a player (60 s to answer); answers a request |
| `/rtp`, `/wild` | to a random safe spot 500 to 5,000 blocks from the spawn, in the Overworld only; once every 30 minutes |
| `/rtp`, `/wild` | to a random safe spot 500 to 10,000 blocks from the spawn, in the Overworld only; once every 5 minutes |
| `/rules` | the server's rules |
| `/back` | back to where you died, or where your last teleport took you from (warmup and cooldown as any teleport) |
| `/afk` | marks you away from the keyboard until you move, turn or run a command |
@@ -59,6 +59,9 @@ header and footer).
| `/invsee <player>`, `/endersee <player>` | operators of level 3: the player's inventory (main, hotbar, armour, off hand) or ender chest in a chest menu, on or off the server; click one of theirs to take it, one of yours to give it; every move in `audit.log`, a summary in `staff.log` |
| `/sidebar off`, `/sidebar on`, `/sidebar` | hides the scoreboard sidebar for you (remembered), shows it again |
| `/smp status`, `/smp off [part\|all] [reason]`, `/smp on [part\|all]` | operators of level 3: the emergency switches (below) |
| `/report <player> <reason>` | anyone: tells the staff about a player (a reason is needed; one report every `reports.cooldown_seconds`, 120). Operators of level 3 on read it at once; one who joins later is told how many came in the last 24 hours. Kept in `reports.log` and the server's log |
| `/reports [page]` | operators of level 3: the reports, newest first (the last 100 kept in memory, read back from `reports.log` at the start) |
| `/ledger [page]`, `/ledger <player> [page]`, `/ledger minted` | operators of level 3: the coin ledger (`audit.log`): the newest coin movements (who to whom, how much, why, when), one player's, or the coins the server made and destroyed in each of the last 6 hours and what made most of them. Reads the end of the file (1 MB) only when asked |
The market trades only plain items (not worn, named or enchanted), and only with players in survival or adventure
mode. Items move first, then money, both logged; a sale the log refuses gives the items back, and a purchase checks the
@@ -230,7 +233,7 @@ lines. `/spawn` lands on a random safe column within `spawn_radius` (250) blocks
as any teleport does.
`/rtp` (or `/wild`) draws `rtp_attempts` (6) chunks at random between `rtp_min_distance` (500) and `rtp_max_distance`
(5000) blocks from the spawn (`/setspawn`'s point, 0 0 without one), uniform over the ring's area and at least 16 blocks
(10000) blocks from the spawn (`/setspawn`'s point, 0 0 without one), uniform over the ring's area and at least 16 blocks
inside the world border (as last saved in `world_border.dat`), then holds them loaded one after another with vanilla's
`forceload` while the warmup runs: the game generates them on its worker threads, never on the server tick. Once a
chunk is loaded, the Overworld's tick reads its 256 columns (`native_read_columns`, the top block below the
@@ -238,7 +241,7 @@ chunk is loaded, the Overworld's tick reads its 256 columns (`native_read_column
whose top block is solid ground (no fluid, fire, cactus, magma, powder snow, berry bush or dripstone) with air or grass
above it, outside another player's claim and still in the ring and the border; with none the next chunk is tried. With
no spot after the six, or no land loaded `rtp_search_seconds` (30) after the warmup, it says so and nothing is spent.
A teleport made starts `rtp_cooldown_seconds` (1800) for that player, kept in `places.json` over restarts ("Wait 12 min
A teleport made starts `rtp_cooldown_seconds` (300) for that player, kept in `places.json` over restarts ("Wait 2 min
before your next /rtp."); a teleport called off (a move, a hit, the game failing it) doesn't. Elsewhere than the
Overworld it refuses. The chunks it forced are let go 10 s after the landing, or at the next start if the server
stopped first; a chunk an operator forced already is used and left forced. `/back` returns from it as from any teleport.
@@ -349,6 +352,7 @@ folder is untouched until the mod first runs on it; it changes no block and no p
| `places.json` | the spawn, the warps, each player's homes, and the seconds each player has played (written once a minute) |
| `nicks.json` | each player's nickname and when they last changed it |
| `vanish.json` | the vanished operators by UUID, with their name |
| `reports.log` | one JSON line per `/report`: `time`, `by`/`by_name`, `player`/`player_name`, `reason`, where each stood (`by_at`, `player_at` when on) |
| `staff.log` | one line per `/invsee` or `/endersee` menu in which something moved: who, whose, what was taken and put |
| `market_prices.json` | each item's price factor and history (minutes for 24 h, hours for 30 days: `[time, mid, traded, sold]`), the day's summary |
| `web/market.json` | for the website (`/hub/market`, through Kira), written by a thread each minute prices move: `schema` 1, `generated`, `currency`, `tax_percent` (always 0, for older readers), `daily_summary` (`day`, `lines`), `items` (`id`, `name`, `category`, `price` (the one price; `buy` and `sell` repeat it on the sides the market trades, for older readers), `mid`, `base`, `change_1h`, `change_24h`, `volume_24h`, `day`: 5-minute points, `month`: hours, each `{step, t, price, volume}`). When `web.token` is set, the same thread POSTs each page of `web/` that changed (gzipped, `Authorization: Bearer <token>`) to `<web.url>/<name>`: Kira keeps the last one and serves it to the site; the server needs no inbound access and Kira no key to it |
@@ -357,7 +361,7 @@ folder is untouched until the mod first runs on it; it changes no block and no p
| `switches.json` | the parts operators turned off (`/smp off`): by whom, when and why |
| `casino.json` | the day's ledger: each player's bets, wins and rounds today (the daily loss cap), the day's biggest wins, each game's totals, the pot's last sweep |
| `claims.json` | each claimed chunk (`<dimension> <chunk x> <chunk z>`) with its owner, and whom each owner trusts |
| `audit.log` | one JSON line per change, written before the change: `time` (ms), `kind` (`open`, `pay`, `grant`, `ah_listing_fee`...), `amount`, `from`/`to` with their balance before, `by` for an operator, `to_pool`/`from_pool` with `pool_before` and `reason` for a pool's movement; a trade's line (`trade`: both players, their items and money) comes before its swap, `trade_failed` after one that didn't go through |
| `audit.log` | one JSON line per change, written before the change: `time` (ms), `kind` (`open`, `pay`, `grant`, `ah_listing_fee`...), `amount`, `from`/`to` with their balance before, `by` for an operator, `to_pool`/`from_pool` with `pool_before` and `reason` for a pool's movement, `flow` (`minted` or `burned`) for money the server made or destroyed (a market sale or purchase, the daily reward, `/eco give` and `take`); a trade's line (`trade`: both players, their items and money) comes before its swap, `trade_failed` after one that didn't go through |
`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
+49 -2
View File
@@ -11,6 +11,8 @@ use crate::game::{
Game,
smp_auction::{self, AuctionHouse},
smp_report::{self, Report},
smp_ledger,
smp_reports::{self, Reports},
native_server::{NativeCommand, NativeServerPlayer, NativeSidebar},
smp_board::{self, Board, Line},
smp_casino::{self, Casino},
@@ -41,7 +43,7 @@ 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.33.0";
pub const VERSION: &str = "0.34.0";
/// `config.json` in `world/horizon-smp/`, written with its defaults the
/// first time, and again with the keys a newer version adds (values kept).
@@ -61,6 +63,8 @@ pub struct Config {
pub nicks: smp_nick::Config,
pub auction: smp_auction::Config,
pub report: smp_report::Config,
/// `/report`: players telling the staff about another player.
pub reports: smp_reports::Config,
pub web: WebPush,
}
@@ -133,6 +137,8 @@ pub struct Smp {
vanish_titled: std::collections::HashSet<u128>,
/// The parts operators turned off (`/smp off`).
switches: Switches,
/// `/report` and `/reports`.
reports: Reports,
}
static STATE: Mutex<Option<Smp>> = Mutex::new(None);
@@ -195,6 +201,7 @@ fn open(dir: PathBuf) -> Smp {
}
let auction = economy.as_ref().ok().map(|e| AuctionHouse::open(e, config.auction));
let report = economy.as_ref().ok().map(|e| Report::open(e, config.report));
let reports = Reports::open(&dir, config.reports);
let places = Places::open(&dir, config.places);
if let Err(e) = &places {
log::error!("[smp] homes and warps are off: {e}");
@@ -273,6 +280,7 @@ fn open(dir: PathBuf) -> Smp {
vanish,
vanish_titled: Default::default(),
switches,
reports,
}
}
@@ -346,6 +354,7 @@ fn save(smp: &mut Smp) {
if let Err(err) = smp.switches.save() {
log::error!("[smp] {err}");
}
smp.reports.save();
}
/// The commands, for every player's command tree.
@@ -422,6 +431,7 @@ pub fn commands(commands: &mut Vec<NativeCommand>) {
"ah mail",
"eco stats",
"money stats",
"report <player:word> [reason:text]",
] {
commands.push(NativeCommand::new(usage));
}
@@ -432,6 +442,10 @@ pub fn commands(commands: &mut Vec<NativeCommand>) {
"nick <name:word> <nick:word>",
"vanish",
"vanish list",
"reports [page:integer]",
"ledger",
"ledger minted",
"ledger <player:word> [page:integer]",
] {
commands.push(NativeCommand::op(usage, 3));
}
@@ -734,6 +748,8 @@ pub(in crate::game) fn perform(
&smp_nick::COMMANDS,
&smp_vanish::COMMANDS,
&smp_switch::COMMANDS,
&smp_reports::COMMANDS,
&smp_ledger::COMMANDS,
];
if !game.native_on(ID) || !ours.iter().any(|c| c.contains(&word)) {
return;
@@ -809,7 +825,33 @@ pub(in crate::game) fn perform(
n.resolve(line, &is_name, &name_of)
});
let line = resolved.as_deref().unwrap_or(line);
let out = if smp_vanish::COMMANDS.contains(&word) {
let out = if smp_reports::COMMANDS.contains(&word) {
let staff: Vec<u128> = players.iter().filter(|p| p.permission >= smp_reports::LEVEL).map(|p| p.uuid).collect();
let economy = smp.economy.as_ref().ok();
let find = |n: &str| {
let on = players.iter().find(|p| p.name.eq_ignore_ascii_case(n));
on.map(|p| (p.uuid, p.name.clone())).or_else(|| ServerNames::read(&dir, economy).find(n))
};
let at = |u: u128| {
let p = players.iter().find(|p| p.uuid == u)?;
let [x, y, z] = p.position.map(|v| v.floor() as i64);
Some(format!("{} {x} {y} {z}", p.dimension))
};
let out = smp.reports.command(&me, &find, &at, &staff, now, line).unwrap_or_default();
for l in smp.reports.logged.drain(..) {
log::info!("[smp] {l}");
}
out
} else if smp_ledger::COMMANDS.contains(&word) {
let economy = smp.economy.as_ref().ok();
let find = |n: &str| {
let on = players.iter().find(|p| p.name.eq_ignore_ascii_case(n));
on.map(|p| (p.uuid, p.name.clone())).or_else(|| economy?.find(n).map(|(u, a)| (u, a.name.clone())))
};
let name_of = |u: u128| economy?.name_of(u).map(str::to_owned);
let money = |n: i64| economy.map_or_else(|| n.to_string(), |e| e.money(n));
smp_ledger::command(&me, &smp.dir.join("audit.log"), &find, &name_of, &money, now, line).unwrap_or_default()
} else if smp_vanish::COMMANDS.contains(&word) {
let ops: Vec<u128> = players.iter().filter(|p| p.permission >= smp_vanish::LEVEL).map(|p| p.uuid).collect();
match &mut smp.vanish {
Ok(v) => {
@@ -2499,6 +2541,11 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
}
}
}
// `/report`: operators who came on hear of the last day's reports.
if n % 20 == 0 {
let on: Vec<(u128, u8)> = players.iter().map(|p| (p.uuid, p.permission)).collect();
out.extend(smp.reports.staff_joined(&on, now));
}
// `/claim show`: the border once a second, to that player only.
let mut particles = Vec::new();
if n % 20 == 0 {
+13 -1
View File
@@ -1,6 +1,6 @@
[mod]
id = "smp"
version = "0.33.0"
version = "0.34.0"
priority = 100
# The commit of horizon it was tested on: it needs native_hide_player (horizon#7003, API 314, stamped at 48c63bc),
@@ -170,6 +170,18 @@ file = "crates/horizon-client/src/game.rs"
name = "smp_invsee_glue"
source = "invsee_glue.rs"
[[module]]
id = "reports"
file = "crates/horizon-client/src/game.rs"
name = "smp_reports"
source = "smp_reports.rs"
[[module]]
id = "ledger"
file = "crates/horizon-client/src/game.rs"
name = "smp_ledger"
source = "smp_ledger.rs"
[[module]]
id = "switch"
file = "crates/horizon-client/src/game.rs"
+4
View File
@@ -752,6 +752,10 @@ impl Economy {
if !reason.is_empty() {
entry["reason"] = reason.into();
}
// Money the server made or destroyed, for `/ledger minted`.
if delta != 0 {
entry["flow"] = if delta > 0 { "minted" } else { "burned" }.into();
}
self.audit.append(&entry)?;
self.accounts
.accounts
+334
View File
@@ -0,0 +1,334 @@
// The coin ledger: `audit.log` (smp_economy) already holds one line for every
// coin that moves (who, how much, why, when), appended before the change.
// `/ledger` lets operators of `LEVEL` read it in game: the newest movements,
// one player's, or what the server made and destroyed hour by hour. Only
// the end of the file is read (`TAIL`), from the newest line back, and only
// when an operator asks: nothing on the tick. No game types. Added to the
// game as `game::smp_ledger`; also a module of this crate, for its tests.
use super::smp_economy::{Out, Player};
use super::smp_store;
use serde_json::Value;
use std::path::Path;
/// The operator level that reads the ledger.
pub const LEVEL: u8 = 3;
pub const COMMANDS: [&str; 1] = ["ledger"];
/// The end of `audit.log` a `/ledger` reads (some hours of a busy day).
const TAIL: u64 = 1 << 20;
/// Lines a page.
const PAGE: usize = 10;
/// Hours `/ledger minted` counts.
const HOURS: i64 = 6;
const HOUR_MS: i64 = 3_600_000;
/// `/ledger [page]`, `/ledger <player> [page]`, `/ledger minted`; None for
/// another command. `audit`: the log's path; `find`: a player by name;
/// `name_of`: a UUID's name; `money`: an amount as players read it.
pub fn command(
me: &Player,
audit: &Path,
find: &dyn Fn(&str) -> Option<(u128, String)>,
name_of: &dyn Fn(u128) -> Option<String>,
money: &dyn Fn(i64) -> String,
now: i64,
line: &str,
) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
if words.first() != Some(&"ledger") {
return None;
}
if me.level < LEVEL {
return Some(vec![Out::Error(
"Only operators can read the ledger.".into(),
)]);
}
let text = smp_store::tail(audit, TAIL);
let newest = text
.lines()
.rev()
.filter_map(|l| serde_json::from_str::<Value>(l).ok());
let page = |w: Option<&&str>| w.and_then(|p| p.parse::<usize>().ok()).unwrap_or(1).max(1);
let (who, page) = match words[1..] {
["minted"] => return Some(minted(newest, money, now)),
[] => (None, 1),
[w] if w.parse::<usize>().is_ok() && find(w).is_none() => (None, page(Some(&w))),
[w, ..] => match find(w) {
Some(p) => (Some(p), page(words.get(2))),
None => return Some(vec![Out::Error("No player was found".into())]),
},
};
let key = who.as_ref().map(|(u, _)| smp_store::uuid_string(*u));
let mine = |e: &Value| {
key.as_deref()
.is_none_or(|k| e["from"].as_str() == Some(k) || e["to"].as_str() == Some(k))
};
let rows: Vec<String> = newest
.filter(|e| {
e["amount"].as_i64().unwrap_or(0) > 0
&& (e.get("from").is_some() || e.get("to").is_some())
})
.filter(mine)
.skip((page - 1) * PAGE)
.take(PAGE)
.map(|e| row(&e, name_of, money))
.collect();
let whose = who.map_or("Coin movements".to_owned(), |(_, n)| {
format!("{n}'s coin movements")
});
if rows.is_empty() {
return Some(vec![Out::Reply(format!(
"{whose}: none in the newest part of the ledger (page {page})."
))]);
}
let mut out = vec![Out::Reply(format!(
"{whose}, newest first (page {page}, UTC):"
))];
out.extend(rows.into_iter().map(Out::Reply));
Some(out)
}
/// `11-05 12:03 pay: Alex → Steve, 300 coins`.
fn row(
e: &Value,
name_of: &dyn Fn(u128) -> Option<String>,
money: &dyn Fn(i64) -> String,
) -> String {
let player = |side: &str| {
let key = e[side].as_str()?;
Some(
smp_store::parse_uuid(key)
.and_then(name_of)
.unwrap_or_else(|| key.to_owned()),
)
};
let pool = e["pool"].as_str().map(|p| format!("the {p} pool"));
let from = player("from")
.or_else(|| e["from_pool"].as_str().map(|p| format!("the {p} pool")))
.or_else(|| pool.clone().filter(|_| e.get("to").is_some()))
.unwrap_or_else(|| "the server".into());
let to = player("to")
.or_else(|| e["to_pool"].as_str().map(|p| format!("the {p} pool")))
.or_else(|| pool.filter(|_| e.get("from").is_some()))
.unwrap_or_else(|| "the server".into());
let when = smp_store::utc_minute(e["time"].as_i64().unwrap_or(0));
let why = e["reason"]
.as_str()
.map(|r| format!(" ({r})"))
.unwrap_or_default();
format!(
"{} {}{why}: {from} → {to}, {}",
when.get(5..).unwrap_or(&when),
e["kind"].as_str().unwrap_or("?"),
money(e["amount"].as_i64().unwrap_or(0))
)
}
/// `/ledger minted`: what the server made and destroyed in each of the last
/// hours (UTC), and what made most of it.
fn minted(
newest: impl Iterator<Item = Value>,
money: &dyn Fn(i64) -> String,
now: i64,
) -> Vec<Out> {
let first = (now.div_euclid(HOUR_MS) - (HOURS - 1)) * HOUR_MS;
let mut hours =
vec![(0i64, 0i64, std::collections::BTreeMap::<String, i64>::new()); HOURS as usize];
for e in newest {
let t = e["time"].as_i64().unwrap_or(0);
if t < first {
break;
}
let i = ((t - first) / HOUR_MS).clamp(0, HOURS - 1) as usize;
let amount = e["amount"].as_i64().unwrap_or(0);
let kind = e["kind"]
.as_str()
.unwrap_or("?")
.split(' ')
.next()
.unwrap_or("?")
.to_owned();
match e["flow"].as_str() {
Some("minted") => {
hours[i].0 += amount;
*hours[i].2.entry(kind).or_default() += amount;
}
Some("burned") => hours[i].1 += amount,
_ => {}
}
}
let mut out = vec![Out::Reply(
"Coins the server made and destroyed, by hour (UTC):".into(),
)];
for (i, (made, gone, kinds)) in hours.iter().enumerate().rev() {
let start = smp_store::utc_minute(first + i as i64 * HOUR_MS);
let top = kinds
.iter()
.max_by_key(|k| k.1)
.map(|(k, _)| format!(", mostly {k}"))
.unwrap_or_default();
out.push(Out::Reply(format!(
"{}: made {}{top}, destroyed {}",
start.get(11..).unwrap_or(&start),
money(*made),
money(*gone)
)));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
const ALEX: u128 = 1;
const STEVE: u128 = 2;
fn dir() -> std::path::PathBuf {
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
let d = std::env::temp_dir().join(format!("smp-ledger-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn find(name: &str) -> Option<(u128, String)> {
[(ALEX, "Alex"), (STEVE, "Steve")]
.into_iter()
.find(|p| p.1.eq_ignore_ascii_case(name))
.map(|(u, n)| (u, n.to_owned()))
}
fn name_of(u: u128) -> Option<String> {
[(ALEX, "Alex"), (STEVE, "Steve")]
.into_iter()
.find(|p| p.0 == u)
.map(|p| p.1.to_owned())
}
fn money(n: i64) -> String {
format!("{n} coins")
}
fn op() -> Player {
Player {
uuid: 9,
name: "Op".into(),
level: 3,
}
}
/// The real economy writes the log: what it records is what `/ledger` reads.
#[test]
fn the_ledger_reads_back_what_the_economy_wrote() {
use super::super::smp_economy::{Config, Economy};
let d = dir();
let mut e = Economy::open(&d, Config::default()).unwrap();
e.now = 1_793_880_000_000; // 2026-11-05 12:00 UTC
e.seen(ALEX, "Alex");
e.seen(STEVE, "Steve");
e.adjust("sell 11 minecraft:birch_log", ALEX, 500, "")
.unwrap();
e.now += 60_000;
e.transfer("pay", ALEX, STEVE, 300).unwrap();
e.now += 60_000;
e.adjust("buy 1 minecraft:cooked_beef", STEVE, -7, "")
.unwrap();
e.save().unwrap();
let audit = d.join("audit.log");
let run =
|line: &str| command(&op(), &audit, &find, &name_of, &money, e.now, line).unwrap();
assert_eq!(
run("ledger"),
vec![
Out::Reply("Coin movements, newest first (page 1, UTC):".into()),
Out::Reply(
"11-05 12:02 buy 1 minecraft:cooked_beef: Steve → the server, 7 coins".into()
),
Out::Reply("11-05 12:01 pay: Alex → Steve, 300 coins".into()),
Out::Reply(
"11-05 12:00 sell 11 minecraft:birch_log: the server → Alex, 500 coins".into()
),
]
);
let alex = run("ledger alex");
assert_eq!(alex.len(), 3, "the sale and the payment: {alex:?}");
assert_eq!(
alex[0],
Out::Reply("Alex's coin movements, newest first (page 1, UTC):".into())
);
assert!(matches!(&run("ledger alex 2")[0], Out::Reply(s) if s.contains("none")));
assert_eq!(
run("ledger nobody"),
vec![Out::Error("No player was found".into())]
);
let minted = run("ledger minted");
assert_eq!(minted.len(), 1 + HOURS as usize);
assert_eq!(
minted[1],
Out::Reply("12:00: made 500 coins, mostly sell, destroyed 7 coins".into())
);
assert_eq!(
minted[2],
Out::Reply("11:00: made 0 coins, destroyed 0 coins".into())
);
// Not for players.
let player = Player {
uuid: ALEX,
name: "Alex".into(),
level: 0,
};
assert_eq!(
command(&player, &audit, &find, &name_of, &money, e.now, "ledger"),
Some(vec![Out::Error(
"Only operators can read the ledger.".into()
)])
);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn pools_and_pages_read_as_they_should() {
let d = dir();
let audit = d.join("audit.log");
let mut lines = String::new();
for i in 0..25 {
lines.push_str(&format!(
"{{\"amount\":{},\"from\":\"00000000-0000-0000-0000-000000000001\",\"kind\":\"market_tax\",\"reason\":\"sell 4 minecraft:diamond\",\"time\":{},\"to_pool\":\"treasury\"}}\n",
i + 1,
1_793_880_000_000i64 + i * 1000
));
}
lines.push_str("{\"amount\":0,\"kind\":\"open\",\"time\":1793880100000,\"to\":\"00000000-0000-0000-0000-000000000002\"}\n");
std::fs::write(&audit, lines).unwrap();
let run = |line: &str| {
command(
&op(),
&audit,
&find,
&name_of,
&money,
1_793_880_100_000,
line,
)
.unwrap()
};
let first = run("ledger");
assert_eq!(
first.len(),
1 + PAGE,
"the opened account (0 coins) isn't a movement"
);
assert_eq!(
first[1],
Out::Reply("11-05 12:00 market_tax (sell 4 minecraft:diamond): Alex → the treasury pool, 25 coins".into())
);
let third = run("ledger 3");
assert_eq!(third.len(), 1 + 5);
assert!(
matches!(&third[5], Out::Reply(s) if s.ends_with(", 1 coins")),
"{third:?}"
);
let _ = std::fs::remove_dir_all(d);
}
}
+8 -10
View File
@@ -62,8 +62,8 @@ impl Default for Config {
],
spawn_radius: 250,
rtp_min_distance: 500,
rtp_max_distance: 5000,
rtp_cooldown_seconds: 1800,
rtp_max_distance: 10_000,
rtp_cooldown_seconds: 300,
rtp_attempts: 6,
rtp_search_seconds: 30,
}
@@ -1390,7 +1390,7 @@ mod tests {
});
assert_eq!(go.len(), 1);
let t = &go[0];
assert!((500.0..=5000.0).contains(&distance(t)), "{:?}", t.to);
assert!((500.0..=10_000.0).contains(&distance(t)), "{:?}", t.to);
assert!(t.to.x.abs() <= 984.0 && t.to.z.abs() <= 984.0, "{:?}", t.to);
assert_eq!((t.to.dimension.as_str(), t.to.y), (OVERWORLD, 65.0));
assert_eq!(t.spread_line(), None, "the spot is exact");
@@ -1432,14 +1432,14 @@ mod tests {
);
p.tick(&[body(A, 1.0)], 20_500);
let out = p
.command(&player(A, 0), &here, 18 * 60_000 + 3_000, "rtp")
.command(&player(A, 0), &here, 3 * 60_000 + 3_000, "rtp")
.unwrap();
assert_eq!(
out,
vec![Out::Error("Wait 12 min before your next /rtp.".into())]
vec![Out::Error("Wait 2 min before your next /rtp.".into())]
);
let out = p
.command(&player(A, 0), &here, 1_800_000 + 2_500, "wild")
.command(&player(A, 0), &here, 300_000 + 2_500, "wild")
.unwrap();
assert_eq!(
out,
@@ -1452,11 +1452,9 @@ mod tests {
let out = again.command(&player(A, 0), &here, 60_000, "rtp").unwrap();
assert_eq!(
out,
vec![Out::Error("Wait 30 min before your next /rtp.".into())]
vec![Out::Error("Wait 5 min before your next /rtp.".into())]
);
let out = again
.command(&player(A, 0), &here, 1_803_000, "rtp")
.unwrap();
let out = again.command(&player(A, 0), &here, 303_000, "rtp").unwrap();
assert!(
matches!(&out[0], Out::Reply(_)),
"allowed after it: {out:?}"
+441
View File
@@ -0,0 +1,441 @@
// `/report <player> <reason>`: any player tells the staff about another one.
// The report is appended to `reports.log` (one JSON object a line), every
// operator of `LEVEL` on reads it at once, and an operator who joins later
// is told how many came in the last day; `/reports [page]` lists them. A
// reason is required, and a player reports at most once every
// `cooldown_seconds` (120). No game types. Added to the game as
// `game::smp_reports`; also a module of this crate, for its tests.
use super::smp_economy::{Out, Player};
use super::smp_store::{self, Audit};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;
#[cfg(test)]
use std::path::PathBuf;
/// The operator level that reads reports.
pub const LEVEL: u8 = 3;
pub const COMMANDS: [&str; 2] = ["report", "reports"];
/// Reports kept in memory for `/reports` (the file keeps them all).
const KEEP: usize = 100;
/// `/reports` lines a page.
const PAGE: usize = 8;
/// The end of `reports.log` read at the start.
const TAIL: u64 = 256 * 1024;
const DAY_MS: i64 = 86_400_000;
/// `config.json`'s `reports`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub enabled: bool,
/// Between two reports of one player.
pub cooldown_seconds: i64,
/// Longer reasons are cut to this many characters.
pub max_reason: usize,
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: true,
cooldown_seconds: 120,
max_reason: 200,
}
}
}
/// One report, as `reports.log` keeps it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Entry {
/// When (ms since 1970).
pub time: i64,
/// Who reported (UUID, name).
pub by: String,
pub by_name: String,
/// Who was reported (UUID, name).
pub player: String,
pub player_name: String,
pub reason: String,
/// Where the reporter stood, and the reported player if on.
pub by_at: String,
pub player_at: String,
}
pub struct Reports {
pub config: Config,
file: Audit,
recent: VecDeque<Entry>,
/// The last report of each player (this run).
last: HashMap<u128, i64>,
/// The operators on at the last look, so one who joins is told once.
staff_on: HashSet<u128>,
/// Lines for the server's log (each report), taken by the glue.
pub logged: Vec<String>,
}
impl Reports {
/// `reports.log` in `dir`: its last reports are read back for `/reports`.
pub fn open(dir: &Path, config: Config) -> Self {
let path = dir.join("reports.log");
let mut recent: VecDeque<Entry> = smp_store::tail(&path, TAIL)
.lines()
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
while recent.len() > KEEP {
recent.pop_front();
}
Self {
config,
file: Audit::new(path),
recent,
last: HashMap::new(),
staff_on: HashSet::new(),
logged: Vec::new(),
}
}
/// Writes what was appended to disk.
pub fn save(&mut self) {
self.file.sync();
}
/// `/report <player> <reason>` and `/reports [page]`; None for another
/// command. `find`: a player who has played here, by name (UUID, name);
/// `at`: where a player on stands; `staff`: the operators of `LEVEL` on.
pub fn command(
&mut self,
me: &Player,
find: &dyn Fn(&str) -> Option<(u128, String)>,
at: &dyn Fn(u128) -> Option<String>,
staff: &[u128],
now: i64,
line: &str,
) -> Option<Vec<Out>> {
let mut words = line.split_whitespace();
match words.next()? {
"report" => {}
"reports" => return Some(self.list(me, words.next())),
_ => return None,
}
if !self.config.enabled {
return Some(vec![Out::Error(
"/report is turned off on this server.".into(),
)]);
}
let Some(name) = words.next() else {
return Some(vec![Out::Error("Usage: /report <player> <reason>".into())]);
};
let reason: String = words
.collect::<Vec<_>>()
.join(" ")
.chars()
.take(self.config.max_reason.max(1))
.collect();
if reason.is_empty() {
return Some(vec![Out::Error(
"Say why: /report <player> <reason>".into(),
)]);
}
let Some((player, player_name)) = find(name) else {
return Some(vec![Out::Error("No player was found".into())]);
};
if player == me.uuid {
return Some(vec![Out::Error("You can't report yourself.".into())]);
}
if let Some(&t) = self.last.get(&me.uuid) {
let wait = t + self.config.cooldown_seconds * 1000 - now;
if wait > 0 {
return Some(vec![Out::Error(format!(
"Wait {} before your next report.",
wait_text(wait)
))]);
}
}
let entry = Entry {
time: now,
by: smp_store::uuid_string(me.uuid),
by_name: me.name.clone(),
player: smp_store::uuid_string(player),
player_name: player_name.clone(),
reason,
by_at: at(me.uuid).unwrap_or_default(),
player_at: at(player).unwrap_or_default(),
};
let json = serde_json::to_value(&entry).unwrap_or_default();
if let Err(e) = self.file.append(&json) {
self.logged
.push(format!("a report couldn't be written: {e}"));
return Some(vec![Out::Error(
"Your report couldn't be saved: please tell a staff member.".into(),
)]);
}
self.last.insert(me.uuid, now);
self.logged.push(format!(
"report by {} on {}: {} ({})",
entry.by_name,
entry.player_name,
entry.reason,
if entry.by_at.is_empty() {
"?"
} else {
&entry.by_at
}
));
let mut out = vec![Out::Reply(format!(
"Thanks, your report on {player_name} was sent to the staff."
))];
let others: Vec<u128> = staff.iter().copied().filter(|&u| u != me.uuid).collect();
if others.is_empty() {
out.push(Out::Reply(
"No staff member is on right now: they will read it when they are.".into(),
));
}
for u in others {
out.push(Out::Tell(u, format!("[Report] {}", row(&entry))));
}
self.recent.push_back(entry);
if self.recent.len() > KEEP {
self.recent.pop_front();
}
Some(out)
}
/// `/reports [page]`: the newest first.
fn list(&self, me: &Player, page: Option<&str>) -> Vec<Out> {
if me.level < LEVEL {
return vec![Out::Error("Only operators can read reports.".into())];
}
if self.recent.is_empty() {
return vec![Out::Reply("No reports.".into())];
}
let pages = self.recent.len().div_ceil(PAGE);
let page = page
.and_then(|p| p.parse::<usize>().ok())
.unwrap_or(1)
.clamp(1, pages);
let mut out = vec![Out::Reply(format!(
"Reports, newest first (page {page} of {pages}):"
))];
for e in self.recent.iter().rev().skip((page - 1) * PAGE).take(PAGE) {
out.push(Out::Reply(format!(
"{} {}",
smp_store::utc_minute(e.time),
row(e)
)));
}
out
}
/// Once a second, the players on with their operator level: each
/// operator who came on is told the reports of the last day.
pub fn staff_joined(&mut self, on: &[(u128, u8)], now: i64) -> Vec<Out> {
let staff: HashSet<u128> = on.iter().filter(|p| p.1 >= LEVEL).map(|p| p.0).collect();
let day = self.recent.iter().filter(|e| e.time > now - DAY_MS).count();
let out = staff
.iter()
.filter(|u| !self.staff_on.contains(u) && day > 0)
.map(|&u| {
let s = if day == 1 { "" } else { "s" };
Out::Tell(
u,
format!("{day} player report{s} in the last 24 hours: /reports"),
)
})
.collect();
self.staff_on = staff;
out
}
}
/// `Alex reported Steve: griefing (overworld 12 64 -30)`.
fn row(e: &Entry) -> String {
let at = if e.player_at.is_empty() {
&e.by_at
} else {
&e.player_at
};
let at = if at.is_empty() {
String::new()
} else {
format!(" ({at})")
};
format!("{} reported {}: {}{at}", e.by_name, e.player_name, e.reason)
}
/// `1 min 40 s`, `40 s`.
fn wait_text(ms: i64) -> String {
let s = (ms + 999) / 1000;
match (s / 60, s % 60) {
(0, s) => format!("{s} s"),
(m, 0) => format!("{m} min"),
(m, s) => format!("{m} min {s} s"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
const ALEX: u128 = 1;
const STEVE: u128 = 2;
const OP: u128 = 3;
fn dir() -> PathBuf {
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("smp-reports-{}-{n}", std::process::id()))
}
fn player(uuid: u128, name: &str, level: u8) -> Player {
Player {
uuid,
name: name.into(),
level,
}
}
fn find(name: &str) -> Option<(u128, String)> {
[(ALEX, "Alex"), (STEVE, "Steve"), (OP, "Op")]
.into_iter()
.find(|p| p.1.eq_ignore_ascii_case(name))
.map(|(u, n)| (u, n.to_owned()))
}
fn at(uuid: u128) -> Option<String> {
(uuid == STEVE).then(|| "minecraft:overworld 12 64 -30".to_owned())
}
#[test]
fn a_report_reaches_the_staff_on_and_the_file() {
let d = dir();
let mut r = Reports::open(&d, Config::default());
let out = r
.command(
&player(ALEX, "Alex", 0),
&find,
&at,
&[OP],
1_000,
"report steve griefing my house",
)
.unwrap();
assert_eq!(
out,
vec![
Out::Reply("Thanks, your report on Steve was sent to the staff.".into()),
Out::Tell(
OP,
"[Report] Alex reported Steve: griefing my house (minecraft:overworld 12 64 -30)".into()
),
]
);
assert_eq!(r.logged.len(), 1);
r.save();
let saved: Entry = serde_json::from_str(
std::fs::read_to_string(d.join("reports.log"))
.unwrap()
.trim(),
)
.unwrap();
assert_eq!(
(
saved.by_name.as_str(),
saved.player.as_str(),
saved.reason.as_str(),
saved.time
),
(
"Alex",
"00000000-0000-0000-0000-000000000002",
"griefing my house",
1_000
)
);
// Read back after a restart, for /reports.
let again = Reports::open(&d, Config::default());
let out = again.list(&player(OP, "Op", 3), None);
assert_eq!(out.len(), 2, "{out:?}");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn a_reason_a_known_player_and_two_minutes_between_reports_are_needed() {
let d = dir();
let mut r = Reports::open(&d, Config::default());
let alex = player(ALEX, "Alex", 0);
let mut run = |now, line: &str| r.command(&alex, &find, &at, &[], now, line).unwrap();
assert_eq!(
run(0, "report"),
vec![Out::Error("Usage: /report <player> <reason>".into())]
);
assert_eq!(
run(0, "report Steve"),
vec![Out::Error("Say why: /report <player> <reason>".into())]
);
assert_eq!(
run(0, "report Nobody spam"),
vec![Out::Error("No player was found".into())]
);
assert_eq!(
run(0, "report alex spam"),
vec![Out::Error("You can't report yourself.".into())]
);
// With no operator on, the reporter is told so.
let out = run(0, "report Steve spam");
assert!(
matches!(&out[1], Out::Reply(s) if s.starts_with("No staff member")),
"{out:?}"
);
assert_eq!(
run(20_000, "report Op abuse"),
vec![Out::Error(
"Wait 1 min 40 s before your next report.".into()
)]
);
assert!(matches!(&run(120_000, "report Op abuse")[0], Out::Reply(_)));
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn only_operators_list_reports_and_one_who_joins_is_told_once() {
let d = dir();
let mut r = Reports::open(&d, Config::default());
assert_eq!(
r.command(&player(ALEX, "Alex", 0), &find, &at, &[], 0, "reports")
.unwrap(),
vec![Out::Error("Only operators can read reports.".into())]
);
assert!(r.staff_joined(&[(OP, 4)], 0).is_empty(), "nothing to tell");
r.staff_joined(&[], 0);
for i in 0..10 {
let who = player(100 + i, &format!("P{i}"), 0);
r.command(&who, &find, &at, &[], 1_000, "report Steve x")
.unwrap();
}
// On already: not told again; coming on: told once.
assert!(r.staff_joined(&[(ALEX, 0)], 2_000).is_empty());
assert_eq!(
r.staff_joined(&[(ALEX, 0), (OP, 4)], 3_000),
vec![Out::Tell(
OP,
"10 player reports in the last 24 hours: /reports".into()
)]
);
assert!(r.staff_joined(&[(OP, 4)], 4_000).is_empty());
let op = player(OP, "Op", 4);
let page2 = r.command(&op, &find, &at, &[], 5_000, "reports 2").unwrap();
assert_eq!(
page2[0],
Out::Reply("Reports, newest first (page 2 of 2):".into())
);
assert_eq!(page2.len(), 3, "10 reports, 8 a page: {page2:?}");
assert!(
matches!(&page2[1], Out::Reply(s) if s.contains("P1 reported Steve")),
"{page2:?}"
);
let _ = std::fs::remove_dir_all(d);
}
}
+41
View File
@@ -131,6 +131,47 @@ pub fn parse_uuid(s: &str) -> Option<u128> {
.flatten()
}
/// `2026-11-02 14:05` from ms since the epoch (UTC).
pub fn utc_minute(ms: i64) -> String {
let secs = ms.div_euclid(1000);
let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
// Howard Hinnant's civil_from_days.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!(
"{y:04}-{m:02}-{d:02} {:02}:{:02}",
rem / 3600,
rem % 3600 / 60
)
}
/// The last `bytes` bytes of a file, from its first whole line; empty when
/// it can't be read.
pub fn tail(path: &Path, bytes: u64) -> String {
use std::io::{Read, Seek, SeekFrom};
let Ok(mut f) = std::fs::File::open(path) else {
return String::new();
};
let len = f.metadata().map(|m| m.len()).unwrap_or(0);
let from = len.saturating_sub(bytes);
let mut bytes = Vec::new();
if f.seek(SeekFrom::Start(from)).is_err() || f.read_to_end(&mut bytes).is_err() {
return String::new();
}
let text = String::from_utf8_lossy(&bytes).into_owned();
match (from > 0).then(|| text.find('\n')).flatten() {
Some(i) => text[i + 1..].to_owned(),
None => text,
}
}
#[cfg(test)]
mod tests {
use super::*;
+1 -22
View File
@@ -272,7 +272,7 @@ impl Switches {
Part::ALL.len()
))];
for (name, off) in &self.data.off {
let when = utc_minute(off.at);
let when = smp_store::utc_minute(off.at);
let why = if off.reason.is_empty() {
String::new()
} else {
@@ -315,27 +315,6 @@ fn everything(turn_off: bool) -> Vec<Part> {
.collect()
}
/// `2026-11-02 14:05` from ms since the epoch (UTC).
fn utc_minute(ms: i64) -> String {
let secs = ms.div_euclid(1000);
let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
// Howard Hinnant's civil_from_days.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!(
"{y:04}-{m:02}-{d:02} {:02}:{:02}",
rem / 3600,
rem % 3600 / 60
)
}
#[cfg(test)]
mod tests {
use super::*;
+35 -4
View File
@@ -375,7 +375,7 @@ def standing(server, c, at, tests=FLAT_GROUND):
def rtp(server, steve, data):
"""/rtp: refused in the Nether; to a random spot 500..5000 blocks from the spawn, on the ground; then its cooldown."""
"""/rtp: refused in the Nether; to a random spot 500..10000 blocks from the spawn, on the ground; then its cooldown."""
r = {}
spawn = json.loads((data / "places.json").read_text())["spawn"]
_, home, _ = where(steve)
@@ -393,7 +393,7 @@ def rtp(server, steve, data):
time.sleep(3)
dim, at, health = where(steve)
r["at"], r["distance"] = at, math.hypot(at[0] - spawn["x"], at[2] - spawn["z"])
assert dim == "minecraft:overworld" and 499 <= r["distance"] <= 5001, r
assert dim == "minecraft:overworld" and 499 <= r["distance"] <= 10001, r
# On the ground, not falling: the same place a few seconds later, unhurt.
time.sleep(4)
_, still, health2 = where(steve)
@@ -405,7 +405,7 @@ def rtp(server, steve, data):
r["log"] = log[-1]
r["slow_ticks"] = [l for l in server.lines[start:] if "slow tick" in l or "Can't keep up" in l]
r["again"] = steve.say("/rtp")
assert any("Wait 30 min before your next /rtp." in l for l in r["again"]), r
assert any("Wait 5 min before your next /rtp." in l for l in r["again"]), r
# The chunk it held loaded is let go 10 s after the landing.
time.sleep(12)
saved = json.loads((data / "places.json").read_text())
@@ -463,7 +463,7 @@ def rtp_alone(ctl, resources, out):
time.sleep(5)
dim, at, _ = where(steve)
r["at"], r["distance"] = at, math.hypot(at[0], at[2])
assert dim == "minecraft:overworld" and 499 <= r["distance"] <= 5001, r
assert dim == "minecraft:overworld" and 499 <= r["distance"] <= 10001, r
r["ground"], _ = standing(server, steve, at, SOLID_GROUND)
r["log"] = [l for l in server.lines[start:] if "[smp] /rtp: Steve" in l]
r["slow_ticks"] = [l for l in server.lines[start:] if "slow tick" in l or "Can't keep up" in l]
@@ -477,6 +477,36 @@ def rtp_alone(ctl, resources, out):
shutil.rmtree(root, ignore_errors=True)
def reports_and_ledger(server, alex, steve, data):
"""/report (Steve, a player) reaches Alex (an operator) and reports.log; a reason is needed and two minutes pass
between reports; /reports and /ledger answer operators only; /ledger reads the coin movements audit.log holds."""
r = {}
r["no_reason"] = steve.say("/report Alex")
assert any("Say why: /report <player> <reason>" in l for l in r["no_reason"]), r
r["sent"] = steve.say("/report Alex proof report")
assert any("Thanks, your report on Alex was sent to the staff." in l for l in r["sent"]), r
time.sleep(1)
r["op_read"] = alex.chat()[-3:]
assert any("[Report] Steve reported Alex: proof report" in l for l in r["op_read"]), r
r["again"] = steve.say("/report Alex again")
assert any("before your next report." in l for l in r["again"]), r
saved = [json.loads(l) for l in (data / "reports.log").read_text().splitlines()]
assert len(saved) == 1 and saved[0]["reason"] == "proof report" and saved[0]["by_name"] == "Steve", saved
assert any("[smp] report by Steve on Alex: proof report" in l for l in server.lines), server.lines[-20:]
r["reports"] = alex.say("/reports")
assert any("Steve reported Alex: proof report" in l for l in r["reports"]), r
r["not_op"] = steve.say("/ledger")
assert not any("Coin movements" in l for l in r["not_op"]), r
r["ledger"] = alex.say("/ledger Steve")
assert any("Steve's coin movements, newest first" in l for l in r["ledger"]), r
# Alex's /pay of 120 at the start, unless ten newer movements of Steve's push it to a later page.
pages = r["ledger"] + alex.say("/ledger Steve 2") + alex.say("/ledger Steve 3")
assert any("pay: Alex → Steve, 120 coins" in l for l in pages), pages
r["minted"] = alex.say("/ledger minted")
assert any("Coins the server made and destroyed, by hour (UTC):" in l for l in r["minted"]), r
return r
def claims(server, alex, steve):
"""Alex claims a chunk: Steve can't break, use or place there until trusted."""
r = {}
@@ -1192,6 +1222,7 @@ def run(ctl, resources, out):
results["places"] = places(server, alex, steve)
results["rtp"] = rtp(server, steve, data)
results["claims"] = claims(server, alex, steve)
results["reports_and_ledger"] = reports_and_ledger(server, alex, steve, data)
results["world_guard"] = world_guard(server, alex, steve)
results["board"] = board(alex, steve, out)
results["polish"] = polish(server, alex, steve, out)
+4
View File
@@ -23,6 +23,8 @@ pub mod smp_economy;
pub mod smp_games;
#[path = "../mixins/smp/smp_invsee.rs"]
pub mod smp_invsee;
#[path = "../mixins/smp/smp_ledger.rs"]
pub mod smp_ledger;
#[path = "../mixins/smp/smp_lottery.rs"]
pub mod smp_lottery;
#[path = "../mixins/smp/smp_market.rs"]
@@ -43,6 +45,8 @@ pub mod smp_presence;
pub mod smp_recipes;
#[path = "../mixins/smp/smp_report.rs"]
pub mod smp_report;
#[path = "../mixins/smp/smp_reports.rs"]
pub mod smp_reports;
#[path = "../mixins/smp/smp_shop.rs"]
pub mod smp_shop;
#[path = "../mixins/smp/smp_store.rs"]