SMP 0.19.0: the bank (/bank), savings paid by the Treasury

Deposits wait in the `bank` pool; interest 0.1% a day on up to 10,000 coins,
paid at 00:00 UTC from the Treasury on the day's lowest amount (no
deposit-before-midnight trick), at most 5% of the Treasury a night, pro rata.
At most 25,000 saved. The wealth tax counts savings. No loans.

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

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "horizon-mod-smp"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "horizon-mod-smp"
version = "0.18.0"
version = "0.19.0"
edition = "2024"
license = "MIT"
publish = false
+16 -3
View File
@@ -8,7 +8,7 @@ Players install nothing: it speaks only what a stock Horizon client already show
lines, vanilla chest menus the server fills with ordinary items (the trade menu); later sounds, titles, the action
bar, boss bars and the sidebar).
This version: the economy with its Treasury and taxes, the auction house, the server market and its `/shop` menu, player trades, the coinflip, the lottery, places (spawn, homes, warps,
This version: the economy with its Treasury and taxes, the auction house, the bank, the server market and its `/shop` menu, player trades, the coinflip, the lottery, places (spawn, homes, warps,
teleport requests, the rules), land claims, and the scoreboard (a sidebar with your balance, playtime and the richest players, the Tab list's
header and footer).
@@ -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 |
| `/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 |
| `/ah auction <start> [hours]` | auctions the stack in your hand from a starting bid, for 1 to 24 hours (12) |
@@ -202,11 +203,23 @@ its last 50 movements. The tax code (`config.json`'s `taxes`):
`land_free_chunks` (4). What a player can't pay is owed: no new claim until it's paid (`/eco taxes pay`, or the
next Monday's collection). Nothing is ever unclaimed for it.
- Wealth tax, every Monday: `wealth_brackets` `[from, percent]`, by default 1% of a balance's part above 50,000,
2% above 250,000 and 3% above 1,000,000 a week. On 2026-11-01 the richest player held 11,804 coins: nobody pays it
2% above 250,000 and 3% above 1,000,000 a week, on the balance and bank savings together (paid from the
balance). On 2026-11-01 the richest player held 11,804 coins: nobody pays it
yet; it keeps fortunes from growing without bound as the economy does.
- Games of chance: the coinflip's and the lottery's `fee_percent` goes to the Treasury.
The week's collection is looked at once a second (one comparison) and runs once a week; a restart never collects
twice (the week collected is kept with the balances). `enabled: false` turns the weekly taxes off.
**The bank.** `/bank` shows your savings; `/bank deposit <amount|all>` and `/bank withdraw <amount|all>` move coins
between your wallet and the bank (the `bank` pool), free and at any time. Interest (`bank.daily_basis_points`, 10:
0.1% a day) is paid at 00:00 UTC from the Treasury, on the lowest amount saved since the last payment (a deposit made
just before midnight earns nothing that night), on at most `max_interest_on` (10,000) coins of an account; a player
saves at most `max_deposit` (25,000). One night's interest for everyone is at most `max_treasury_percent` (5) of the
Treasury, shared out pro rata when that's short: the bank never makes money, it hands back part of the taxes to
those who keep coins out of circulation. Interest stays saved (logged as `bank_interest`); deposits and withdrawals
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`).
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.
@@ -246,7 +259,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` | 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`; 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` | 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/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 |
+31 -3
View File
@@ -10,6 +10,7 @@
use crate::game::{
Game,
smp_auction::{self, AuctionHouse},
smp_bank::{self, Bank},
native_server::{NativeCommand, NativeServerPlayer, NativeSidebar},
smp_board::{self, Board, Line},
smp_claims::{self, Claims},
@@ -36,7 +37,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.18.0";
pub const VERSION: &str = "0.19.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).
@@ -55,6 +56,7 @@ pub struct Config {
pub nicks: smp_nick::Config,
pub taxes: smp_tax::Config,
pub auction: smp_auction::Config,
pub bank: smp_bank::Config,
}
/// The SMP of the open world.
@@ -66,6 +68,8 @@ pub struct Smp {
taxes: Option<Taxes>,
/// The auction house (None with the economy off).
auction: Option<AuctionHouse>,
/// Savings and their interest (None with the economy off).
bank: Option<Bank>,
market: Market,
shop_config: smp_shop::Config,
/// The `/shop` menus open.
@@ -147,6 +151,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 places = Places::open(&dir, config.places);
if let Err(e) = &places {
log::error!("[smp] homes and warps are off: {e}");
@@ -185,6 +190,7 @@ fn open(dir: PathBuf) -> Smp {
economy,
taxes,
auction,
bank,
market,
shop_config: config.shop,
shop: Shop::default(),
@@ -222,6 +228,10 @@ fn save(smp: &mut Smp) {
if let (Some(t), Ok(e)) = (&mut smp.taxes, &mut smp.economy) {
t.store(e);
}
// And the bank's accounts.
if let (Some(b), Ok(e)) = (&mut smp.bank, &mut smp.economy) {
b.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);
@@ -330,6 +340,9 @@ pub fn commands(commands: &mut Vec<NativeCommand>) {
"ah claim",
"ah mine",
"ah mail",
"bank",
"bank deposit <amount:word>",
"bank withdraw <amount:word>",
] {
commands.push(NativeCommand::new(usage));
}
@@ -373,9 +386,9 @@ impl Bag for GameBag<'_, '_> {
}
}
const COMMANDS: [&str; 16] = [
const COMMANDS: [&str; 17] = [
"balance", "bal", "pay", "baltop", "eco", "money", "worth", "sell", "buy", "shop", "trade", "coinflip",
"lottery", "treasury", "taxes", "ah",
"lottery", "treasury", "taxes", "ah", "bank",
];
/// The players on, as the places see them.
@@ -580,9 +593,17 @@ pub(in crate::game) fn perform(
let on: Vec<(u128, String)> = players.iter().map(|p| (p.uuid, p.name.clone())).collect();
let mut bag = GameBag(game);
let my_chunks = smp.claims.as_ref().map_or(0, |c| c.held(me.uuid));
// The wealth tax counts bank savings (`/eco taxes` shows the bill).
if let (Some(t), Some(b)) = (&mut smp.taxes, &smp.bank)
&& matches!(word, "eco" | "taxes")
{
t.savings = b.savings();
}
let taxes = smp.taxes.as_mut();
let bank = smp.bank.as_mut();
taxes
.and_then(|t| t.command(e, &me, my_chunks, 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))
.or_else(|| {
@@ -1430,10 +1451,17 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
let vat = smp.taxes.as_ref().map_or(0, |t| t.config.vat_percent);
out.extend(a.tick(e, &on, vat, now));
}
// The bank's interest: an O(1) check each second, the work once a day.
if let Some(b) = &mut smp.bank {
out.extend(b.tick(e, now));
}
// Monday's taxes: an O(1) check each second, the work once a week.
if let Some(t) = &mut smp.taxes
&& t.due(now)
{
if let Some(b) = &smp.bank {
t.savings = b.savings();
}
let chunks = smp.claims.as_ref().map(|c| c.counts()).unwrap_or_default();
out.extend(t.collect(e, &chunks, now));
}
+7 -1
View File
@@ -1,6 +1,6 @@
[mod]
id = "smp"
version = "0.18.0"
version = "0.19.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 = "bank"
file = "crates/horizon-client/src/game.rs"
name = "smp_bank"
source = "smp_bank.rs"
[[module]]
id = "auction"
file = "crates/horizon-client/src/game.rs"
+442
View File
@@ -0,0 +1,442 @@
// The bank (`/bank`): savings that earn a small interest, paid once a day
// from the Treasury (taxes and fees), never minted. A player deposits coins
// (they wait in the `bank` pool) and takes them back at any time, free.
// Interest is paid at 00:00 UTC on the lowest amount the account held since
// the last payment, so a deposit made just before midnight earns nothing
// that night; only the first `max_interest_on` coins of each account earn,
// and one day's interest for everyone is at most `max_treasury_percent` of
// the Treasury (shared out pro rata when it's short). No loans: a loan's
// collateral (items) can't be held safely here, and a loan without it is
// free money for an alt that never pays back.
//
// The accounts live in `economy.json` (`ext.bank`), the coins in its pool:
// money and records are written together. No game types. Added to the game
// as `game::smp_bank`; also a module of this crate, for its tests.
use super::smp_economy::{DAY_MS, Economy, MAX_AMOUNT, Out, Player, TREASURY, parse_amount};
use super::smp_store::{parse_uuid, uuid_string};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
/// The pool savings wait in.
pub const POOL: &str = "bank";
/// `config.json`'s `bank`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub enabled: bool,
/// Interest a day, in hundredths of a percent (10: 0.1% a day).
pub daily_basis_points: i64,
/// Coins of an account that earn interest; more may be saved.
pub max_interest_on: i64,
/// What a player may save in all.
pub max_deposit: i64,
/// One day's interest for everyone is at most this share of the
/// Treasury.
pub max_treasury_percent: i64,
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: true,
daily_basis_points: 10,
max_interest_on: 10_000,
max_deposit: 25_000,
max_treasury_percent: 5,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Saving {
pub amount: i64,
/// The lowest `amount` since the last payment: what earns.
pub low: i64,
pub earned: i64,
}
/// `economy.json`'s `ext.bank`.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Data {
pub accounts: BTreeMap<String, Saving>,
/// The day (since 1970, UTC) interest was last paid.
pub day: i64,
pub paid: i64,
/// The last payment: what it was, and whether the Treasury's cap cut it.
pub last: i64,
pub last_cut: bool,
}
pub struct Bank {
pub config: Config,
pub data: Data,
dirty: bool,
}
impl Bank {
pub fn open(economy: &Economy, config: Config) -> Self {
let mut data = economy
.ext("bank")
.and_then(|v| serde_json::from_value::<Data>(v.clone()).ok())
.unwrap_or_default();
if data.day == 0 {
data.day = economy.now.div_euclid(DAY_MS);
}
Self {
config,
data,
dirty: false,
}
}
pub fn store(&mut self, economy: &mut Economy) {
if self.dirty {
economy.set_ext("bank", serde_json::to_value(&self.data).unwrap_or_default());
self.dirty = false;
}
}
/// What each player has saved (the wealth tax counts it).
pub fn savings(&self) -> HashMap<u128, i64> {
self.data
.accounts
.iter()
.filter_map(|(k, s)| Some((parse_uuid(k)?, s.amount)))
.collect()
}
pub fn saved(&self, uuid: u128) -> i64 {
self.data
.accounts
.get(&uuid_string(uuid))
.map_or(0, |s| s.amount)
}
/// `/bank ...`; None for another command.
pub fn command(&mut self, economy: &mut Economy, me: &Player, line: &str) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
if words.first() != Some(&"bank") {
return None;
}
if !self.config.enabled {
return Some(vec![Out::Error(
"The bank is closed on this server.".into(),
)]);
}
Some(match words[1..] {
[] => self.show(economy, me),
["deposit", amount] => self.deposit(economy, me, amount),
["withdraw", amount] => self.withdraw(economy, me, amount),
_ => vec![Out::Error(
"Usage: /bank, /bank deposit <amount|all>, /bank withdraw <amount|all>".into(),
)],
})
}
fn rate(&self) -> String {
let bp = self.config.daily_basis_points;
format!("{}.{:02}% a day", bp / 100, bp % 100)
}
fn show(&self, economy: &Economy, me: &Player) -> Vec<Out> {
let s = self
.data
.accounts
.get(&uuid_string(me.uuid))
.cloned()
.unwrap_or_default();
let next =
(s.low.min(self.config.max_interest_on) * self.config.daily_basis_points) / 10_000;
vec![
Out::Reply(format!(
"Saved: {} (earned so far {}). Wallet: {}.",
economy.money(s.amount),
economy.money(s.earned),
economy.money(economy.balance(me.uuid))
)),
Out::Reply(format!(
"Interest: {} on up to {}, paid at 00:00 UTC on the lowest amount saved since the last payment, from the Treasury (at most {}% of it a day). Tonight: about {}.",
self.rate(),
economy.money(self.config.max_interest_on),
self.config.max_treasury_percent,
economy.money(next)
)),
Out::Reply(format!(
"/bank deposit <amount|all> (up to {} saved), /bank withdraw <amount|all>: free, at any time. No loans.",
economy.money(self.config.max_deposit)
)),
]
}
fn amount(word: &str, all: i64) -> Option<i64> {
if word == "all" {
(all > 0).then_some(all)
} else {
parse_amount(word)
}
}
fn deposit(&mut self, economy: &mut Economy, me: &Player, word: &str) -> Vec<Out> {
let key = uuid_string(me.uuid);
let saved = self.saved(me.uuid);
let room = (self.config.max_deposit - saved).max(0);
let wallet = economy.balance(me.uuid);
let Some(amount) = Self::amount(word, wallet.min(room)) else {
return vec![Out::Error(if room == 0 {
format!(
"You have saved the most there is: {}.",
economy.money(self.config.max_deposit)
)
} else {
"The amount must be a positive whole number, or all.".into()
})];
};
if amount > room {
return vec![Out::Error(format!(
"You may save {} more (at most {} in all).",
economy.money(room),
economy.money(self.config.max_deposit)
))];
}
if let Err(e) = economy.to_pool("bank_deposit", me.uuid, POOL, amount, "") {
return vec![Out::Error(e)];
}
// `low` stays: what was there all day earns, not the new coins.
let s = self.data.accounts.entry(key).or_default();
s.amount += amount;
self.dirty = true;
vec![Out::Reply(format!(
"Saved {}: {} in the bank. It earns from tomorrow's payment on.",
economy.money(amount),
economy.money(s.amount)
))]
}
fn withdraw(&mut self, economy: &mut Economy, me: &Player, word: &str) -> Vec<Out> {
let key = uuid_string(me.uuid);
let saved = self.saved(me.uuid);
if saved == 0 {
return vec![Out::Error("You have nothing in the bank.".into())];
}
let Some(amount) = Self::amount(word, saved) else {
return vec![Out::Error(if saved == 0 {
"You have nothing in the bank.".into()
} else {
"The amount must be a positive whole number, or all.".into()
})];
};
if amount > saved {
return vec![Out::Error(format!(
"You have {} in the bank.",
economy.money(saved)
))];
}
if let Err(e) = economy.from_pool("bank_withdraw", POOL, me.uuid, amount, "") {
return vec![Out::Error(e)];
}
let s = self.data.accounts.get_mut(&key).expect("saved > 0");
s.amount -= amount;
s.low = s.low.min(s.amount);
let left = s.amount;
if left == 0 && s.earned == 0 {
self.data.accounts.remove(&key);
}
self.dirty = true;
vec![Out::Reply(format!(
"Took {} out: {} left in the bank.",
economy.money(amount),
economy.money(left)
))]
}
/// Once a second from the glue; the work once a day: interest from the
/// Treasury into each account (it stays saved, and earns from then on).
pub fn tick(&mut self, economy: &mut Economy, now: i64) -> Vec<Out> {
let today = now.div_euclid(DAY_MS);
if today <= self.data.day {
return Vec::new();
}
self.data.day = today;
self.dirty = true;
let bp = self.config.daily_basis_points.max(0);
let due: Vec<(String, i64)> = self
.data
.accounts
.iter()
.map(|(k, s)| {
(
k.clone(),
(s.low.clamp(0, self.config.max_interest_on) as i128 * bp as i128 / 10_000)
as i64,
)
})
.collect();
let total: i64 = due.iter().map(|d| d.1).sum();
let cap = economy.pool(TREASURY).max(0) as i128
* self.config.max_treasury_percent.clamp(0, 100) as i128
/ 100;
let cut = i128::from(total) > cap;
let mut paid = 0;
let mut out = Vec::new();
for (key, d) in due {
let pay = if cut {
(i128::from(d) * cap / i128::from(total.max(1))) as i64
} else {
d
};
let pay = pay.min(MAX_AMOUNT);
if pay > 0
&& economy
.pool_move("bank_interest", TREASURY, POOL, pay, "")
.is_ok()
{
paid += pay;
let s = self.data.accounts.get_mut(&key).expect("listed");
s.amount += pay;
s.earned += pay;
if let Some(u) = parse_uuid(&key) {
out.push(Out::Tell(
u,
format!(
"Bank interest: {} (saved: {}).",
economy.money(pay),
economy.money(s.amount)
),
));
}
}
}
// From now on, what's there all day earns.
for s in self.data.accounts.values_mut() {
s.low = s.amount;
}
self.data.paid += paid;
self.data.last = paid;
self.data.last_cut = cut;
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::smp_economy::Config as EcoConfig;
const ALEX: u128 = 1;
const ALT: u128 = 2;
const T0: i64 = 1_793_577_600_000; // a midnight, UTC
fn me(uuid: u128) -> Player {
Player {
uuid,
name: format!("P{uuid}"),
level: 0,
}
}
fn setup(name: &str, treasury: i64) -> (Economy, Bank, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("smp-bank-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut e = Economy::open(&dir, EcoConfig::default()).unwrap();
e.now = T0 + 1000;
for u in [ALEX, ALT] {
e.seen(u, &format!("P{u}"));
e.adjust("sell", u, 50_000, "").unwrap();
}
if treasury > 0 {
e.to_pool("seed", ALT, TREASURY, treasury, "").unwrap();
}
let b = Bank::open(&e, Config::default());
(e, b, dir)
}
fn total(e: &Economy) -> i64 {
e.balance(ALEX) + e.balance(ALT) + e.pool(TREASURY) + e.pool(POOL)
}
fn say(b: &mut Bank, e: &mut Economy, who: u128, line: &str) -> Vec<Out> {
b.command(e, &me(who), line).unwrap()
}
#[test]
fn a_deposit_earns_from_the_next_full_day_and_interest_comes_from_the_treasury() {
let (mut e, mut b, _d) = setup("earn", 10_000);
let start = total(&e);
say(&mut b, &mut e, ALEX, "bank deposit 5000");
assert_eq!((e.balance(ALEX), e.pool(POOL)), (45_000, 5_000));
// The first midnight: deposited during the day, nothing earned.
assert!(b.tick(&mut e, T0 + DAY_MS).is_empty());
// The next: 0.1% of 5,000.
let out = b.tick(&mut e, T0 + 2 * DAY_MS);
assert_eq!(
out,
[Out::Tell(
ALEX,
"Bank interest: 5 coins (saved: 5,005 coins).".into()
)]
);
assert_eq!((e.pool(TREASURY), b.saved(ALEX)), (9_995, 5_005));
assert!(
b.tick(&mut e, T0 + 2 * DAY_MS + 5_000).is_empty(),
"once a day"
);
assert_eq!(total(&e), start, "nothing minted");
}
#[test]
fn deposit_before_midnight_and_withdraw_after_earns_nothing() {
let (mut e, mut b, _d) = setup("game", 10_000);
b.tick(&mut e, T0 + DAY_MS);
say(&mut b, &mut e, ALEX, "bank deposit 10000");
b.tick(&mut e, T0 + 2 * DAY_MS); // deposited today: low was 0
say(&mut b, &mut e, ALEX, "bank withdraw all");
say(&mut b, &mut e, ALEX, "bank deposit 10000");
// Took it out during the day: the low was 0 again.
assert!(b.tick(&mut e, T0 + 3 * DAY_MS).is_empty());
assert_eq!(e.balance(ALEX) + b.saved(ALEX), 50_000);
}
#[test]
fn caps_hold_and_a_poor_treasury_pays_pro_rata() {
let (mut e, mut b, _d) = setup("caps", 100);
let err = |o: Vec<Out>| match &o[0] {
Out::Error(s) => s.clone(),
o => panic!("{o:?}"),
};
assert!(
err(say(&mut b, &mut e, ALEX, "bank deposit 30000"))
.starts_with("You may save 25,000 coins more")
);
assert!(err(say(&mut b, &mut e, ALEX, "bank withdraw 1")).starts_with("You have nothing"));
say(&mut b, &mut e, ALEX, "bank deposit all");
assert_eq!(b.saved(ALEX), 25_000, "all = up to the cap");
say(&mut b, &mut e, ALT, "bank deposit 10000");
b.tick(&mut e, T0 + DAY_MS);
// Due: 10 each (only 10,000 earns); the Treasury (100) pays 5% = 5 in all.
b.tick(&mut e, T0 + 2 * DAY_MS);
assert_eq!((b.data.last, b.data.last_cut), (4, true));
assert_eq!(e.pool(TREASURY), 96);
// An empty Treasury pays nothing; nobody goes below zero.
let (mut e, mut b, _d) = setup("empty", 0);
say(&mut b, &mut e, ALEX, "bank deposit 5000");
b.tick(&mut e, T0 + DAY_MS);
assert!(b.tick(&mut e, T0 + 2 * DAY_MS).is_empty());
assert_eq!(b.saved(ALEX), 5_000);
}
#[test]
fn savings_survive_a_restart_with_the_money() {
let (mut e, mut b, dir) = setup("restart", 1_000);
say(&mut b, &mut e, ALEX, "bank deposit 700");
b.store(&mut e);
e.save().unwrap();
let e2 = Economy::open(&dir, EcoConfig::default()).unwrap();
let b2 = Bank::open(&e2, Config::default());
assert_eq!((b2.saved(ALEX), e2.pool(POOL)), (700, 700));
assert_eq!(b2.savings()[&ALEX], 700);
}
}
+20 -3
View File
@@ -82,6 +82,9 @@ pub struct Taxes {
pub config: Config,
pub state: State,
dirty: bool,
/// What each player has in the bank (the glue sets it): the wealth tax
/// counts it with the balance, so saving doesn't hide wealth.
pub savings: HashMap<u128, i64>,
}
/// `12d 3h`, `3h 05m`, `4m`.
@@ -113,6 +116,7 @@ impl Taxes {
config,
state,
dirty,
savings: HashMap::new(),
}
}
@@ -229,7 +233,8 @@ impl Taxes {
}
}
for (uuid, _, balance) in economy.accounts() {
let bill = self.wealth_bill(balance).min(balance);
let saved = self.savings.get(&uuid).copied().unwrap_or(0);
let bill = self.wealth_bill(balance.saturating_add(saved)).min(balance);
if bill > 0
&& economy
.to_pool("wealth_tax", uuid, TREASURY, bill, "")
@@ -415,7 +420,8 @@ impl Taxes {
.collect();
let next = week_start(self.state.week + 1) - economy.now;
let land = self.land_bill(chunks);
let wealth_bill = self.wealth_bill(economy.balance(me.uuid));
let saved = self.savings.get(&me.uuid).copied().unwrap_or(0);
let wealth_bill = self.wealth_bill(economy.balance(me.uuid).saturating_add(saved));
let mut out = vec![
Out::Reply("Taxes: all of them go to the Treasury (/treasury), which pays grants, prizes and refunds.".into()),
Out::Reply(format!(
@@ -427,7 +433,7 @@ impl Taxes {
economy.money(c.land_tax_per_chunk),
c.land_free_chunks
)),
Out::Reply(format!("Wealth tax, weekly, on your balance: {}.", wealth.join(", "))),
Out::Reply(format!("Wealth tax, weekly, on your balance and bank savings: {}.", wealth.join(", "))),
Out::Reply("Games of chance: the house's fee goes to the Treasury.".into()),
];
if !c.enabled {
@@ -533,6 +539,7 @@ mod tests {
config: Config::default(),
state: State::default(),
dirty: false,
savings: HashMap::new(),
};
assert_eq!(t.land_bill(4), 0);
assert_eq!(t.land_bill(16), 60);
@@ -544,6 +551,16 @@ mod tests {
assert_eq!(t.wealth_bill(2_000_000), 2_000 + 15_000 + 30_000);
}
#[test]
fn bank_savings_count_for_the_wealth_tax() {
let (mut e, mut t, _dir) = setup("savings");
// Alex: 1,000 in the wallet, 59,000 saved: taxed on 60,000.
t.savings.insert(ALEX, 59_000);
let now = MONDAY;
t.collect(&mut e, &HashMap::new(), now);
assert_eq!(e.balance(ALEX), 900);
}
#[test]
fn monday_collects_once_into_the_treasury_and_unpaid_land_tax_blocks_claims() {
let (mut e, mut t, dir) = setup("collect");
+1 -1
View File
@@ -732,7 +732,7 @@ def run(ctl, resources, out):
time.sleep(2)
config = json.loads((data / "config.json").read_text())
results["config_keys"] = sorted(config)
assert results["config_keys"] == ["auction", "board", "claims", "currency", "gambling", "market", "nicks",
assert results["config_keys"] == ["auction", "bank", "board", "claims", "currency", "gambling", "market", "nicks",
"places", "presence", "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"]
+2
View File
@@ -3,6 +3,8 @@
//! `super::smp_store` reaches the same code in both.
#[path = "../mixins/smp/smp_auction.rs"]
pub mod smp_auction;
#[path = "../mixins/smp/smp_bank.rs"]
pub mod smp_bank;
#[path = "../mixins/smp/smp_board.rs"]
pub mod smp_board;
#[path = "../mixins/smp/smp_claims.rs"]