mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 11:05:58 -10:00
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:
1 parent
78853c19a6
commit
e44816b65a
13 files changed
+940
-45
No files matched your search
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-smp"
|
||||
version = "0.33.0"
|
||||
version = "0.34.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "horizon-mod-smp"
|
||||
version = "0.33.0"
|
||||
version = "0.34.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:?}"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in new issue
Block a user