SMP 0.20.0: /eco stats, the daily economy report, web/economy.json

/eco stats: the supply and where it sits, what the server made and destroyed
today (new per-kind flows in economy.json), the day's taxes, Gini and top-10
share. At 00:00 UTC everyone reads the day before; 60 daily snapshots kept in
ext.report. web/economy.json once a minute, written off the tick thread, with
every Treasury movement and the tax code, for /hub.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-01 18:13:49 +01:00
1 parent 27bfc9da13
commit bb97027039
9 files changed
+631 -8

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "horizon-mod-smp"
version = "0.19.0"
version = "0.20.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "horizon-mod-smp"
version = "0.19.0"
version = "0.20.0"
edition = "2024"
license = "MIT"
publish = false
+13 -1
View File
@@ -20,6 +20,7 @@ header and footer).
| `/eco take <player> <amount> [reason]`, `/money take ...` | operators of level 3: a cheat's gains go into the Treasury, logged with the reason. Nobody gives or sets money (`/eco give` and `/eco set` say so) |
| `/treasury`, `/treasury log` | the Treasury's balance, its income by tax and its spending (today and since it opened), its last movements |
| `/treasury pay <player> <amount> <reason>` | operators of level 3: pays a player from the Treasury (an event prize, a bug refund, a grant), never more than it holds; everyone sees it |
| `/eco stats` | the money supply and where it sits, what the server made and destroyed today, taxes and fees, how evenly money is spread |
| `/bank`, `/bank deposit <amount\|all>`, `/bank withdraw <amount\|all>` | your savings, which earn a small interest from the Treasury each night |
| `/ah` | the auction house: a chest menu of listings by category, cheapest a unit first; click one to buy it or bid; your listings (click to take one back) and your mail |
| `/ah sell <price> [count]` | lists the stack in your hand (or part of it) at a fixed price, for `listing_hours` (48); the listing fee (1%, at least 1 coin) goes to the Treasury |
@@ -220,6 +221,17 @@ those who keep coins out of circulation. Interest stays saved (logged as `bank_i
are `bank_deposit` and `bank_withdraw`. No loans: their collateral (items) can't be held safely here, and a loan
without it is free money for an alt that never pays back. Savings are kept in `economy.json` (`ext.bank`).
**The report.** `/eco stats` (anyone) shows the money supply (wallets, savings and money held, the Treasury, the
insurance fund, each pool), what the server made today (market sales, the daily reward) and destroyed (market
purchases, operators' `/eco take`), the day's taxes and fees, and how evenly money is spread (Gini over players
with money, the richest ten's share). At 00:00 UTC everyone reads the day before in one line (`report.daily_report`).
A snapshot of each day is kept 60 days (`report.history_days`) in `economy.json` (`ext.report`). Once a minute
`world/horizon-smp/web/economy.json` (`report.web_json`) is written off the tick thread for the site's /hub:
`{"schema": 1, "generated": <ms>, "currency", "today", "pools", "minted_today", "burned_today", "treasury": {balance,
insurance, income, spent, income_today, spent_today, log}, "taxes": {rates, last_collection}, "days": [...],
"daily_report": "<yesterday's line>"}`. The server's Discord relay posts only chat, joins, leaves and deaths: the
daily line reaches Discord through whoever reads that file (Kira).
Other parts reach the pools through `Economy::to_pool`, `from_pool`, `pool_move` and `settle_pool` (a bet against a
pool: the stake goes in, the payout comes out, never more than the pool holds), each one audit line.
@@ -259,7 +271,7 @@ action bar (API 21), `native_block_spread`, `native_piston_move`, `native_explos
| File | What it is |
|---|---|
| `mixins/smp/horizon-mixin.toml` | The bundle: sixteen modules and the HEAD hooks |
| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs`, `smp_recipes.rs`, `smp_shop.rs`, `smp_nick.rs`, `smp_trade.rs`, `smp_games.rs`, `smp_lottery.rs`, `smp_places.rs`, `smp_claims.rs`, `smp_board.rs`, `smp_presence.rs`, `smp_tax.rs`, `smp_auction.rs`, `smp_bank.rs` | The rules, no game types (a trade's stacks are a type parameter): added to the game as `game::smp_store`, `game::smp_economy`, `game::smp_market`, `game::smp_recipes`, `game::smp_shop`, `game::smp_nick`, `game::smp_trade`, `game::smp_games`, `game::smp_lottery`, `game::smp_places`, `game::smp_claims`, `game::smp_board`, `game::smp_presence`, `game::smp_tax`, `game::smp_auction`, `game::smp_bank`; also this crate's modules (`src/lib.rs`), for their tests |
| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs`, `smp_recipes.rs`, `smp_shop.rs`, `smp_nick.rs`, `smp_trade.rs`, `smp_games.rs`, `smp_lottery.rs`, `smp_places.rs`, `smp_claims.rs`, `smp_board.rs`, `smp_presence.rs`, `smp_tax.rs`, `smp_auction.rs`, `smp_bank.rs`, `smp_report.rs` | The rules, no game types (a trade's stacks are a type parameter): added to the game as `game::smp_store`, `game::smp_economy`, `game::smp_market`, `game::smp_recipes`, `game::smp_shop`, `game::smp_nick`, `game::smp_trade`, `game::smp_games`, `game::smp_lottery`, `game::smp_places`, `game::smp_claims`, `game::smp_board`, `game::smp_presence`, `game::smp_tax`, `game::smp_auction`, `game::smp_bank`, `game::smp_report`; also this crate's modules (`src/lib.rs`), for their tests |
| `mixins/smp/glue.rs` | The state for the open world and the edges to the game's types (`game::smp_glue`) |
| `mixins/smp/{register,command,tick,lifecycle,menu_clicked,menu_closed,destroy_block,right_click_block,block_spread,piston_move,explosion_detonate,fill_bucket,attack_entity}.rs` | One small helper per hook |
| `proof.py` | A dedicated server built with the mod and two real clients, on fresh temporary data |
+39 -1
View File
@@ -11,6 +11,7 @@ use crate::game::{
Game,
smp_auction::{self, AuctionHouse},
smp_bank::{self, Bank},
smp_report::{self, Report},
native_server::{NativeCommand, NativeServerPlayer, NativeSidebar},
smp_board::{self, Board, Line},
smp_claims::{self, Claims},
@@ -37,7 +38,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.19.0";
pub const VERSION: &str = "0.20.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).
@@ -57,6 +58,7 @@ pub struct Config {
pub taxes: smp_tax::Config,
pub auction: smp_auction::Config,
pub bank: smp_bank::Config,
pub report: smp_report::Config,
}
/// The SMP of the open world.
@@ -70,6 +72,8 @@ pub struct Smp {
auction: Option<AuctionHouse>,
/// Savings and their interest (None with the economy off).
bank: Option<Bank>,
/// `/eco stats`, the daily report, `web/economy.json`.
report: Option<Report>,
market: Market,
shop_config: smp_shop::Config,
/// The `/shop` menus open.
@@ -152,6 +156,7 @@ fn open(dir: PathBuf) -> Smp {
let taxes = economy.as_ref().ok().map(|e| Taxes::open(e, config.taxes));
let auction = economy.as_ref().ok().map(|e| AuctionHouse::open(e, config.auction));
let bank = economy.as_ref().ok().map(|e| Bank::open(e, config.bank));
let report = economy.as_ref().ok().map(|e| Report::open(e, config.report));
let places = Places::open(&dir, config.places);
if let Err(e) = &places {
log::error!("[smp] homes and warps are off: {e}");
@@ -191,6 +196,7 @@ fn open(dir: PathBuf) -> Smp {
taxes,
auction,
bank,
report,
market,
shop_config: config.shop,
shop: Shop::default(),
@@ -232,6 +238,9 @@ fn save(smp: &mut Smp) {
if let (Some(b), Ok(e)) = (&mut smp.bank, &mut smp.economy) {
b.store(e);
}
if let (Some(r), Ok(e)) = (&mut smp.report, &mut smp.economy) {
r.store(e);
}
// So are the auction house's listings, bids and mail.
if let (Some(a), Ok(e)) = (&mut smp.auction, &mut smp.economy) {
a.store(e);
@@ -343,6 +352,8 @@ pub fn commands(commands: &mut Vec<NativeCommand>) {
"bank",
"bank deposit <amount:word>",
"bank withdraw <amount:word>",
"eco stats",
"money stats",
] {
commands.push(NativeCommand::new(usage));
}
@@ -601,8 +612,13 @@ pub(in crate::game) fn perform(
}
let taxes = smp.taxes.as_mut();
let bank = smp.bank.as_mut();
let report = smp.report.as_ref();
taxes
.and_then(|t| t.command(e, &me, my_chunks, line))
.or_else(|| {
let savings = bank.as_ref().map(|b| b.savings()).filter(|_| line.ends_with("stats")).unwrap_or_default();
report.and_then(|r| r.command(e, &savings, line))
})
.or_else(|| bank.and_then(|b| b.command(e, &me, line)))
.or_else(|| e.command(&me, &on, line))
.or_else(|| smp.market.command(e, &me, trades, &mut bag, line))
@@ -1455,6 +1471,28 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
if let Some(b) = &mut smp.bank {
out.extend(b.tick(e, now));
}
// The daily report and, once a minute, web/economy.json (written off
// the tick thread).
if let Some(r) = &mut smp.report
&& r.due(now)
{
let savings = smp.bank.as_ref().map(|b| b.savings()).unwrap_or_default();
let (o, web) = r.tick(e, &savings, now);
out.extend(o);
if web {
let taxes = smp.taxes.as_ref().map_or(serde_json::Value::Null, |t| {
serde_json::json!({"rates": t.config, "last_collection": t.state.last})
});
let doc = r.web(e, &savings, taxes, now);
let path = smp.dir.join("web").join("economy.json");
std::thread::spawn(move || {
let made = path.parent().map_or(Ok(()), std::fs::create_dir_all);
if let Err(err) = made.map_err(|e| e.to_string()).and_then(|()| smp_store::write_json(&path, &doc)) {
log::warn!("[smp] web/economy.json: {err}");
}
});
}
}
// Monday's taxes: an O(1) check each second, the work once a week.
if let Some(t) = &mut smp.taxes
&& t.due(now)
+7 -1
View File
@@ -1,6 +1,6 @@
[mod]
id = "smp"
version = "0.19.0"
version = "0.20.0"
priority = 100
# The commit of horizon main it was tested on. It uses only the native hook points (hooks 62 in
@@ -38,6 +38,12 @@ file = "crates/horizon-client/src/game.rs"
name = "smp_shop"
source = "smp_shop.rs"
[[module]]
id = "report"
file = "crates/horizon-client/src/game.rs"
name = "smp_report"
source = "smp_report.rs"
[[module]]
id = "bank"
file = "crates/horizon-client/src/game.rs"
+69 -2
View File
@@ -73,6 +73,9 @@ pub struct Treasury {
pub day: i64,
pub income_today: BTreeMap<String, i64>,
pub spent_today: BTreeMap<String, i64>,
/// The day before `day`'s, kept for the daily report.
pub income_yesterday: BTreeMap<String, i64>,
pub spent_yesterday: BTreeMap<String, i64>,
/// The latest movements, newest last (at most `LOG`).
pub log: Vec<Movement>,
}
@@ -105,6 +108,40 @@ pub struct Accounts {
/// What went into and out of the Treasury pool, for `/treasury`.
#[serde(default)]
pub treasury: Treasury,
/// Money made and destroyed by the server (`adjust`: market sales and
/// purchases, the daily reward), by kind, for `/eco stats`.
#[serde(default)]
pub flows: Flows,
}
/// Money the server made (`minted`) or took out of the game (`burned`),
/// today and the day before, by kind (the audit kind's first word).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Flows {
pub day: i64,
pub minted: BTreeMap<String, i64>,
pub burned: BTreeMap<String, i64>,
pub minted_yesterday: BTreeMap<String, i64>,
pub burned_yesterday: BTreeMap<String, i64>,
}
impl Flows {
/// Starts `day` (moving the last one to yesterday's when it was the day
/// before).
pub fn roll(&mut self, day: i64) {
if self.day == day {
return;
}
let next = self.day + 1 == day;
self.minted_yesterday = std::mem::take(&mut self.minted);
self.burned_yesterday = std::mem::take(&mut self.burned);
if !next {
self.minted_yesterday.clear();
self.burned_yesterday.clear();
}
self.day = day;
}
}
/// The state's pool: taxes, fees and house edges in; grants out.
@@ -192,6 +229,22 @@ impl Economy {
.collect()
}
/// Money the server made and destroyed, by kind (rolled to `now`'s day).
pub fn flows(&mut self) -> &Flows {
self.accounts.flows.roll(self.now.div_euclid(DAY_MS));
&self.accounts.flows
}
/// All the money held for games.
pub fn held_total(&self) -> i64 {
self.accounts.held.values().sum()
}
/// Every pool, by name.
pub fn pools(&self) -> &BTreeMap<String, i64> {
&self.accounts.pools
}
/// Money `uuid` has held for a game.
pub fn held(&self, uuid: u128) -> i64 {
self.accounts
@@ -332,9 +385,14 @@ impl Economy {
.unwrap_or_else(|| by.to_owned());
let t = &mut self.accounts.treasury;
if t.day != day {
let next = t.day + 1 == day;
t.income_yesterday = std::mem::take(&mut t.income_today);
t.spent_yesterday = std::mem::take(&mut t.spent_today);
if !next {
t.income_yesterday.clear();
t.spent_yesterday.clear();
}
t.day = day;
t.income_today.clear();
t.spent_today.clear();
}
let (all, today) = if amount > 0 {
(&mut t.income, &mut t.income_today)
@@ -733,6 +791,15 @@ impl Economy {
.get_mut(&key)
.expect("checked")
.balance = after;
let flows = &mut self.accounts.flows;
flows.roll(self.now.div_euclid(DAY_MS));
let family = kind.split(' ').next().unwrap_or(kind).to_owned();
let map = if delta >= 0 {
&mut flows.minted
} else {
&mut flows.burned
};
*map.entry(family).or_default() += delta.abs();
self.touch();
Ok(after)
}
+498
View File
@@ -0,0 +1,498 @@
// The economy in the open: `/eco stats` (the money supply and where it
// sits, what the server made and destroyed today, the Treasury's day, how
// evenly money is spread), a report to everyone at 00:00 UTC on the day
// before, and `web/economy.json` for the site's /hub (written once a minute,
// off the tick thread, by the glue). One snapshot a day is kept (60 days) in
// `economy.json` (`ext.report`) for the curves. Reading only: this part
// moves no money. No game types. Added to the game as `game::smp_report`;
// also a module of this crate, for its tests.
use super::smp_economy::{DAY_MS, Economy, INSURANCE, Out, TREASURY};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashMap};
/// `config.json`'s `report`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// The 00:00 UTC report in everyone's chat.
pub daily_report: bool,
/// `web/economy.json` once a minute.
pub web_json: bool,
pub history_days: usize,
}
impl Default for Config {
fn default() -> Self {
Self {
daily_report: true,
web_json: true,
history_days: 60,
}
}
}
/// One day, as the report keeps it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Day {
/// Days since 1970 (UTC).
pub day: i64,
/// All the money there is, at the end of the day.
pub supply: i64,
/// Players' money: wallets, savings, money held (bids, stakes, margins).
pub players: i64,
pub treasury: i64,
pub insurance: i64,
/// Made and destroyed by the server that day.
pub minted: i64,
pub burned: i64,
/// The Treasury's income and spending that day.
pub taxes: i64,
pub spent: i64,
/// Over players with money: Gini (0 to 1, in thousandths) and the
/// richest ten's share (percent).
pub gini_milli: i64,
pub top10_percent: i64,
}
/// `ext.report`.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct State {
pub days: Vec<Day>,
/// The last day reported.
pub day: i64,
}
pub struct Report {
pub config: Config,
pub state: State,
dirty: bool,
minute: i64,
}
/// Pools that are the state's, not players' money.
const STATE_POOLS: [&str; 3] = [TREASURY, INSURANCE, "casino"];
/// Gini (thousandths) and the top ten's share (percent) of `wealth`.
pub fn spread(mut wealth: Vec<i64>) -> (i64, i64) {
wealth.retain(|w| *w > 0);
wealth.sort_unstable();
let n = wealth.len() as i128;
let total: i128 = wealth.iter().map(|w| i128::from(*w)).sum();
if total == 0 {
return (0, 0);
}
let weighted: i128 = wealth
.iter()
.enumerate()
.map(|(i, w)| (2 * i as i128 - n + 1) * i128::from(*w))
.sum();
let top: i128 = wealth.iter().rev().take(10).map(|w| i128::from(*w)).sum();
(
(weighted * 1000 / (n * total)) as i64,
(top * 100 / total) as i64,
)
}
fn sum(m: &BTreeMap<String, i64>) -> i64 {
m.values().sum()
}
/// `market sale 60,000 coins, daily reward 20,000 coins` (largest first).
fn list(e: &Economy, m: &BTreeMap<String, i64>, n: usize) -> String {
let mut v: Vec<(&String, &i64)> = m.iter().filter(|(_, a)| **a > 0).collect();
v.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
let parts: Vec<String> = v
.iter()
.take(n)
.map(|(k, a)| format!("{} {}", label(k), e.money(**a)))
.collect();
if parts.is_empty() {
"nothing".into()
} else {
parts.join(", ")
}
}
fn label(kind: &str) -> String {
match kind {
"sell" => "market sales".into(),
"buy" => "market purchases".into(),
k => k.replace('_', " "),
}
}
impl Report {
pub fn open(economy: &Economy, config: Config) -> Self {
let mut state = economy
.ext("report")
.and_then(|v| serde_json::from_value::<State>(v.clone()).ok())
.unwrap_or_default();
if state.day == 0 {
state.day = economy.now.div_euclid(DAY_MS);
}
Self {
config,
state,
dirty: false,
minute: 0,
}
}
pub fn store(&mut self, economy: &mut Economy) {
if self.dirty {
economy.set_ext(
"report",
serde_json::to_value(&self.state).unwrap_or_default(),
);
self.dirty = false;
}
}
/// The day's numbers now. `savings`: what each player has in the bank.
pub fn snapshot(economy: &mut Economy, savings: &HashMap<u128, i64>, day: i64) -> Day {
let accounts = economy.accounts();
let wallets: i64 = accounts.iter().map(|a| a.2).sum();
let pools: i64 = economy.pools().values().sum();
let state: i64 = STATE_POOLS.iter().map(|p| economy.pool(p)).sum();
let supply = wallets + economy.held_total() + pools;
let mut wealth: HashMap<u128, i64> = accounts.iter().map(|a| (a.0, a.2)).collect();
for (u, s) in savings {
*wealth.entry(*u).or_default() += s;
}
let (gini_milli, top10_percent) = spread(wealth.into_values().collect());
let today = economy.now.div_euclid(DAY_MS);
let (minted, burned, taxes, spent) = {
let t = economy.treasury();
let (inc, out) = if t.day == day {
(sum(&t.income_today), sum(&t.spent_today))
} else if t.day == day + 1 {
(sum(&t.income_yesterday), sum(&t.spent_yesterday))
} else {
(0, 0)
};
let f = economy.flows();
let (m, b) = if day == today {
(sum(&f.minted), sum(&f.burned))
} else if day + 1 == today {
(sum(&f.minted_yesterday), sum(&f.burned_yesterday))
} else {
(0, 0)
};
(m, b, inc, out)
};
Day {
day,
supply,
players: supply - state,
treasury: economy.pool(TREASURY),
insurance: economy.pool(INSURANCE),
minted,
burned,
taxes,
spent,
gini_milli,
top10_percent,
}
}
/// Runs `/eco stats` (or `/money stats`); None for another command.
pub fn command(
&self,
economy: &mut Economy,
savings: &HashMap<u128, i64>,
line: &str,
) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
if !matches!(words.as_slice(), ["eco" | "money", "stats"]) {
return None;
}
let today = economy.now.div_euclid(DAY_MS);
let d = Self::snapshot(economy, savings, today);
let since = self.state.days.last().map(|y| d.supply - y.supply);
let pools: Vec<String> = economy
.pools()
.iter()
.filter(|(k, v)| **v > 0 && !STATE_POOLS.contains(&k.as_str()))
.map(|(k, v)| format!("{} {}", k.replace('_', " "), economy.money(*v)))
.collect();
let flows = economy.flows().clone();
let t = economy.treasury().clone();
let (inc, out) = if t.day == today {
(t.income_today, t.spent_today)
} else {
Default::default()
};
let mut lines = vec![
Out::Reply(format!(
"Money supply: {}{}. Players hold {} (of it in pools: {}), the Treasury {}, the insurance fund {}.",
economy.money(d.supply),
since.map_or(String::new(), |s| format!(
" ({}{} since midnight)",
if s >= 0 { "+" } else { "-" },
economy.money(s.abs())
)),
economy.money(d.players),
if pools.is_empty() {
"none".into()
} else {
pools.join(", ")
},
economy.money(d.treasury),
economy.money(d.insurance)
)),
Out::Reply(format!(
"Today the server made {} ({}) and destroyed {} ({}).",
economy.money(sum(&flows.minted)),
list(economy, &flows.minted, 3),
economy.money(sum(&flows.burned)),
list(economy, &flows.burned, 3)
)),
Out::Reply(format!(
"Taxes and fees today: {} ({}). The Treasury paid out {}.",
economy.money(sum(&inc)),
list(economy, &inc, 4),
economy.money(sum(&out))
)),
Out::Reply(format!(
"Spread: Gini {}.{:03} (0 equal, 1 one player has all), the richest ten hold {}%.",
d.gini_milli / 1000,
d.gini_milli % 1000,
d.top10_percent
)),
];
lines.push(Out::Reply(
"The Treasury: /treasury. The taxes: /eco taxes.".into(),
));
Some(lines)
}
/// Whether `tick` has work at `now` (a new minute or day): the glue
/// gathers the savings only then.
pub fn due(&self, now: i64) -> bool {
now.div_euclid(60_000) != self.minute || now.div_euclid(DAY_MS) > self.state.day
}
/// Once a second from the glue. At the day's turn: the day before is
/// kept and, if on, told to everyone. Returns the lines, and whether
/// `web/economy.json` is due (once a minute).
pub fn tick(
&mut self,
economy: &mut Economy,
savings: &HashMap<u128, i64>,
now: i64,
) -> (Vec<Out>, bool) {
let minute = now.div_euclid(60_000);
let web = self.config.web_json && minute != self.minute;
self.minute = minute;
let today = now.div_euclid(DAY_MS);
if today <= self.state.day {
return (Vec::new(), web);
}
let yesterday = today - 1;
self.state.day = today;
self.dirty = true;
// The supply now is yesterday's end, give or take a second.
let d = Self::snapshot(economy, savings, yesterday);
let before = self
.state
.days
.last()
.filter(|p| p.day + 1 == yesterday)
.map(|p| p.supply);
self.state.days.push(d.clone());
let keep = self.config.history_days.max(1);
if self.state.days.len() > keep {
let extra = self.state.days.len() - keep;
self.state.days.drain(..extra);
}
let mut out = Vec::new();
if self.config.daily_report {
out.push(Out::All(self.daily_line(economy, &d, before)));
}
(out, web)
}
fn daily_line(&self, economy: &Economy, d: &Day, before: Option<i64>) -> String {
format!(
"Economy yesterday: money supply {}{}; the server made {} and destroyed {}; taxes and fees {} to the Treasury (now {}), which paid out {}. /eco stats",
economy.money(d.supply),
before.map_or(String::new(), |b| {
let c = d.supply - b;
format!(
" ({}{})",
if c >= 0 { "+" } else { "-" },
economy.money(c.abs())
)
}),
economy.money(d.minted),
economy.money(d.burned),
economy.money(d.taxes),
economy.money(d.treasury),
economy.money(d.spent)
)
}
/// `web/economy.json`: aggregates, the Treasury's every movement (public
/// in game too), the tax code, the days kept.
pub fn web(
&self,
economy: &mut Economy,
savings: &HashMap<u128, i64>,
taxes: Value,
now: i64,
) -> Value {
let today = now.div_euclid(DAY_MS);
let d = Self::snapshot(economy, savings, today);
let flows = economy.flows().clone();
let t = economy.treasury().clone();
let fresh = t.day == today;
let pools: BTreeMap<String, i64> = economy.pools().clone();
let daily = self.state.days.last().map(|y| {
self.daily_line(
economy,
y,
self.state
.days
.iter()
.rev()
.nth(1)
.filter(|p| p.day + 1 == y.day)
.map(|p| p.supply),
)
});
json!({
"schema": 1,
"generated": now,
"currency": economy.config.currency,
"today": d,
"pools": pools,
"minted_today": flows.minted,
"burned_today": flows.burned,
"treasury": {
"balance": economy.pool(TREASURY),
"insurance": economy.pool(INSURANCE),
"income": t.income,
"spent": t.spent,
"income_today": if fresh { t.income_today } else { BTreeMap::new() },
"spent_today": if fresh { t.spent_today } else { BTreeMap::new() },
"log": t.log,
},
"taxes": taxes,
"days": self.state.days,
"daily_report": daily,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::smp_economy::Config as EcoConfig;
const A: u128 = 1;
const B: u128 = 2;
const T0: i64 = 1_793_577_600_000; // a midnight, UTC
fn setup(name: &str) -> (Economy, Report) {
let dir = std::env::temp_dir().join(format!("smp-report-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut e = Economy::open(&dir, EcoConfig::default()).unwrap();
e.now = T0 + 1000;
e.seen(A, "Alex");
e.seen(B, "Bo");
let r = Report::open(&e, Config::default());
(e, r)
}
#[test]
fn spread_measures_inequality() {
assert_eq!(spread(vec![10, 10, 10, 10]), (0, 100));
let (g, _) = spread(vec![0, 0, 0, 100]);
assert_eq!(g, 0, "only players with money count");
let (g, top) = spread((1..=20).map(|i| if i == 20 { 1_000 } else { 1 }).collect());
assert!(g > 900, "{g}");
assert_eq!(top, 99);
}
#[test]
fn stats_show_what_was_made_destroyed_and_taxed_today() {
let (mut e, r) = setup("stats");
e.adjust("sell 20 minecraft:wheat", A, 600, "").unwrap();
e.adjust("daily_reward", B, 50, "").unwrap();
e.adjust("buy 5 minecraft:torch", A, -100, "").unwrap();
e.to_pool("vat", A, TREASURY, 25, "").unwrap();
let out = r
.command(&mut e, &HashMap::from([(B, 0)]), "eco stats")
.unwrap();
let text: Vec<String> = out
.iter()
.map(|o| match o {
Out::Reply(s) => s.clone(),
o => panic!("{o:?}"),
})
.collect();
assert!(
text[0].starts_with("Money supply: 550 coins. Players hold 525 coins"),
"{}",
text[0]
);
assert_eq!(
text[1],
"Today the server made 650 coins (market sales 600 coins, daily reward 50 coins) and destroyed 100 coins (market purchases 100 coins)."
);
assert!(
text[2].starts_with("Taxes and fees today: 25 coins (vat 25 coins)"),
"{}",
text[2]
);
assert!(r.command(&mut e, &HashMap::new(), "eco give A 5").is_none());
}
#[test]
fn midnight_reports_the_day_before_once_and_keeps_it_through_a_restart() {
let (mut e, mut r) = setup("daily");
e.adjust("sell 1 minecraft:diamond", A, 100, "").unwrap();
e.to_pool("vat", A, TREASURY, 5, "").unwrap();
let (out, web) = r.tick(&mut e, &HashMap::new(), T0 + 2000);
assert!(out.is_empty() && web, "same day; the JSON's first minute");
assert!(
!r.tick(&mut e, &HashMap::new(), T0 + 3000).1,
"once a minute"
);
e.now = T0 + DAY_MS + 500;
let now = e.now;
let (out, _) = r.tick(&mut e, &HashMap::new(), now);
assert_eq!(
out,
[Out::All(
"Economy yesterday: money supply 100 coins; the server made 100 coins and destroyed 0 coins; taxes and fees 5 coins to the Treasury (now 5 coins), which paid out 0 coins. /eco stats"
.into()
)]
);
assert!(
r.tick(&mut e, &HashMap::new(), now + 1000).0.is_empty(),
"once"
);
let json = r.web(&mut e, &HashMap::new(), json!({}), now);
assert_eq!(json["schema"], 1);
assert_eq!(json["days"][0]["minted"], 100);
assert_eq!(json["treasury"]["balance"], 5);
assert!(
json["daily_report"]
.as_str()
.unwrap()
.starts_with("Economy yesterday")
);
r.store(&mut e);
e.save().unwrap();
let dir = std::env::temp_dir().join(format!("smp-report-daily-{}", std::process::id()));
let e2 = Economy::open(&dir, EcoConfig::default()).unwrap();
let r2 = Report::open(&e2, Config::default());
assert_eq!(r2.state.days.len(), 1);
assert_eq!(r2.state.day, (T0 + DAY_MS).div_euclid(DAY_MS));
}
}
+1 -1
View File
@@ -733,7 +733,7 @@ def run(ctl, resources, out):
config = json.loads((data / "config.json").read_text())
results["config_keys"] = sorted(config)
assert results["config_keys"] == ["auction", "bank", "board", "claims", "currency", "gambling", "market", "nicks",
"places", "presence", "shop", "starting_balance", "taxes"], config
"places", "presence", "report", "shop", "starting_balance", "taxes"], config
results["welcome"] = steve.chat()
assert any("Welcome to the server, Steve!" in l for l in results["welcome"]), results["welcome"]
assert any("1. Be respectful to everyone." in l for l in results["welcome"]), results["welcome"]
+2
View File
@@ -25,6 +25,8 @@ pub mod smp_places;
pub mod smp_presence;
#[path = "../mixins/smp/smp_recipes.rs"]
pub mod smp_recipes;
#[path = "../mixins/smp/smp_report.rs"]
pub mod smp_report;
#[path = "../mixins/smp/smp_shop.rs"]
pub mod smp_shop;
#[path = "../mixins/smp/smp_store.rs"]