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