mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 11:05:58 -10:00
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:
1 parent
27bfc9da13
commit
bb97027039
9 files changed
+631
-8
No files matched your search
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-smp"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in new issue
Block a user