Compare commits

...
Author SHA1 Message Date
Ludovic MarieandJustKarkat a53d9d286c Add crossversion 0.7.0
Submitted by @JustKarkat through the form.

Submission: hfV3QHSHEP9PTxsiSunSL2onyk0sYXVgpshuONGDbrI
Co-authored-by: JustKarkat <[email protected]>
2026-11-02 20:24:58 +01:00
Ludovic MarieandClaude Opus 5.5 b22a9c42a3 SMP 0.30.1: the claim-free zone covers every dimension (#162)
rdbt: the zone around 0 0 should apply in the Nether and the End too.
Claims there are refused and dropped at start in every dimension.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-02 17:01:15 +01:00
Ludovic MarieandClaude Opus 5.5 6ab145fce5 SMP 0.30.0: a claim-free zone around 0 0 (claims.free_radius) (#159)
Operators asked for the land near spawn to be free for all: no one may
claim a chunk that touches the zone, and claims already there are dropped
at start, each logged. Off by default (0).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-02 16:09:20 +01:00
c240394cde smp: simplify the economy: no taxes, fees, Treasury, bank, jobs or futures (#153)
* smp: simplify the economy, drop the tax code

No more VAT on auction sales, weekly land and wealth taxes, /taxes and
/eco taxes, tax on market trades or teleport fees. The Treasury stays
(fees and house edges in, operators' grants out) with /treasury, now in
its own small module. Old taxes/tax_percent/fees config keys are ignored
and owed land tax is forgiven.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* smp: remove the Treasury, the bank, jobs and futures, and every fee

Operators make money with /eco give <player> <amount> <reason> (everyone
reads it) and take it with /eco take. The auction listing fee and the
coinflip/lottery fee are gone. The casino's pot gets its float once, made
for it, then keeps the house edge.

A world from before this gets its bank savings and open futures margins
back at the first start; the Treasury, insurance fund, contracts and land
tax owed are dropped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* smp: keep the casino's float mark in economy.json

The "pot got its float" flag lived in casino.json while the money went to
economy.json, so a lost or unreadable casino.json, or a crash between the
two saves, made up to 20,000 again on the next start. The mark now sits in
the economy's ext, written in the same file as the pot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Ludovic Marie <[email protected]>
2026-11-02 15:02:43 +01:00
Ludovic MarieandClaude Opus 5.5 d57056b937 SMP 0.29.1: an operator may open their own ender chest with /endersee (#156)
/invsee on yourself stays refused (it would move stacks into the same
inventory); your own ender chest is a second container, so /endersee works.
Asked by bunchy7s (operator) on Discord.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-02 13:36:10 +01:00
25 changed files with 719 additions and 4346 deletions

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
id = "crossversion"
kind = "native-mod"
name = "Cross Version"
summary = "Join servers on older Minecraft versions: picks the version per server, runs ViaProxy locally, and logs in with an account added to the proxy."
description = "**Cross-version** lets Horizon join servers running an older Minecraft version. On a join it reads what version the server speaks, and if it isn't 26.3 it starts ViaProxy on a loopback port so the server is spoken to in its own version — a 26.3 server is never touched. The server list marks those servers joinable (green bars, version in green above the MOTD), Edit Server and Direct Connect carry a click-to-change version switch for servers whose status lies, and online-mode works by logging in with a Microsoft account you add to ViaProxy once from its settings page. Horizon itself never signs anything, so a joined server can't use your session elsewhere. It downloads ViaProxy (GPL-3.0, hash-pinned) on first use and needs Java 17+; nothing else."
authors = ["JustKarkat"]
owners = ["JustKarkat"]
license = "MIT"
source = "https://github.com/JustKarkat/horizon-crossversion/"
icon = "https://github.com/JustKarkat/horizon-crossversion/blob/master/icon.png?raw"
key = "RWTcPJHJYjPW0pV3lPwr45crCu0wJxW5HbDLc3fr3Eom1TzxO++kHOo4"
[[versions]]
version = "0.7.0"
published = "2026-11-02"
game = "26.3"
file = "crossversion-0.7.0.hnmod"
sha256 = "281f45baeea2b65b091d5464c57eeba7b3d3be47eaddcbec6acf33975cf7ec40"
signature = """
untrusted comment: signature from minisign secret key
RUTcPJHJYjPW0sZHADCefonT57e7aWgGLjF1DWTY6wS9TgmVVYfztD8u1sozK/UoTsXdbHjijRvSdmt5HHdowtIi4PFO0CT9EAU=
trusted comment: timestamp:1793647010\tfile:crossversion-0.7.0.hnmod\thashed
CZX329b7UHVbmCZAgb+Qfo4B1UMGXLH733SnAZh0sgqjrwm1j7uLYI8kHO44qwO0Np1NG1gpvqwzsiuPAMGKDg=="""
changelog = "A version switch on Edit Server and Direct Connect: choose what version ViaProxy tells that server it is (auto, 1.8, 1.9, 1.12.2, 1.16.5, 1.20.1, 1.21.1, 1.21.4, 26.3), kept per address, for servers whose status reports the wrong version. Online-mode still logs in through the account added to ViaProxy; the client never signs."
+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "horizon-mod-smp"
version = "0.29.0"
version = "0.30.1"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "horizon-mod-smp"
version = "0.29.0"
version = "0.30.1"
edition = "2024"
license = "MIT"
publish = false
+42 -99
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 bank, the server market and its `/shop` menu, player trades, the coinflip, the lottery, places (spawn, homes, warps,
This version: the economy, the auction house, 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).
@@ -17,19 +17,14 @@ header and footer).
| `/balance [player]`, `/bal` | your balance, or another player's |
| `/pay <player> <amount>` | gives money to a player who has an account (anyone who has played since the mod was on) |
| `/baltop [page]` | the richest players, 10 a page |
| `/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 |
| `/jobs`, `/jobs deliver <n>` | the day's contracts: deliver items the state asks for, paid from the Treasury |
| `/futures`, `/futures long\|short <item> <margin> [leverage]`, `/futures close <n>`, `/futures info <item>`, `/futures list` | perpetual futures on the market's prices: a chest menu of your positions (profit green, loss red, liquidation price), leverage up to 10x |
| `/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 |
| `/eco give <player> <amount> <reason>`, `/money give ...` | operators of level 3: makes money for a player (an event prize, a bug refund); everyone reads it with the reason, and the audit log keeps it |
| `/eco take <player> <amount> [reason]`, `/money take ...` | operators of level 3: takes a cheat's gains out of the game, logged with the reason |
| `/eco stats` | the money supply and where it sits, what the server made and destroyed today, how evenly money is spread |
| `/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 sell <price> [count]` | lists the stack in your hand (or part of it) at a fixed price, for `listing_hours` (48); listing is free |
| `/ah auction <start> [hours]` | auctions the stack in your hand from a starting bid, for 1 to 24 hours (12) |
| `/ah bid <listing> <amount>`, `/ah buy <listing>` | bids (the money is held until someone bids more or it ends), buys a fixed-price listing |
| `/ah cancel <listing>`, `/ah claim`, `/ah search <words>`, `/ah mine`, `/ah mail` | takes back a listing nobody bid on; gives you your mail; the menu on a search, your listings, your mail |
| `/eco taxes`, `/taxes` | the tax code and your own bill on Monday; `/eco taxes pay` pays land tax you owe |
| `/shop` | the market as a chest menu: categories, a click buys one, a shift-click 64, a click on a stack of yours sells it, "Sell all" |
| `/market [item]` | the moving market as a chest menu: categories, each item's price, its move over 1 h and 24 h and its volume; an item's page buys or sells 1, 16 or 64, sells all you carry, and gives its price chart as a map |
| `/worth [item]` | an item's price, the same to buy and to sell (the one in your hand without a name), and what you may still sell today |
@@ -70,7 +65,7 @@ money and the room before anything moves.
- One price per item, to buy and to sell (since 0.29): a config that still has two (`buy` 400, `sell` 100) gets
their geometric mean (200) at load, logged ("one price now"); `buy` or `sell` 0 still closes that side. A trade
of N items is priced along the curve it moves the price on (below), so buying N and selling them back gives back
exactly what was paid, less the tax: a round trip never earns (tested for 1, 64 and past the minute's band, and
exactly what was paid (less whole-coin rounding): a round trip never earns (tested for 1, 64 and past the minute's band, and
over thousands of random trades with the drift in between).
- No chain of recipes turns what the market sells into something it buys for more: the cheapest way to get each item
from the market (bought, or crafted, smelted, cut from bought items, through any number of recipes) is worked out,
@@ -86,8 +81,7 @@ money and the room before anything moves.
and paying it all to a main is refused once the main's limit is reached ("can only receive N more today").
Trades (`/trade`) and auction house sales are not counted.
- Amounts are checked: no price above 1,000,000,000,000, no sum that overflows.
- The coinflip and the lottery give the winner the stakes (less `fee_percent`, 0 by default): they move money
between players and never make any.
- The coinflip and the lottery give the winner all the stakes: they move money between players and never make any.
Default prices (coins; "buy only": the market sells it but doesn't buy it, "sell only" the other way):
@@ -142,22 +136,20 @@ when selling: one diamond at 200 costs 201 and sells for 199, 64 cobblestone at
exact path of the purchase. All trades together move an item at most `max_minute_move_percent` (5%) a minute from
where it started that minute: past that, a purchase is cut short and a sale keeps the rest ("sell them in a
minute"). The factor goes back to 1 with a half-life of `half_life_hours` (6) and stays within `min_factor` (0.6)
and `max_factor` (1.6), where the rest of a trade goes at that price. A `tax_percent` (2%) of each sale and purchase
goes to the Treasury, on its own line of the receipt ("Sold 3 diamond at 198 coins each: 595 coins." then "Tax 2%:
-12 coins. You get 583 coins."). Items a player bought from the market in the last `round_trip_hours` (24) can't be
and `max_factor` (1.6), where the rest of a trade goes at that price. There is no tax on trades (an older config's
`tax_percent` is ignored): the receipt is the price ("Sold 3 diamond at 198 coins each: 595 coins."). Items a player bought from the market in the last `round_trip_hours` (24) can't be
sold back to it. (`max_trade_impact_percent` and `max_player_impact_percent` are no longer used: the curve and the
minute's band do their work.) History: one point a minute for 24 hours and one an hour for 30 days (the mid price, the
items traded and those sold), in `market_prices.json`, written by a thread every 5 minutes and when the world closes.
At the first minute of each UTC day, the day's three biggest risers and fallers (at least 1%) and news from real
activity ("Diamond supply up 40% today", sales against the day before's, at least `news_percent` and
`news_min_volume`) go to everyone's chat and to `web/market.json` for Kira to post. `enabled: false` keeps the
fixed prices of 0.23. For other mods (the futures): `Market::spot`, `twap(id, minutes, now)`, `mark_price(id, now)`
(the 10-minute TWAP from at least 5 points), `volume(id, now, hours)`, `history(id, Span)`.
fixed prices of 0.23. For other mods: `Market::spot`, `volume(id, now, hours)`, `history(id, Span)`.
**`/market`.** A 6-row chest menu: the shop's categories and the day's movers; a category's items, each with its
price, its change over 1 h and 24 h (green ▲, red ▼) and what was traded in 24 h; an item's page with its
base price, the tax and what you may still sell today, buttons to buy or sell 1, 16 and 64 (what they come to
along the curve, and the tax), sell all of it you carry, and the price chart over 24 hours or 7 days, given as a filled map (128 × 128,
base price and what you may still sell today, buttons to buy or sell 1, 16 and 64 (what they come to
along the curve), sell all of it you carry, and the price chart over 24 hours or 7 days, given as a filled map (128 × 128,
drawn by the server: the curve green when it rose, red when it fell, the price, the change, the low and high). A
chart map is drawn again each minute for whoever carries it, and once a minute the open menus follow the prices.
`/market <item>` opens the item's page. `/worth` adds the item's moves and volume.
@@ -180,14 +172,14 @@ the trade, and swaps everything in one step, or nothing (and both must confirm a
adventure players only.
**Coinflips.** Off by default: the owner turns them on in `config.json` (`gambling`: `enabled`, `min_bet` 10,
`max_bet` 1000, `cooldown_seconds` 30, `offer_seconds` 60, `fee_percent` 0). A stake is held from the offer, in
`max_bet` 1000, `cooldown_seconds` 30, `offer_seconds` 60). A stake is held from the offer, in
`economy.json` with the balances, until the coin falls; an offer cancelled, not taken in time, or whose player
left gives it back, and so does the next start for one the server stopped with. The coin is a fresh random key from
the system, never the world's random. Only the in-game currency is staked and won.
**The lottery.** Part of `gambling` (`ticket_price` 10, `max_tickets` 100 a player a draw, `lottery_seconds` 3600
from a round's first ticket to its draw). Tickets are held like coinflip stakes; at the draw one ticket is picked at
random among all sold (more tickets, more chances), its owner gets the pot (less `fee_percent`), and everyone hears
random among all sold (more tickets, more chances), its owner gets the whole pot, and everyone hears
it. A round with a single player, or one the server stopped with, gives every ticket back.
**The casino.** On with `gambling.enabled` (and `casino.enabled`, true by default). Every game is a chest menu any
@@ -211,26 +203,25 @@ Every draw is made by the server (ChaCha20 keyed from the system) before the ani
`cooldown_ms` (2000) passes between the end of a round and their next bet. A bet is held like a coinflip stake;
closing the menu or leaving never loses a round: a spin or a pull ends and is paid, a blackjack hand stands, a crash
bet is cashed out when its player leaves in flight (or given back before take-off). Wins are paid from the casino's
pot (`economy.json`'s pool `casino`), never with new money: a win larger than the pot is paid up to it. Once an hour
the pot above `float` (20,000) goes to the Treasury (`casino_profit`); below it, the Treasury tops it up with what it
holds (`casino_float`). A win of `jackpot_multiple` (50) times the stake, or of `announce_profit` (5000) coins, is
pot (`economy.json`'s pool `casino`): a win larger than the pot is paid up to it. A new casino's pot gets `float`
(20,000) once, made for it (`casino_float`; an older pot only what it lacks of it), then keeps every lost bet: the
house edge stays in the pot, out of the players' hands. A win of `jackpot_multiple` (50) times the stake, or of `announce_profit` (5000) coins, is
told to everyone. Settings in `config.json`'s `casino`, also `crash_edge_percent` (4) and `crash_max` (100).
**Places.** A teleport waits `warmup_seconds` (3) in `config.json`'s `places`; a player who moves or is hurt meanwhile
stays where they are, and `cooldown_seconds` (10) passes between two of one player's teleports. `/tpa` takes the
stays where they are, and `cooldown_seconds` (10) passes between two of one player's teleports. Teleports are free (an older config's
`places.fees` is ignored). `/tpa` takes the
asker to where the other player is when the warmup ends. Homes grow with the time played on the server since the mod
was on (`homes`: `[hours, homes]`, by default 1 home, 2 from 5 hours, 3 from 20, 5 from 100). `rules` holds `/rules`'
lines. Teleports are vanilla's `tp`, run as the server (`execute in <level> run tp`), so they cross levels and land
as any teleport does. Teleports cost a fee to the Treasury (`places.fees`: `spawn` 0, `home` 5, `warp` 10, `back` 10,
`tpa` 10, paid by the one who moves), logged as `travel_fee`. It is checked when the command runs and paid when the
teleport happens: one called off (moved, hurt, the money spent meanwhile) costs nothing and leaves `/back` and the
cooldown as they were. The spawn stays free so a lost or new player can always get back.
as any teleport does.
**Claims.** In a claimed chunk, only its owner, the players they trust and operators of level 3 break blocks or right
click blocks: doors, chests, levers, buttons, placing a block, a bucket or flint on a block. Anyone else's click does
nothing, and the action bar says whose land it is. The chunks a player may hold grow with the time played here
(`claims.chunks`: `[hours, chunks]`, by default 4, 16 from 5 hours, 36 from 20, 64 from 100); `claims.enabled`
turns them off. The world stays out too: no water or lava flows, no fire spreads or burns, and no piston pushes into
turns them off. `claims.free_radius` (0, none) keeps the land within that many blocks of 0 0, in every dimension, free
for all: no one claims a chunk that touches it, and claims already there are dropped at start (each logged). The world stays out too: no water or lava flows, no fire spreads or burns, and no piston pushes into
a claim from land that isn't its owner's (or a player's they trust), and explosions break no claimed block, whoever
lit them. Buckets are guarded where they fill or empty, and hitting the claim's item frames, armour stands,
paintings, boats, minecarts and animals does nothing for others (`claims.protected_entities`, entity ids; monsters
@@ -264,82 +255,34 @@ limited. All of it is looked at once a second.
leave the seller's inventory at once; what doesn't sell, what an auction's winner gets, and what didn't fit in a
buyer's inventory wait in the player's mail (`/ah claim`, or the menu's mail page; as much as fits, the rest stays).
A purchase takes the whole listing: the listing leaves first, the items move, then the money (the seller gets the
price less VAT, which goes to the Treasury); of two players who buy at once (the game runs every click on one
whole price); of two players who buy at once (the game runs every click on one
thread), the second finds it gone. Bids wait in the `auction` pool: a higher bid gives the last one back at once,
raising one's own only holds the difference, and a bid in the last `snipe_seconds` (60) moves the end back to
60 seconds away. When an auction ends, the seller is paid (less VAT) and the items go to the winner's mail. Sellers
60 seconds away. When an auction ends, the seller is paid the winning bid and the items go to the winner's mail. Sellers
and outbid players who are offline find a note when they come back. Everything (listings, bids, mail, notes) is
written in `economy.json` with the balances, in one step: a crash can't keep the money and lose the items, or the
reverse. Only plain items for now (not worn, named or enchanted); whole stacks with their enchantments wait for
Horizon's `native_give_stack` to be stamped. Survival and adventure players only. `config.json`'s `auction`:
`enabled`, `listing_fee_percent` (1), `max_listings` (8 a player), `listing_hours` (48), `auction_hours` (12),
`enabled`, `max_listings` (8 a player), `listing_hours` (48), `auction_hours` (12),
`auction_hours_max` (24), `bid_step_percent` (5: the smallest raise), `snipe_seconds` (60). Ended listings are
looked at once a second (a pass over the open listings).
**The Treasury and taxes.** Nobody can make money, operators included: money enters the game only through the daily
reward and what the market buys from players (later, paid contracts, from the Treasury). Every tax, fee and house edge
goes into the Treasury, a pool in `economy.json` that belongs to no player (not in `/baltop` or the sidebar), and
operators pay only from it (`/treasury pay`, with a reason everyone sees, logged as `grant`). A refund for a bug is
such a payment, never new money. `/treasury` shows its balance, its income by kind and its spending; `/treasury log`
its last 50 movements. The tax code (`config.json`'s `taxes`):
- VAT (`vat_percent`, 5): on sales between players (the auction house, next), paid by the seller from the price.
- Land tax, every Monday 00:00 UTC: `land_tax_per_chunk` (5 coins) a week per claimed chunk past the first
`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 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.
**Money in and out.** Money enters the game through what the market buys from players, the daily reward, the
casino's float (once) and operators' `/eco give` (with a reason everyone reads, logged as `eco_give`). It leaves
through what the market sells and operators' `/eco take`. There are no fees, taxes, bank, contracts or futures: no
VAT, land or wealth tax, no tax on market trades, no teleport, auction or coinflip fee. A world from before 0.30 is
cleaned up at its first start: bank savings and the margins of open futures positions go back to their owners
(`bank_closed`, `futures_closed`), and the Treasury, its insurance fund, contracts and land tax owed are dropped.
`config.json`'s old `taxes`, `bank`, `jobs` and `futures` sections and the `fee_percent` and `listing_fee_percent`
keys are ignored.
**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`).
**Contracts.** Each day at 00:00 UTC the state posts `jobs.contracts_per_day` (4) contracts drawn from `jobs.items`
(farm, mine and mob produce the market buys): "deliver 64 wheat for 128 coins", announced to everyone. `/jobs` lists
them; `/jobs deliver <n>` takes `batch` (64) plain items from the inventory (they leave the game) and pays at once from
the Treasury, logged as `contract <count> <item>`. A delivery pays the market's sell price plus `premium_percent`
(30), and never more than `ask_percent` (90) of what the market asks for the item, checked again at delivery as
prices move: buying from the market to deliver never pays. Each contract takes `deliveries` (8) for the whole
server, a player makes at most `max_per_player` (2) a day, survival or adventure only, and one day's contracts cost
at most `budget_percent` (10) of the Treasury when posted (an empty Treasury posts none). Never AFK income: only a
command with the items pays. The board is kept in `economy.json` (`ext.jobs`): a restart neither posts twice nor
loses a delivery.
**Futures.** `/futures long|short <item> <margin> [leverage]` opens a perpetual position on an item's market price:
its size is margin × leverage at the mark, in items. The mark is the market's 10-minute average of the mid
(`Market::mark_price`/`twap`), never the last trade, so one player can't move it in one go; an item needs 5 minutes of
prices first. Leverage: 2x for any traded item, +1x per `volume_per_leverage` (500) items the market traded in 24 h,
up to `max_leverage` (10x). The liquidation price is shown when it opens and in the menu. Each minute (the market's
price timer, never per tick) positions are marked: one whose equity falls to `maintenance_percent` (5%) of its size
is liquidated, online or not, and its whole margin goes to the insurance fund (the player is told when back; a
margin of `announce_margin` (1,000) or more is told to everyone). A player never loses more than the margin: a move
past it is the fund's (counted as `gaps`). The house is every trade's other side through the insurance fund (part of
the Treasury): profits come out of it, losses go in, and a position that would raise the house's net exposure past
what the fund could pay in a `stress_percent` (30%) move is refused. Fees `fee_percent` (0.2%) of the size, to open
and to close, go to the Treasury. Funding, each hour: the side with more open interest pays the fund up to
`funding_percent_hour` (0.05%) of its size, in proportion to the imbalance, from its margins. Caps: `max_player_item`
(20,000 coins of size on one item) and `max_player_total` (50,000), open interest on an item at most
`max_item_volume_days` (3) days of its market volume. Positions and their margins (the `futures` pool) are in
`economy.json` (`ext.futures`), written with the money: a restart keeps them and never liquidates twice.
**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
**The report.** `/eco stats` (anyone) shows the money supply (wallets and money held, each pool, the casino's pot),
what the server made today (market sales, the daily reward, `/eco give`) and destroyed (market purchases, operators'
`/eco take`), 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": [...],
`{"schema": 1, "generated": <ms>, "currency", "today", "pools", "minted_today", "burned_today", "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).
@@ -356,18 +299,18 @@ folder is untouched until the mod first runs on it; it changes no block and no p
| File | Holds |
|---|---|
| `config.json` | `currency` (default `coins`), `starting_balance` (default 0), `market`: `enabled`, `items` (each item's `buy`, what the market asks, and `sell`, what it pays, 0 closing that side), `daily_sell_coins` (2000) and `daily_sell_items` (640), 0 for no limit; `shop`: `enabled`, `title`, `categories` (`name`, `icon`, `items`); `web`: `url` (Kira's `/smp/ingest`) and `token` (empty: nothing is sent), where the pages of `web/` go; written with the defaults the first time, and again when a newer version adds keys (your values kept) |
| `economy.json` | balances by player UUID, with the name last seen; `held`: coinflip stakes and lottery tickets being decided; `pools`: the Treasury, its insurance fund and other system pools; `treasury`: its income and spending by kind and its last movements; `ext`: other parts' state written in the same step as the money (`tax`: the week collected, land tax owed; `auction`: the listings, bids, mail and notes) |
| `economy.json` | balances by player UUID, with the name last seen; `held`: coinflip stakes and lottery tickets being decided; `pools`: the casino's pot and the auction house's bids; `ext`: other parts' state written in the same step as the money (`auction`: the listings, bids, mail and notes) |
| `places.json` | the spawn, the warps, each player's homes, and the seconds each player has played (written once a minute) |
| `nicks.json` | each player's nickname and when they last changed it |
| `staff.log` | one line per `/invsee` or `/endersee` menu in which something moved: who, whose, what was taken and put |
| `market.json` | what each player sold today (coins and items), for the daily limits |
| `market_prices.json` | each item's price factor and history (minutes for 24 h, hours for 30 days: `[time, mid, traded, sold]`), what each player bought in the last day (no selling it back), the day's summary |
| `web/market.json` | for the website (`/hub/market`, through Kira), written by a thread each minute prices move: `schema` 1, `generated`, `currency`, `tax_percent`, `taxes_collected`, `daily_summary` (`day`, `lines`), `items` (`id`, `name`, `category`, `price` (the one price; `buy` and `sell` repeat it on the sides the market trades, for older readers), `mid`, `base`, `change_1h`, `change_24h`, `volume_24h`, `day`: 5-minute points, `month`: hours, each `{step, t, price, volume}`). When `web.token` is set, the same thread POSTs each page of `web/` that changed (gzipped, `Authorization: Bearer <token>`) to `<web.url>/<name>`: Kira keeps the last one and serves it to the site; the server needs no inbound access and Kira no key to it |
| `web/market.json` | for the website (`/hub/market`, through Kira), written by a thread each minute prices move: `schema` 1, `generated`, `currency`, `tax_percent` (always 0, for older readers), `daily_summary` (`day`, `lines`), `items` (`id`, `name`, `category`, `price` (the one price; `buy` and `sell` repeat it on the sides the market trades, for older readers), `mid`, `base`, `change_1h`, `change_24h`, `volume_24h`, `day`: 5-minute points, `month`: hours, each `{step, t, price, volume}`). When `web.token` is set, the same thread POSTs each page of `web/` that changed (gzipped, `Authorization: Bearer <token>`) to `<web.url>/<name>`: Kira keeps the last one and serves it to the site; the server needs no inbound access and Kira no key to it |
| `presence.json` | the players who have joined (no welcome again), and the day each last got the daily reward |
| `board.json` | the players who hid the sidebar (`/sidebar off`) |
| `casino.json` | the day's ledger: each player's bets, wins and rounds today (the daily loss cap), the day's biggest wins, each game's totals, the pot's last sweep |
| `claims.json` | each claimed chunk (`<dimension> <chunk x> <chunk z>`) with its owner, and whom each owner trusts |
| `audit.log` | one JSON line per change, written before the change: `time` (ms), `kind` (`open`, `pay`, `grant`, `land_tax`...), `amount`, `from`/`to` with their balance before, `by` for an operator, `to_pool`/`from_pool` with `pool_before` and `reason` for a pool's movement; a trade's line (`trade`: both players, their items and money) comes before its swap, `trade_failed` after one that didn't go through |
| `audit.log` | one JSON line per change, written before the change: `time` (ms), `kind` (`open`, `pay`, `grant`, `ah_listing_fee`...), `amount`, `from`/`to` with their balance before, `by` for an operator, `to_pool`/`from_pool` with `pool_before` and `reason` for a pool's movement; a trade's line (`trade`: both players, their items and money) comes before its swap, `trade_failed` after one that didn't go through |
`economy.json` is written whole to a temporary file, flushed and renamed over the old one, at most once a server tick
and when the world closes: a crash leaves the old file or the new one. If it can't be read, the economy stays off (the
@@ -385,8 +328,8 @@ action bar (API 21), `native_block_spread`, `native_piston_move`, `native_explos
| File | What it is |
|---|---|
| `mixins/smp/horizon-mixin.toml` | The bundle: twenty-eight 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`, `smp_report.rs`, `smp_jobs.rs`, `smp_market_prices.rs`, `smp_market_menu.rs`, `smp_market_chart.rs`, `smp_invsee.rs`, `smp_futures.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`, `game::smp_jobs`, `game::smp_market_prices`, `game::smp_market_menu`, `game::smp_market_chart`, `game::smp_invsee`, `game::smp_futures`; also this crate's modules (`src/lib.rs`), for their tests |
| `mixins/smp/horizon-mixin.toml` | The bundle: twenty-seven 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_auction.rs`, `smp_report.rs`, `smp_market_prices.rs`, `smp_market_menu.rs`, `smp_market_chart.rs`, `smp_invsee.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_auction`, `game::smp_report`, `game::smp_market_prices`, `game::smp_market_menu`, `game::smp_market_chart`, `game::smp_invsee`; 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 |
| `mixins/smp/smp_casino.rs`, `smp_casino_roulette.rs`, `smp_casino_slots.rs`, `smp_casino_crash.rs`, `smp_casino_blackjack.rs` | The casino's rules, menus and effects (`game::smp_casino*`) |
+1 -1
View File
@@ -132,7 +132,7 @@ pub(in crate::game) fn clicked(
}
/// The casino's share of the server's tick: frames, rounds that end, the
/// pot's hourly sweep. The console commands, for after the lock.
/// pot's float (once). The console commands, for after the lock.
pub(in crate::game) fn tick(
game: &mut Game<'_>,
casino: &mut Casino,
+26 -324
View File
@@ -10,9 +10,6 @@
use crate::game::{
Game,
smp_auction::{self, AuctionHouse},
smp_bank::{self, Bank},
smp_futures::{self, Futures},
smp_jobs::{self, Jobs},
smp_report::{self, Report},
native_server::{NativeCommand, NativeServerPlayer, NativeSidebar},
smp_board::{self, Board, Line},
@@ -30,7 +27,6 @@ use crate::game::{
smp_nick::{self, Names as _, Nicks},
smp_shop::{self, Shop},
smp_store,
smp_tax::{self, Taxes},
smp_trade::{self, Act, Cell, Trades},
};
use horizon_client::{
@@ -43,7 +39,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.29.0";
pub const VERSION: &str = "0.30.1";
/// `config.json` in `world/horizon-smp/`, written with its defaults the
/// first time, and again with the keys a newer version adds (values kept).
@@ -61,12 +57,8 @@ pub struct Config {
pub board: smp_board::Config,
pub presence: smp_presence::Config,
pub nicks: smp_nick::Config,
pub taxes: smp_tax::Config,
pub auction: smp_auction::Config,
pub bank: smp_bank::Config,
pub report: smp_report::Config,
pub jobs: smp_jobs::Config,
pub futures: smp_futures::Config,
pub web: WebPush,
}
@@ -95,18 +87,10 @@ pub struct Smp {
dir: PathBuf,
/// An error when `economy.json` can't be read: the commands say so.
economy: Result<Economy, String>,
/// The tax code and its weekly collection (None with the economy off).
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>,
/// `/eco stats`, the daily report, `web/economy.json`.
report: Option<Report>,
/// The day's contracts (None with the economy off).
jobs: Option<Jobs>,
/// Positions on the market's prices (None with the economy off).
futures: Option<Futures>,
market: Market,
shop_config: smp_shop::Config,
/// The `/shop` menus open.
@@ -194,23 +178,20 @@ fn open(dir: PathBuf) -> Smp {
if let Err(e) = &economy {
log::error!("[smp] the economy is off: {e}");
}
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 jobs = economy.as_ref().ok().map(|e| Jobs::open(e, config.jobs));
let futures = economy.as_ref().ok().map(|e| {
let mut f = Futures::open(e, config.futures);
f.rebase(&market.rebased);
f
});
let places = Places::open(&dir, config.places);
if let Err(e) = &places {
log::error!("[smp] homes and warps are off: {e}");
}
let claims = Claims::open(&dir, config.claims);
if let Err(e) = &claims {
log::error!("[smp] claims are off: {e}");
let mut claims = Claims::open(&dir, config.claims);
match &mut claims {
Ok(c) => {
for line in c.clear_free_zone() {
log::warn!("[smp] {line}");
}
}
Err(e) => log::error!("[smp] claims are off: {e}"),
}
let board = Board::open(&dir, config.board);
if let Err(e) = &board {
@@ -244,12 +225,8 @@ fn open(dir: PathBuf) -> Smp {
Smp {
dir,
economy,
taxes,
auction,
bank,
report,
jobs,
futures,
market,
shop_config: config.shop,
shop: Shop::default(),
@@ -288,27 +265,10 @@ fn state(dir: PathBuf) -> std::sync::MutexGuard<'static, Option<Smp>> {
}
fn save(smp: &mut Smp) {
// The tax state is written with the balances, in the same file.
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);
}
// The report's state is written with the balances, in the same file.
if let (Some(r), Ok(e)) = (&mut smp.report, &mut smp.economy) {
r.store(e);
}
if let (Some(j), Ok(e)) = (&mut smp.jobs, &mut smp.economy) {
j.store(e);
}
// And the futures' positions (their margins are in the `futures` pool).
if let (Some(f), Ok(e)) = (&mut smp.futures, &mut smp.economy) {
f.store(e);
for p in f.problems.drain(..) {
log::error!("[smp] {p}");
}
}
// 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);
@@ -414,12 +374,6 @@ pub fn commands(commands: &mut Vec<NativeCommand>) {
"afk",
"nick <name:word>",
"realname <nick:word>",
"treasury",
"treasury log",
"taxes",
"taxes pay",
"eco taxes",
"eco taxes pay",
"ah",
"ah search <words:text>",
"ah sell <price:integer> [count:integer]",
@@ -430,19 +384,8 @@ pub fn commands(commands: &mut Vec<NativeCommand>) {
"ah claim",
"ah mine",
"ah mail",
"bank",
"bank deposit <amount:word>",
"bank withdraw <amount:word>",
"eco stats",
"money stats",
"jobs",
"jobs deliver <contract:integer>",
"futures",
"futures list",
"futures info <item:item>",
"futures long <item:item> <margin:integer> [leverage:word]",
"futures short <item:item> <margin:integer> [leverage:word]",
"futures close <position:integer>",
] {
commands.push(NativeCommand::new(usage));
}
@@ -451,13 +394,13 @@ pub fn commands(commands: &mut Vec<NativeCommand>) {
"setwarp <name:word>",
"delwarp <name:word>",
"nick <name:word> <nick:word>",
"treasury pay <player:word> <amount:integer> <reason:text>",
] {
commands.push(NativeCommand::op(usage, 3));
}
// Operators take a cheat's gains into the Treasury; nobody gives or
// sets money (grants come from the Treasury: `/treasury pay`).
// Operators make money (an event prize, a bug refund: with a reason
// everyone reads) and take a cheat's gains away.
for name in ["eco", "money"] {
commands.push(NativeCommand::op(&format!("{name} give <player:word> <amount:integer> <reason:text>"), 3));
commands.push(NativeCommand::op(&format!("{name} take <player:word> <amount:integer> [reason:text]"), 3));
}
}
@@ -486,9 +429,9 @@ impl Bag for GameBag<'_, '_> {
}
}
const COMMANDS: [&str; 21] = [
const COMMANDS: [&str; 16] = [
"balance", "bal", "pay", "baltop", "eco", "money", "worth", "sell", "buy", "shop", "market", "trade", "coinflip",
"lottery", "treasury", "taxes", "ah", "bank", "jobs", "casino", "futures",
"lottery", "ah", "casino",
];
/// `/pay <player> <amount>`'s receiver (account, name) and amount, as
@@ -505,27 +448,6 @@ fn pay_target(e: &Economy, line: &str) -> Option<(u128, String, i64)> {
Some((to, account.name.clone(), amount))
}
/// The market's prices as the futures read them: the mark is the 10-minute
/// average of the mid (never the last trade), once the market has at least
/// 5 minutes of it; the volume is the last 24 hours'.
struct MarketPrices<'a>(&'a Market);
impl smp_futures::Prices for MarketPrices<'_> {
fn mark(&self, id: &str, now: i64) -> Option<f64> {
self.0.mark_price(id, now)?;
self.0.twap(id, 10, now)
}
fn volume(&self, id: &str, now: i64) -> u64 {
self.0.volume(id, now, 24)
}
}
/// The market's (sell, buy) for one item, as the contracts read it.
fn market_price(market: &Market, id: &str) -> Option<(i64, i64)> {
let p = market.price(id);
(p.sell > 0 || p.buy > 0).then_some((p.sell, p.buy))
}
/// The players on, as the places see them.
fn bodies(players: &[NativeServerPlayer]) -> Vec<Body> {
players
@@ -616,7 +538,7 @@ pub(in crate::game) fn perform(
}
}
// A nickname where a command names a player: the player's name.
let names_a_player = matches!(word, "pay" | "tpa" | "trade" | "playtime" | "tpaccept" | "tpdeny" | "eco" | "money" | "treasury");
let names_a_player = matches!(word, "pay" | "tpa" | "trade" | "playtime" | "tpaccept" | "tpdeny" | "eco" | "money");
let resolved = smp.nicks.as_ref().ok().filter(|_| names_a_player).and_then(|n| {
let accounts = smp.economy.as_ref().map(|e| e.accounts()).unwrap_or_default();
let is_name = |w: &str| players.iter().any(|p| p.name.eq_ignore_ascii_case(w)) || accounts.iter().any(|a| a.1.eq_ignore_ascii_case(w));
@@ -687,12 +609,7 @@ pub(in crate::game) fn perform(
on.or_else(|| economy?.name_of(u).map(str::to_owned))
.unwrap_or_else(|| smp_store::uuid_string(u))
};
let owed = match (&smp.taxes, economy) {
(Some(t), Some(e)) if line.split_whitespace().eq(["claim"]) => t.may_claim(e, me.uuid).err(),
_ => None,
};
match (&mut smp.claims, at) {
(Ok(_), Some(_)) if owed.is_some() => vec![Out::Error(owed.expect("checked"))],
(Ok(c), Some(at)) => c.command(&me, &at, played, &find, &name_of, line).unwrap_or_default(),
(Ok(_), None) => vec![Out::Error("You are not in the world.".into())],
(Err(_), _) => vec![Out::Error(
@@ -700,18 +617,7 @@ pub(in crate::game) fn perform(
)],
}
} else if smp_places::COMMANDS.contains(&word) {
// A teleport's fee is paid when it happens; refused now when the
// player can't pay it.
let short = match (&smp.places, &smp.economy) {
(Ok(p), Ok(e)) if p.fee(line) > e.balance(me.uuid) => Some(format!(
"This teleport costs {} (to the Treasury); you have {}.",
e.money(p.fee(line)),
e.money(e.balance(me.uuid))
)),
_ => None,
};
match &mut smp.places {
Ok(_) if short.is_some() => vec![Out::Error(short.expect("checked"))],
Ok(p) => p.command(&me, &bodies(&players), now, line).unwrap_or_default(),
Err(_) => vec![Out::Error(
"Homes and warps are off: their file can't be read (see the server log).".into(),
@@ -731,35 +637,15 @@ pub(in crate::game) fn perform(
}
Err(_) => vec![Out::Error("The economy is off: its file can't be read (see the server log).".into())],
}
} else if word == "futures" {
futures_command(game, smp, &me, now, line)
} else {
match &mut smp.economy {
Ok(e) => {
e.seen(me.uuid, &me.name);
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();
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(|| {
let price = |id: &str| market_price(&smp.market, id);
smp.jobs.as_mut()?.command(e, &me, trades, &mut bag, &price, line)
})
report
.and_then(|r| r.command(e, line))
.or_else(|| {
// A payment counts toward the receiver's daily selling
// limit (alts can't sell for a main past it).
@@ -1077,10 +963,6 @@ pub(in crate::game) fn menu_clicked(game: &mut Game<'_>, uuid: u128, window: i32
shop_clicked(game, smp, uuid, slot, mode == 1);
return;
}
if smp.futures.as_ref().is_some_and(|f| f.views.get(&uuid).is_some_and(|v| v.0 == Some(window))) {
futures_clicked(game, smp, uuid, slot);
return;
}
if smp.market_menus.views.get(&uuid).is_some_and(|v| v.0 == Some(window)) {
market_clicked(game, smp, uuid, slot);
return;
@@ -1139,12 +1021,6 @@ pub(in crate::game) fn menu_closed(game: &mut Game<'_>, uuid: u128, window: i32)
smp.shop.views.remove(&uuid);
return;
}
if let Some(f) = &mut smp.futures
&& f.views.get(&uuid).is_some_and(|v| v.0 == Some(window))
{
f.views.remove(&uuid);
return;
}
if smp.market_menus.views.get(&uuid).is_some_and(|v| v.0 == Some(window)) {
smp.market_menus.views.remove(&uuid);
return;
@@ -1436,7 +1312,6 @@ fn market_items(smp: &Smp, uuid: u128, now: i64) -> Vec<Option<ItemStack>> {
use smp_market_menu::{Cell as C, Chart};
let economy = smp.economy.as_ref().ok();
let money = |n: i64| economy.map_or_else(|| n.to_string(), |e| e.money(n));
let tax = |n: i64| smp.market.tax_on(n);
let name = |id: &str| smp_market::title_name(id);
smp.market_menus
.layout(&smp.shop_config, &smp.market.config, uuid)
@@ -1457,7 +1332,6 @@ fn market_items(smp: &Smp, uuid: u128, now: i64) -> Vec<Option<ItemStack>> {
let mut lines = price_lines(smp, &id, now);
let base = smp.market.base(&id);
lines.push((format!("Base price: {}", money(base.buy.max(base.sell))), "gray"));
lines.push((format!("Tax: {}% to the Treasury", smp.market.config.dynamic.tax_percent), "gray"));
if let Some(left) = smp.market.items_left(uuid, now, &id) {
lines.push((format!("You may still sell {left} today"), "gray"));
}
@@ -1466,19 +1340,13 @@ fn market_items(smp: &Smp, uuid: u128, now: i64) -> Vec<Option<ItemStack>> {
C::Buy(id, n) => {
// What these n cost now, the price moving as they go.
let cost = smp.market.value(&id, n, true).0;
let mut b = with_lore(
button("minecraft:lime_concrete", &format!("Buy {n} for {}", money(cost)), "green"),
&[(format!("Tax {}%: +{}", smp.market.config.dynamic.tax_percent, money(tax(cost))), "gray")],
);
let mut b = button("minecraft:lime_concrete", &format!("Buy {n} for {}", money(cost)), "green");
b.count = n;
b
}
C::Sell(id, n) => {
let gets = smp.market.value(&id, n, false).0;
let mut b = with_lore(
button("minecraft:red_concrete", &format!("Sell {n} for {}", money(gets)), "red"),
&[(format!("Tax {}%: −{}", smp.market.config.dynamic.tax_percent, money(tax(gets))), "gray")],
);
let mut b = button("minecraft:red_concrete", &format!("Sell {n} for {}", money(gets)), "red");
b.count = n;
b
}
@@ -1787,10 +1655,9 @@ fn ah_command(game: &mut Game<'_>, smp: &mut Smp, me: &Player, trades: bool, now
return vec![Out::Error("The economy is off: its file can't be read (see the server log).".into())];
};
e.seen(me.uuid, &me.name);
let vat = smp.taxes.as_ref().map_or(0, |t| t.config.vat_percent);
let reply = {
let mut bag = GameBag(game);
a.command(e, me, trades, &mut bag, vat, now, line)
a.command(e, me, trades, &mut bag, now, line)
};
match reply {
Some(smp_auction::Reply::Out(out)) => out,
@@ -1900,7 +1767,6 @@ fn ah_clicked(game: &mut Game<'_>, smp: &mut Smp, uuid: u128, slot: i32) {
};
let now = chrono::Utc::now().timestamp_millis();
let cats = smp.shop_config.categories.clone();
let vat = smp.taxes.as_ref().map_or(0, |t| t.config.vat_percent);
let (Ok(e), Some(a)) = (&mut smp.economy, &mut smp.auction) else {
return;
};
@@ -1925,7 +1791,7 @@ fn ah_clicked(game: &mut Game<'_>, smp: &mut Smp, uuid: u128, slot: i32) {
return;
}
Click::Redraw => Vec::new(),
Click::Buy(id) => a.buy(e, &me, &mut bag, id, vat, now),
Click::Buy(id) => a.buy(e, &me, &mut bag, id, now),
Click::Bid(id) => a.bid(e, &me, id, None, now),
Click::Withdraw(id) => {
let mut out = a.withdraw(&me, id);
@@ -1949,112 +1815,6 @@ fn ah_clicked(game: &mut Game<'_>, smp: &mut Smp, uuid: u128, slot: i32) {
tell(game, uuid, out);
}
/// `/futures ...`: a command, or (with no argument) the menu.
fn futures_command(game: &mut Game<'_>, smp: &mut Smp, me: &Player, now: i64, line: &str) -> Vec<Out> {
let (Ok(e), Some(f)) = (&mut smp.economy, &mut smp.futures) else {
return vec![Out::Error("The economy is off: its file can't be read (see the server log).".into())];
};
e.seen(me.uuid, &me.name);
let out = f.command(e, &MarketPrices(&smp.market), me, now, line).unwrap_or_default();
if !out.is_empty() || !line.split_whitespace().eq(["futures"]) {
return out;
}
if smp.trades.trade_of(me.uuid).is_some() {
return vec![Out::Error("Finish or cancel your trade first.".into())];
}
f.views.insert(me.uuid, (None, None));
let items = futures_items(smp, me.uuid, now);
let window = game.native_open_menu(me.uuid, smp_futures::ROWS, Text::literal("Futures"), items);
let f = smp.futures.as_mut().expect("checked");
match window {
Some(w) => {
f.views.insert(me.uuid, (Some(w), None));
Vec::new()
}
None => {
f.views.remove(&me.uuid);
vec![Out::Error("The futures menu couldn't open.".into())]
}
}
}
/// The futures menu: each position with its profit (green) or loss (red)
/// and liquidation price; the insurance fund; help.
fn futures_items(smp: &Smp, uuid: u128, now: i64) -> Vec<Option<ItemStack>> {
let (Some(f), Ok(e)) = (&smp.futures, &smp.economy) else {
return Vec::new();
};
let prices = MarketPrices(&smp.market);
f.layout(uuid)
.into_iter()
.map(|cell| {
use smp_futures::{Cell as C, Prices as _};
Some(match cell {
C::Empty => return None,
C::Position(id, armed) => {
let p = f.position(id)?;
let mark = prices.mark(&p.item, now);
let up = mark.is_none_or(|m| p.pnl(m) >= 0.0);
let line = f.describe(p, mark);
if armed {
button(&p.item, &format!("Click again to close: {line}"), "yellow")
} else {
button(&p.item, &line, if up { "green" } else { "red" })
}
}
C::Fund => button(
"minecraft:gold_block",
&format!(
"Insurance fund: {} · fees so far {} · {} liquidations",
e.money(e.pool(smp_economy::INSURANCE)),
e.money(f.data.fees),
f.data.liquidations
),
"gold",
),
C::Help => button("minecraft:book", smp_futures::HELP_TEXT, "white"),
C::Close => button("minecraft:barrier", "Close", "red"),
})
})
.collect()
}
/// A click in the futures menu: arm a position, close it, or close.
fn futures_clicked(game: &mut Game<'_>, smp: &mut Smp, uuid: u128, slot: i32) {
let Some(p) = game.native_server_players().into_iter().find(|p| p.uuid == uuid) else {
return;
};
let Ok(slot) = usize::try_from(slot) else {
return;
};
if slot >= smp_futures::SIZE {
return;
}
let me = Player {
uuid,
name: p.name.clone(),
level: p.permission,
};
let now = chrono::Utc::now().timestamp_millis();
let (Ok(e), Some(f)) = (&mut smp.economy, &mut smp.futures) else {
return;
};
e.now = now;
let out = match f.click(uuid, slot) {
smp_futures::Click::Nothing => return,
smp_futures::Click::Close => {
f.views.remove(&uuid);
game.native_close_menu(uuid);
return;
}
smp_futures::Click::Redraw => Vec::new(),
smp_futures::Click::Shut(id) => f.close(e, &MarketPrices(&smp.market), &me, id, now),
};
let items = futures_items(smp, uuid, now);
game.native_set_menu_items(uuid, items);
tell(game, uuid, out);
}
/// What a menu click answers, to the player who clicked (no command's
/// feedback there).
fn tell(game: &mut Game<'_>, uuid: u128, out: Vec<Out>) {
@@ -2118,17 +1878,6 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
let (o, go) = p.tick(&bodies(&players), now);
out.extend(o);
for t in go {
// The fee, to the Treasury, only for a teleport made; without
// the money (spent during the warmup) it is called off.
let economy = smp.economy.as_mut().ok().filter(|_| t.fee > 0);
if let Some(e) = &economy
&& e.balance(t.uuid) < t.fee
{
p.call_off(&t);
let why = format!("Teleport called off: it costs {}.", e.money(t.fee));
out.push(Out::Tell(t.uuid, why));
continue;
}
// `.3`: a whole number would be a block's centre to `tp`.
let line = format!(
"execute in {} run tp {} {:.3} {:.3} {:.3}",
@@ -2140,18 +1889,9 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
out.push(Out::Tell(t.uuid, "The teleport failed.".into()));
continue;
}
let mut done = t.done.clone();
if let Some(e) = economy {
match e.to_pool("travel_fee", t.uuid, smp_economy::TREASURY, t.fee, &t.what) {
Ok(()) => done = format!("{done} Fee: {}.", e.money(t.fee)),
Err(err) => log::warn!("[smp] travel fee: {err}"),
}
}
out.push(Out::Tell(t.uuid, done));
out.push(Out::Tell(t.uuid, t.done.clone()));
}
}
// Futures menus to draw again after the minute's marking.
let mut redraw: Vec<u128> = Vec::new();
if let Ok(e) = &mut smp.economy
&& n % 20 == 0
{
@@ -2174,41 +1914,17 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
// The auction house: notes for who came on, listings that ended.
if let Some(a) = &mut smp.auction {
let on: Vec<u128> = players.iter().map(|p| p.uuid).collect();
let vat = smp.taxes.as_ref().map_or(0, |t| t.config.vat_percent);
out.extend(a.tick(e, &on, vat, now));
}
// Futures: marked once a minute (liquidations, funding each hour);
// the open menus are drawn again then, with the new profits.
if let Some(f) = &mut smp.futures {
let on: Vec<u128> = players.iter().map(|p| p.uuid).collect();
let minute = f.data.minute;
out.extend(f.tick(e, &MarketPrices(&smp.market), &on, now));
if f.data.minute != minute {
redraw = f.views.iter().filter(|v| v.1.0.is_some()).map(|v| *v.0).collect();
}
}
// The day's contracts: an O(1) check each second, posted once a day.
if let Some(j) = &mut smp.jobs {
let price = |id: &str| market_price(&smp.market, id);
out.extend(j.tick(e, &price, 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));
out.extend(a.tick(e, &on, 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);
let (o, web) = r.tick(e, 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 doc = r.web(e, 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);
@@ -2218,16 +1934,6 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
});
}
}
// 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));
}
}
// The market's minute: prices move, open menus and carried charts
// follow, the files are written by threads; once a day, the movers.
@@ -2250,10 +1956,6 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
}
}
}
for uuid in redraw {
let items = futures_items(smp, uuid, now);
game.native_set_menu_items(uuid, items);
}
// Each trade's offers against the inventories now: a player gone or
// dead ends it; a stack that changed leaves its offer.
let sides: Vec<(u128, String)> = smp
+1 -25
View File
@@ -1,6 +1,6 @@
[mod]
id = "smp"
version = "0.29.0"
version = "0.30.1"
priority = 100
# The commit of horizon it was tested on: it needs horizon#6106 (merged at 975c07d): native_inventory,
@@ -62,36 +62,12 @@ file = "crates/horizon-client/src/game.rs"
name = "smp_report"
source = "smp_report.rs"
[[module]]
id = "jobs"
file = "crates/horizon-client/src/game.rs"
name = "smp_jobs"
source = "smp_jobs.rs"
[[module]]
id = "futures"
file = "crates/horizon-client/src/game.rs"
name = "smp_futures"
source = "smp_futures.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"
name = "smp_auction"
source = "smp_auction.rs"
[[module]]
id = "tax"
file = "crates/horizon-client/src/game.rs"
name = "smp_tax"
source = "smp_tax.rs"
[[module]]
id = "trade"
file = "crates/horizon-client/src/game.rs"
+67 -107
View File
@@ -1,6 +1,6 @@
// The auction house (`/ah`): players sell to players. A listing is a fixed
// price or a timed auction; listing costs a fee and each sale pays VAT, both
// to the Treasury. Listed items leave the seller's inventory at once; what
// price or a timed auction; listing is free, and a sale pays the seller the
// whole price. Listed items leave the seller's inventory at once; what
// doesn't sell (and what an auction's winner gets) waits in the player's mail
// (`/ah claim`, or the menu's mail page). Bids are held in the `auction`
// pool until the auction ends or someone bids more. A bid in the last
@@ -19,7 +19,7 @@
// menu from `Cell`s. Added to the game as `game::smp_auction`; 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_economy::{DAY_MS, Economy, MAX_AMOUNT, Out, Player, parse_amount};
use super::smp_market::{Bag, item_name, valid_id};
use super::smp_shop::{Category, title};
use super::smp_store::{parse_uuid, uuid_string};
@@ -57,9 +57,6 @@ const CATEGORY_SLOTS: [usize; 21] = [
#[serde(default)]
pub struct Config {
pub enabled: bool,
/// Paid when listing, in percent of the price (or starting bid), at
/// least 1 coin; to the Treasury, never given back.
pub listing_fee_percent: i64,
/// Listings one player may have at once.
pub max_listings: usize,
/// How long a fixed-price listing stays.
@@ -77,7 +74,6 @@ impl Default for Config {
fn default() -> Self {
Self {
enabled: true,
listing_fee_percent: 1,
max_listings: 8,
listing_hours: 48,
auction_hours: 12,
@@ -143,8 +139,6 @@ pub struct Today {
pub day: i64,
pub sales: u32,
pub volume: i64,
pub fees: i64,
pub vat: i64,
}
/// What `economy.json`'s `ext.auction` holds.
@@ -240,6 +234,19 @@ pub struct AuctionHouse {
pub problems: Vec<String>,
}
/// `2d 5h`, `3h 05m`, `12m`: the time left on a listing.
fn span(ms: i64) -> String {
let m = ms.max(0) / 60_000;
let (d, h, m) = (m / 1440, m / 60 % 24, m % 60);
if d > 0 {
format!("{d}d {h}h")
} else if h > 0 {
format!("{h}h {m:02}m")
} else {
format!("{m}m")
}
}
/// `price * percent / 100`, without overflow.
fn percent(price: i64, percent: i64) -> i64 {
(i128::from(price) * i128::from(percent.clamp(0, 100)) / 100) as i64
@@ -377,11 +384,10 @@ impl AuctionHouse {
&mut self.data.today
}
fn record_sale(&mut self, l: &Listing, price: i64, vat: i64, now: i64) {
fn record_sale(&mut self, l: &Listing, price: i64, now: i64) {
let t = self.count_today(now);
t.sales += 1;
t.volume = t.volume.saturating_add(price);
t.vat = t.vat.saturating_add(vat);
self.data.sales.push(Sale {
time: now,
item: l.item.clone(),
@@ -399,24 +405,17 @@ impl AuctionHouse {
format!("{} {}", l.count, item_name(&l.item))
}
/// Pays the seller `price` (from the buyer, or from the bids' pool),
/// then the VAT from it to the Treasury: what the VAT was.
/// Pays the seller `price` (from the buyer, or from the bids' pool).
fn pay_seller(
economy: &mut Economy,
from: Result<u128, ()>,
seller: u128,
price: i64,
vat_percent: i64,
) -> Result<i64, String> {
) -> Result<(), String> {
match from {
Ok(buyer) => economy.transfer("ah_sale", buyer, seller, price)?,
Err(()) => economy.from_pool("ah_sale", POOL, seller, price, "")?,
Ok(buyer) => economy.transfer("ah_sale", buyer, seller, price),
Err(()) => economy.from_pool("ah_sale", POOL, seller, price, ""),
}
let vat = percent(price, vat_percent);
if vat > 0 {
economy.to_pool("vat", seller, TREASURY, vat, "auction house sale")?;
}
Ok(vat)
}
/// Runs `/ah ...`; None for another command. `bag`: the acting player's
@@ -428,7 +427,6 @@ impl AuctionHouse {
me: &Player,
trades: bool,
bag: &mut dyn Bag,
vat_percent: i64,
now: i64,
line: &str,
) -> Option<Reply> {
@@ -485,7 +483,7 @@ impl AuctionHouse {
_ => err("Usage: /ah bid <listing> <amount>"),
},
["buy", id] => match id.parse::<u64>() {
Ok(id) => out(self.buy(economy, me, bag, id, vat_percent, now)),
Ok(id) => out(self.buy(economy, me, bag, id, now)),
Err(_) => err("Usage: /ah buy <listing>"),
},
["cancel", id] => match id.parse::<u64>() {
@@ -549,10 +547,6 @@ impl AuctionHouse {
if count == 0 || count > held {
return err(format!("You hold {held} {}.", item_name(&item)));
}
let fee = percent(price, self.config.listing_fee_percent).max(1);
if economy.balance(me.uuid) < fee {
return err(format!("Listing costs {}.", economy.money(fee)));
}
let taken = bag.take(&item, count);
if taken != count {
bag.give(&item, taken);
@@ -565,10 +559,7 @@ impl AuctionHouse {
"kind": "ah_list", "id": id, "seller": mine, "item": &item, "count": count,
"price": price, "auction": hours.is_some(), "ends": ends,
});
if let Err(e) = economy
.record(entry)
.and_then(|()| economy.to_pool("ah_listing_fee", me.uuid, TREASURY, fee, ""))
{
if let Err(e) = economy.record(entry) {
bag.give(&item, taken);
return err(e);
}
@@ -586,22 +577,19 @@ impl AuctionHouse {
};
let line = match hours {
None => format!(
"Listed #{id}: {} for {} ({:.2} each), {}h. Fee: {}.",
"Listed #{id}: {} for {} ({:.2} each), {}h.",
Self::what(&l),
economy.money(price),
price as f64 / f64::from(count),
self.config.listing_hours,
economy.money(fee)
self.config.listing_hours
),
Some(h) => format!(
"Auction #{id}: {} from {}, ends in {h}h. Fee: {}.",
"Auction #{id}: {} from {}, ends in {h}h.",
Self::what(&l),
economy.money(price),
economy.money(fee)
economy.money(price)
),
};
self.data.listings.push(l);
self.count_today(now).fees += fee;
self.dirty = true;
vec![Out::Reply(line)]
}
@@ -623,7 +611,6 @@ impl AuctionHouse {
me: &Player,
bag: &mut dyn Bag,
id: u64,
vat_percent: i64,
now: i64,
) -> Vec<Out> {
let err = |s: String| vec![Out::Error(s)];
@@ -670,14 +657,10 @@ impl AuctionHouse {
return err("The items couldn't be given: nothing was bought.".into());
}
self.post(&uuid_string(me.uuid), &l.item, l.count - given, "bought");
let vat = match Self::pay_seller(economy, Ok(me.uuid), seller, l.price, vat_percent) {
Ok(v) => v,
Err(e) => {
self.problems.push(format!("auction house sale #{id}: {e}"));
0
}
};
self.record_sale(&l, l.price, vat, now);
if let Err(e) = Self::pay_seller(economy, Ok(me.uuid), seller, l.price) {
self.problems.push(format!("auction house sale #{id}: {e}"));
}
self.record_sale(&l, l.price, now);
let mut out = vec![Out::Reply(format!(
"Bought {} from {} for {}.{}",
Self::what(&l),
@@ -690,11 +673,10 @@ impl AuctionHouse {
}
))];
let line = format!(
"{} bought your {} for {} (VAT {}).",
"{} bought your {} for {}.",
me.name,
Self::what(&l),
economy.money(l.price - vat),
economy.money(vat)
economy.money(l.price)
);
self.notify(&mut out, &l.seller, line);
out
@@ -778,7 +760,7 @@ impl AuctionHouse {
"You bid {} on #{id} ({}), ends in {}.{}",
economy.money(amount),
Self::what(&l),
super::smp_tax::span(l.ends - now),
span(l.ends - now),
if extended {
" A late bid: the end moved back."
} else {
@@ -804,10 +786,7 @@ impl AuctionHouse {
}
let l = self.data.listings.remove(i);
self.post(&l.seller, &l.item, l.count, "withdrawn");
vec![Out::Reply(format!(
"Took #{id} ({}) back. The fee stays paid.",
Self::what(&l)
))]
vec![Out::Reply(format!("Took #{id} ({}) back.", Self::what(&l)))]
}
/// Gives `uuid` their mail (one parcel, or all of it), as much as fits;
@@ -857,13 +836,7 @@ impl AuctionHouse {
/// Once a second: notes to the players who came on, and the listings
/// that ended (fixed ones back to their seller, auctions to their
/// winner). Only a comparison while nothing ended.
pub fn tick(
&mut self,
economy: &mut Economy,
online: &[u128],
vat_percent: i64,
now: i64,
) -> Vec<Out> {
pub fn tick(&mut self, economy: &mut Economy, online: &[u128], now: i64) -> Vec<Out> {
self.online = online.iter().copied().collect();
let mut out = Vec::new();
if !self.data.notes.is_empty() {
@@ -895,16 +868,12 @@ impl AuctionHouse {
continue;
};
let seller = parse_uuid(&l.seller).unwrap_or_default();
let vat = match Self::pay_seller(economy, Err(()), seller, b.amount, vat_percent) {
Ok(v) => v,
Err(e) => {
self.problems
.push(format!("auction #{} payment: {e}", l.id));
0
}
};
if let Err(e) = Self::pay_seller(economy, Err(()), seller, b.amount) {
self.problems
.push(format!("auction #{} payment: {e}", l.id));
}
self.post(&b.uuid, &l.item, l.count, "won");
self.record_sale(&l, b.amount, vat, now);
self.record_sale(&l, b.amount, now);
let line = format!(
"You won #{} ({}) for {}: the items are in your mail (/ah claim).",
l.id,
@@ -913,12 +882,11 @@ impl AuctionHouse {
);
self.notify(&mut out, &b.uuid, line);
let line = format!(
"Auction #{} ({}) sold to {} for {} (VAT {}).",
"Auction #{} ({}) sold to {} for {}.",
l.id,
Self::what(&l),
b.name,
economy.money(b.amount - vat),
economy.money(vat)
economy.money(b.amount)
);
self.notify(&mut out, &l.seller.clone(), line);
}
@@ -1098,7 +1066,7 @@ impl AuctionHouse {
/// A listing's name on its button.
pub fn label(&self, l: &Listing, now: i64, money: &dyn Fn(i64) -> String) -> String {
let each = l.amount() as f64 / f64::from(l.count.max(1));
let left = super::smp_tax::span(l.ends - now);
let left = span(l.ends - now);
let head = format!("#{} {} {}", l.id, l.count, title(&l.item));
if l.auction {
let bid = match &l.bid {
@@ -1163,7 +1131,6 @@ mod tests {
const STEVE: u128 = 2;
const NOTCH: u128 = 3;
const T0: i64 = 1_793_577_600_000;
const VAT: i64 = 5;
/// A player's inventory: plain stacks by id, 36 slots of 64.
#[derive(Default)]
@@ -1250,7 +1217,7 @@ mod tests {
/// Every coin of the game: balances and pools.
fn total(e: &Economy) -> i64 {
e.balance(ALEX) + e.balance(STEVE) + e.balance(NOTCH) + e.pool(TREASURY) + e.pool(POOL)
e.balance(ALEX) + e.balance(STEVE) + e.balance(NOTCH) + e.pool(POOL)
}
fn run(
@@ -1261,14 +1228,14 @@ mod tests {
now: i64,
line: &str,
) -> Vec<Out> {
match ah.command(e, me, true, bag, VAT, now, line) {
match ah.command(e, me, true, bag, now, line) {
Some(Reply::Out(o)) => o,
other => panic!("{line}: {other:?}"),
}
}
#[test]
fn a_fixed_sale_moves_items_once_and_pays_fee_and_vat_to_the_treasury() {
fn a_fixed_sale_moves_items_once_and_pays_the_seller_the_whole_price() {
let (mut e, mut ah, _dir) = setup("fixed");
let mut alex = holding("diamond", 10);
let out = run(
@@ -1287,13 +1254,13 @@ mod tests {
alex.items["minecraft:diamond"], 0,
"the items left the inventory"
);
assert_eq!((e.balance(ALEX), e.pool(TREASURY)), (990, 10), "1% fee");
assert_eq!(e.balance(ALEX), 1000, "listing is free");
// Steve and Notch click "buy" in the same tick: one gets it.
let mut steve = empty();
let mut notch = empty();
ah.online = [ALEX, STEVE, NOTCH].into();
let a = ah.buy(&mut e, &p(STEVE, "Steve"), &mut steve, 1, VAT, T0 + 1);
let b = ah.buy(&mut e, &p(NOTCH, "Notch"), &mut notch, 1, VAT, T0 + 1);
let a = ah.buy(&mut e, &p(STEVE, "Steve"), &mut steve, 1, T0 + 1);
let b = ah.buy(&mut e, &p(NOTCH, "Notch"), &mut notch, 1, T0 + 1);
assert!(
matches!(&a[0], Out::Reply(s) if s.starts_with("Bought 10 diamond from Alex")),
"{a:?}"
@@ -1311,19 +1278,16 @@ mod tests {
),
(10, None)
);
// Alex gets 1,000 less 5% VAT; Steve paid 1,000.
// Alex gets the whole 1,000 Steve paid.
assert_eq!(
(e.balance(ALEX), e.balance(STEVE), e.balance(NOTCH)),
(990 + 950, 0, 1000)
(1000 + 1000, 0, 1000)
);
assert_eq!(e.pool(TREASURY), 10 + 50);
assert_eq!(total(&e), 3000, "nothing made, nothing lost");
assert!(a.contains(&Out::Tell(
ALEX,
"Steve bought your 10 diamond for 950 coins (VAT 50 coins).".into()
"Steve bought your 10 diamond for 1,000 coins.".into()
)));
let t = e.treasury();
assert_eq!((t.income["vat"], t.income["ah_listing_fee"]), (50, 10));
}
#[test]
@@ -1347,16 +1311,16 @@ mod tests {
);
// Creative players can't list or buy.
assert!(matches!(
ah.command(&mut e, &alex, false, &mut bag, VAT, T0, "ah sell 10"),
ah.command(&mut e, &alex, false, &mut bag, T0, "ah sell 10"),
Some(Reply::Out(o)) if matches!(&o[0], Out::Error(_))
));
run(&mut ah, &mut e, &alex, &mut bag, T0, "ah sell 5000 5");
// Own listing, too poor, no room, a failed give: nothing moves.
assert!(
matches!(&ah.buy(&mut e, &alex, &mut empty(), 1, VAT, T0)[0], Out::Error(s) if s.starts_with("That's your own"))
matches!(&ah.buy(&mut e, &alex, &mut empty(), 1, T0)[0], Out::Error(s) if s.starts_with("That's your own"))
);
assert!(
matches!(&ah.buy(&mut e, &p(STEVE, "Steve"), &mut empty(), 1, VAT, T0)[0], Out::Error(s) if s.starts_with("You only have"))
matches!(&ah.buy(&mut e, &p(STEVE, "Steve"), &mut empty(), 1, T0)[0], Out::Error(s) if s.starts_with("You only have"))
);
e.adjust("sell", STEVE, 10_000, "").unwrap();
let mut full = Inv {
@@ -1364,7 +1328,7 @@ mod tests {
..Inv::default()
};
assert!(
matches!(&ah.buy(&mut e, &p(STEVE, "Steve"), &mut full, 1, VAT, T0)[0], Out::Error(s) if s.starts_with("Make room"))
matches!(&ah.buy(&mut e, &p(STEVE, "Steve"), &mut full, 1, T0)[0], Out::Error(s) if s.starts_with("Make room"))
);
let mut broken = Inv {
slots_free: 5,
@@ -1372,7 +1336,7 @@ mod tests {
..Inv::default()
};
assert!(matches!(
&ah.buy(&mut e, &p(STEVE, "Steve"), &mut broken, 1, VAT, T0)[0],
&ah.buy(&mut e, &p(STEVE, "Steve"), &mut broken, 1, T0)[0],
Out::Error(_)
));
assert!(ah.listing(1).is_some(), "the listing stays for sale");
@@ -1437,8 +1401,7 @@ mod tests {
);
assert_eq!(ah.listing(1).unwrap().ends, late + 60_000);
assert!(
ah.tick(&mut e, &[STEVE, NOTCH], VAT, late + 59_000)
.is_empty(),
ah.tick(&mut e, &[STEVE, NOTCH], late + 59_000).is_empty(),
"not over yet"
);
// A restart mid-auction: everything comes back from economy.json.
@@ -1447,7 +1410,7 @@ mod tests {
let mut e = Economy::open(&dir, EcoConfig::default()).unwrap();
let mut ah = AuctionHouse::open(&e, Config::default());
assert_eq!((e.pool(POOL), ah.listing(1).unwrap().amount()), (400, 400));
let out = ah.tick(&mut e, &[STEVE, NOTCH], VAT, late + 60_000);
let out = ah.tick(&mut e, &[STEVE, NOTCH], late + 60_000);
assert!(out.contains(&Out::Tell(
STEVE,
"You won #1 (3 emerald) for 400 coins: the items are in your mail (/ah claim).".into()
@@ -1455,20 +1418,17 @@ mod tests {
assert_eq!(e.pool(POOL), 0);
assert_eq!(
e.balance(ALEX),
999 + 400 - 20,
"paid while offline, less 5% VAT (fee: 1)"
);
assert_eq!(
e.balance(STEVE) + e.balance(NOTCH) + e.balance(ALEX) + e.pool(TREASURY),
3000
1000 + 400,
"paid while offline, the whole bid"
);
assert_eq!(e.balance(STEVE) + e.balance(NOTCH) + e.balance(ALEX), 3000);
// Alex reads the note when back.
let out = ah.tick(&mut e, &[ALEX], VAT, late + 61_000);
let out = ah.tick(&mut e, &[ALEX], late + 61_000);
assert_eq!(
out,
[Out::Tell(
ALEX,
"Auction #1 (3 emerald) sold to Steve for 380 coins (VAT 20 coins).".into()
"Auction #1 (3 emerald) sold to Steve for 400 coins.".into()
)]
);
// Steve claims: the items, once.
@@ -1496,7 +1456,7 @@ mod tests {
run(&mut ah, &mut e, &alex, &mut more, T0, "ah auction 50 1");
ah.online = [ALEX].into();
// The auction (1 h) ends with no bid; the fixed one (48 h) later.
let out = ah.tick(&mut e, &[ALEX], VAT, T0 + 3_600_000);
let out = ah.tick(&mut e, &[ALEX], T0 + 3_600_000);
assert_eq!(
out,
[Out::Tell(
@@ -1504,7 +1464,7 @@ mod tests {
"#2 (64 wheat) ended with no bids: the items are in your mail (/ah claim).".into()
)]
);
ah.tick(&mut e, &[ALEX], VAT, T0 + 48 * 3_600_000);
ah.tick(&mut e, &[ALEX], T0 + 48 * 3_600_000);
assert_eq!(ah.data.listings.len(), 0);
assert_eq!(ah.mail_of(ALEX).len(), 2);
// Room for one stack only: one parcel comes, the other waits.
-442
View File
@@ -1,442 +0,0 @@
// 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);
}
}
+53 -58
View File
@@ -9,9 +9,9 @@
// each player staked and got back today, the day's biggest wins, each game's
// totals) and the lobby menu. Winnings are paid from the casino's own pot
// (the economy's "casino" pool, which every lost bet fills); a win the pot
// can't cover is paid up to what it holds, never with new money. Once an
// hour the pot above its float goes to the Treasury: the house edge is the
// state's income, a money sink for the players. Each game is a file of its
// can't cover is paid up to what it holds. A new casino's pot gets its
// float once, made for it; then it keeps the house edge, a money sink for
// the players. Each game is a file of its
// own (smp_casino_roulette, ...). No game types: the glue (casino_glue.rs)
// turns the icons and effects into menus, packets and commands. Added to the
// game as `game::smp_casino`; also a module of this crate, for its tests.
@@ -20,7 +20,7 @@ use super::smp_casino_blackjack::{self as bj, Blackjack};
use super::smp_casino_crash::{self as crash, Crash};
use super::smp_casino_roulette::{self as roulette, Roulette};
use super::smp_casino_slots::{self as slots, Slots};
use super::smp_economy::{Economy, Out, Player, TREASURY};
use super::smp_economy::{Economy, Out, Player};
use super::smp_store::{read_json, uuid_string, write_json};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
@@ -34,7 +34,6 @@ pub const SIZE: usize = 54;
/// The least time between two pictures of an animated menu: 5 a second.
pub const FRAME_MS: i64 = 200;
const DAY_MS: i64 = 86_400_000;
const HOUR_MS: i64 = 3_600_000;
/// Wins kept in the day's list.
const WINS: usize = 10;
@@ -53,7 +52,7 @@ pub struct Config {
pub daily_win_cap: i64,
/// Between the end of a player's round and their next bet.
pub cooldown_ms: i64,
/// What the pot keeps for paying wins; above it, the Treasury's.
/// What a new casino's pot starts with, for paying wins (made once).
pub float: i64,
/// A win of at least this profit is told to everyone.
pub announce_profit: i64,
@@ -398,10 +397,13 @@ pub struct Ledger {
/// The day's biggest wins (by profit), biggest first.
pub wins: Vec<Win>,
pub totals: BTreeMap<String, Totals>,
/// When the pot last sent its profit to the Treasury (ms).
pub last_sweep: i64,
}
/// The mark, in `economy.json`'s `ext`, that the pot got its float: kept with
/// the pot's money so one write saves both, and a lost or unreadable
/// `casino.json` can't make the float again.
const FLOATED: &str = "casino_float";
/// A bet placed and held, for a game to decide.
#[derive(Clone, Debug, PartialEq)]
pub struct Stake {
@@ -710,31 +712,21 @@ impl Casino {
fx
}
/// Once an hour: the pot above its float goes to the Treasury; below
/// it, the Treasury tops it up with what it can spare. Never new money.
pub fn sweep(&mut self, economy: &mut Economy, now: i64) -> Option<String> {
if now - self.ledger.last_sweep < HOUR_MS {
/// Once, ever: a new casino's pot gets its float (what it lacks of
/// it), made for it. A world whose pot already holds money (from before
/// 0.30) gets only what it lacks.
pub fn seed(&mut self, economy: &mut Economy) -> Option<String> {
if economy.ext(FLOATED) == Some(&serde_json::Value::Bool(true)) {
return None;
}
self.ledger.last_sweep = now;
self.dirty = true;
let (pot, float) = (economy.pool(POT), self.config.float.max(0));
let result = if pot > float {
economy.pool_move(
"casino_profit",
POT,
TREASURY,
pot - float,
"the casino's pot above its float",
)
} else {
let top_up = (float - pot).min(economy.pool(TREASURY));
if top_up <= 0 {
return None;
}
economy.pool_move("casino_float", TREASURY, POT, top_up, "the casino's float")
};
result.err()
economy.set_ext(FLOATED, serde_json::Value::Bool(true));
let lacks = self.config.float.max(0) - economy.pool(POT);
if lacks <= 0 {
return None;
}
economy
.mint_pool("casino_float", POT, lacks, "the casino's float")
.err()
}
/// The day's biggest wins, as lines.
@@ -823,7 +815,7 @@ impl Casino {
),
"Every draw is made by the server (ChaCha20 seeded by the system), before the animation starts.".into(),
format!(
"Wins are paid from the casino's pot ({} now); a win larger than the pot is paid up to it. The house's profit goes to the Treasury.",
"Wins are paid from the casino's pot ({} now); a win larger than the pot is paid up to it. The house's profit stays in it.",
economy.money(economy.pool(POT))
),
];
@@ -1499,7 +1491,7 @@ impl Casino {
}
/// Each tick while something moves (`animating`), and once a second:
/// the frames due, the rounds that end, the hourly sweep.
/// the frames due, the rounds that end, the pot's float (once).
pub fn tick(&mut self, economy: &mut Economy, now: i64) -> Vec<Fx> {
let mut fx = Vec::new();
for (spin, n) in self.roulette.ended(now) {
@@ -1569,9 +1561,9 @@ impl Casino {
});
}
}
if let Some(e) = self.sweep(economy, now) {
if let Some(e) = self.seed(economy) {
fx.push(Fx::Out(Out::All(format!(
"[casino] the pot's sweep failed: {e}"
"[casino] the pot's float failed: {e}"
))));
}
fx
@@ -1662,20 +1654,14 @@ pub(crate) mod tests {
}
let (mut c, problem) = Casino::open(&dir, Config::default(), true);
assert!(problem.is_none());
// Draws the same every run; no sweep unless asked.
// Draws the same every run; the pot as set up.
c.rng = Rng::from_seed([7; 32]);
c.ledger.last_sweep = i64::MAX / 2;
e.set_ext(FLOATED, serde_json::Value::Bool(true));
(e, c, dir)
}
pub fn money(e: &Economy) -> i64 {
e.balance(A)
+ e.balance(B)
+ e.balance(99)
+ e.held(A)
+ e.held(B)
+ e.pool(POT)
+ e.pool(TREASURY)
e.balance(A) + e.balance(B) + e.balance(99) + e.held(A) + e.held(B) + e.pool(POT)
}
#[test]
@@ -1902,20 +1888,29 @@ pub(crate) mod tests {
}
#[test]
fn the_sweep_sends_the_profit_to_the_treasury_and_never_mints() {
let (mut e, mut c, dir) = setup("sweep", 25_000);
let before = money(&e);
c.ledger.last_sweep = 0;
assert_eq!(c.sweep(&mut e, HOUR_MS), None);
assert_eq!((e.pool(POT), e.pool(TREASURY)), (20_000, 5_000));
// Not again within the hour.
e.pool_move("test", POT, TREASURY, 18_000, "test").unwrap();
c.sweep(&mut e, HOUR_MS + 1);
assert_eq!(e.pool(POT), 2000);
// An hour later the Treasury tops the float up with what it holds.
c.sweep(&mut e, 2 * HOUR_MS);
assert_eq!((e.pool(POT), e.pool(TREASURY)), (20_000, 5_000));
assert_eq!(money(&e), before);
fn a_new_pot_gets_its_float_once_and_keeps_the_house_edge() {
let (mut e, mut c, dir) = setup("seed", 0);
e.set_ext(FLOATED, serde_json::Value::Bool(false));
assert_eq!(c.seed(&mut e), None);
assert_eq!(e.pool(POT), 20_000);
assert_eq!(e.flows().minted["casino_float"], 20_000);
// Never again, however low the pot runs.
e.pool_move("test", POT, "elsewhere", 19_000, "test")
.unwrap();
assert_eq!(c.seed(&mut e), None);
assert_eq!(e.pool(POT), 1000);
// Nor after a restart that lost casino.json: the mark is the economy's.
e.save().unwrap();
let mut e = Economy::open(&dir, EconomyConfig::default()).unwrap();
std::fs::remove_file(dir.join("casino.json")).ok();
let (mut c, _) = Casino::open(&dir, Config::default(), true);
assert_eq!(c.seed(&mut e), None);
assert_eq!(e.pool(POT), 1000, "no second float");
// An old pot with money gets only what it lacks.
let (mut e, mut c, _) = setup("seed-old", 25_000);
e.set_ext(FLOATED, serde_json::Value::Bool(false));
c.seed(&mut e);
assert_eq!(e.pool(POT), 25_000, "above the float: nothing made");
std::fs::remove_dir_all(dir).unwrap();
}
+93 -9
View File
@@ -25,6 +25,9 @@ pub struct Config {
pub chunks: Vec<[i64; 2]>,
/// The entities only the owner and trusted players may hit in a claim.
pub protected_entities: Vec<String>,
/// Blocks around 0 0, in every dimension, where no one may claim (free
/// for all); 0 for none. Claims already there are dropped at start.
pub free_radius: i64,
}
impl Default for Config {
@@ -36,6 +39,7 @@ impl Default for Config {
.iter()
.map(|&e| format!("minecraft:{e}"))
.collect(),
free_radius: 0,
}
}
}
@@ -183,6 +187,43 @@ impl Claims {
})
}
/// Whether a chunk touches the free zone around 0 0 (any dimension).
fn in_free_zone(&self, cx: i32, cz: i32) -> bool {
let r = self.config.free_radius;
let near = |c: i32| i64::from(c) * 16 <= r && i64::from(c) * 16 + 15 >= -r;
r > 0 && near(cx) && near(cz)
}
/// Drops the claims inside the free zone (a radius set after they were
/// made): one line each, for the log.
pub fn clear_free_zone(&mut self) -> Vec<String> {
let inside: Vec<(String, i32, i32, u128)> = self
.owners
.iter()
.flat_map(|(d, c)| {
c.iter()
.filter(|((x, z), _)| self.in_free_zone(*x, *z))
.map(|((x, z), u)| (d.clone(), *x, *z, *u))
})
.collect();
let mut lines = Vec::new();
for (dimension, cx, cz, owner) in inside {
if let Some(c) = self.owners.get_mut(&dimension) {
c.remove(&(cx, cz));
}
self.data.chunks.remove(&key(&dimension, cx, cz));
self.changed = true;
lines.push(format!(
"chunk {cx} {cz} (x {}, z {}) in {dimension} of {} is in the free zone: unclaimed",
cx * 16,
cz * 16,
smp_store::uuid_string(owner)
));
}
lines.sort();
lines
}
pub fn save(&mut self) -> Result<(), String> {
if self.changed {
smp_store::write_json(&self.path, &self.data)?;
@@ -271,15 +312,6 @@ impl Claims {
Err(Some(format!("This land is claimed by {}.", name_of(owner))))
}
/// How many chunks each owner holds (the weekly land tax), one pass.
pub fn counts(&self) -> std::collections::HashMap<u128, usize> {
let mut out = std::collections::HashMap::new();
for &u in self.owners.values().flat_map(|c| c.values()) {
*out.entry(u).or_default() += 1;
}
out
}
/// How many chunks `uuid` holds.
pub fn held(&self, uuid: u128) -> usize {
self.owners
@@ -334,6 +366,10 @@ impl Claims {
("claim", []) => match self.chunk_owner(here, cx, cz) {
Some(u) if u == me.uuid => vec![Out::Error("This chunk is already yours.".into())],
Some(u) => vec![Out::Error(format!("This chunk is {}'s.", name_of(u)))],
None if self.in_free_zone(cx, cz) => vec![Out::Error(format!(
"No claims within {} blocks of 0 0: that land is free for all.",
self.config.free_radius
))],
None => {
let (held, limit) = (self.held(me.uuid), self.limit(played));
if held >= limit {
@@ -513,6 +549,54 @@ mod tests {
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn no_claims_in_the_free_zone_and_old_ones_are_dropped() {
let d = dir();
let mut c = Claims::open(&d, Config::default()).unwrap();
let alex = player(A, 0);
for (x, z) in [(8.0, 8.0), (-520.0, 8.0), (600.0, 600.0)] {
c.command(&alex, &at(x, z), 1_000_000, &find, &name_of, "claim")
.unwrap();
}
c.save().unwrap();
let config = Config {
free_radius: 500,
..Config::default()
};
let mut c = Claims::open(&d, config).unwrap();
// Chunk 0 0 and chunk -33 0 (x -528 to -513: outside 500) stay apart.
assert_eq!(c.clear_free_zone().len(), 1);
let ow = "minecraft:overworld";
assert_eq!(c.owner(ow, 8, 8), None);
assert_eq!(c.owner(ow, -520, 8), Some(A));
assert_eq!(c.owner(ow, 600, 600), Some(A));
let out = c
.command(
&alex,
&at(490.0, -490.0),
1_000_000,
&find,
&name_of,
"claim",
)
.unwrap();
assert!(matches!(&out[0], Out::Error(e) if e.contains("free for all")));
// The zone is the same in every dimension.
let nether = Place {
dimension: "minecraft:the_nether".into(),
..at(8.0, 8.0)
};
let out = c
.command(&alex, &nether, 1_000_000, &find, &name_of, "claim")
.unwrap();
assert!(matches!(&out[0], Out::Error(e) if e.contains("free for all")));
// Saved without them.
c.save().unwrap();
let again = Claims::open(&d, Config::default()).unwrap();
assert_eq!(again.owner(ow, 8, 8), None);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn the_world_stays_out_of_a_claim() {
let d = dir();
+274 -233
View File
@@ -42,46 +42,6 @@ pub struct Account {
pub balance: i64,
}
/// What the Treasury paid or got, for `/treasury log`.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Movement {
pub time: i64,
/// `vat`, `land_tax`, `grant`...
pub kind: String,
/// Positive: income; negative: spending.
pub amount: i64,
/// The player's name last seen.
pub player: String,
/// Why, as the operator wrote it (payments); empty for taxes.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub reason: String,
/// The operator who paid it (name).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub by: String,
}
/// The Treasury: the state's account. Every tax, fee and house edge goes in;
/// operators' grants, refunds and prizes (and later bank interest and
/// contracts) come out, never more than it holds. Nobody can make money.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Treasury {
/// Since the Treasury opened, by kind.
pub income: BTreeMap<String, i64>,
pub spent: BTreeMap<String, i64>,
/// Today's (UTC day number), by kind.
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>,
}
/// Movements `/treasury log` keeps.
pub const LOG: usize = 50;
/// Milliseconds in a day.
pub const DAY_MS: i64 = 86_400_000;
@@ -100,16 +60,14 @@ pub struct Accounts {
/// balances, so one write moves both and a crash can't split them.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub ext: BTreeMap<String, serde_json::Value>,
/// System pools, by name: the Treasury (`TREASURY`), its insurance
/// fund (`INSURANCE`), the casino's pot. Money in a pool belongs to no
/// player: never in `accounts()`, `/baltop` or the sidebar.
/// System pools, by name: the casino's pot, the auction house's bids.
/// Money in a pool belongs to no player: never in `accounts()`,
/// `/baltop` or the sidebar.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub pools: BTreeMap<String, i64>,
/// 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`.
/// purchases, the daily reward, operators' `/eco give` and `/eco take`),
/// by kind, for `/eco stats`.
#[serde(default)]
pub flows: Flows,
}
@@ -144,10 +102,10 @@ impl Flows {
}
}
/// The state's pool: taxes, fees and house edges in; grants out.
pub const TREASURY: &str = "treasury";
/// Part of the state: covers what a liquidated position can't.
pub const INSURANCE: &str = "insurance";
/// What 0.30 took out (the Treasury, its insurance fund, the bank, the
/// futures, the jobs, the taxes): their pools and their state in `ext`.
const RETIRED_POOLS: [&str; 4] = ["treasury", "insurance", "bank", "futures"];
const RETIRED_PARTS: [&str; 4] = ["bank", "futures", "jobs", "tax"];
/// A player as a command sees them.
#[derive(Clone, Debug, PartialEq)]
@@ -198,6 +156,7 @@ impl Economy {
revision: 0,
now: 0,
};
economy.retire()?;
// Games the last run left undecided: their stakes go back.
let held: Vec<(String, i64)> = economy.accounts.held.clone().into_iter().collect();
for (key, amount) in held {
@@ -208,6 +167,60 @@ impl Economy {
Ok(economy)
}
/// Once, on a world from before 0.30: bank savings and open futures
/// positions' margins go back to their owners (logged `bank_closed`,
/// `futures_closed`), and the retired parts' pools and state are dropped.
fn retire(&mut self) -> Result<(), String> {
let mut back: Vec<(&str, u128, i64)> = Vec::new();
let ext = &self.accounts.ext;
if let Some(accounts) = ext
.get("bank")
.and_then(|b| b.get("accounts"))
.and_then(|a| a.as_object())
{
for (key, saving) in accounts {
let amount = saving.get("amount").and_then(|a| a.as_i64()).unwrap_or(0);
if let Some(u) = super::smp_store::parse_uuid(key) {
back.push(("bank_closed", u, amount));
}
}
}
if let Some(positions) = ext
.get("futures")
.and_then(|f| f.get("positions"))
.and_then(|p| p.as_array())
{
for p in positions {
let owner = p
.get("owner")
.and_then(|o| o.as_str())
.and_then(super::smp_store::parse_uuid);
let margin = p.get("margin").and_then(|m| m.as_i64()).unwrap_or(0);
if let Some(u) = owner {
back.push(("futures_closed", u, margin));
}
}
}
for (kind, uuid, amount) in back.into_iter().filter(|b| b.2 > 0) {
let entry = self.entry(kind, None, Some(uuid), amount, "");
self.audit.append(&entry)?;
let account = self.accounts.accounts.entry(uuid_string(uuid)).or_default();
account.balance = account.balance.saturating_add(amount);
self.touch();
}
for part in RETIRED_PARTS {
if self.accounts.ext.remove(part).is_some() {
self.touch();
}
}
for pool in RETIRED_POOLS {
if self.accounts.pools.remove(pool).is_some() {
self.touch();
}
}
Ok(())
}
fn touch(&mut self) {
self.dirty = true;
self.revision += 1;
@@ -295,30 +308,23 @@ impl Economy {
Ok(())
}
/// A game decided: each stake leaves held, and `winner` gets `prize`
/// (at most the stakes together), logged as `kind`.
/// A game decided: each stake leaves held, and `winner` gets them all,
/// logged as `kind`.
pub fn award(
&mut self,
kind: &str,
stakes: &[(u128, i64)],
winner: u128,
prize: i64,
) -> Result<(), String> {
let total: i64 = stakes.iter().map(|(_, a)| *a).sum();
if stakes.iter().any(|(u, a)| self.held(*u) < *a) || !(0..=total).contains(&prize) {
if stakes.iter().any(|(u, a)| self.held(*u) < *a) {
return Err("The stakes are not held.".into());
}
let fee = total - prize;
let prize: i64 = stakes.iter().map(|(_, a)| *a).sum();
let mut entry = self.entry(kind, None, Some(winner), prize, "");
entry["stakes"] = stakes
.iter()
.map(|(u, a)| serde_json::json!({ "uuid": uuid_string(*u), "amount": a }))
.collect();
if fee > 0 {
// The house's fee goes to the Treasury, not away.
entry["fee"] = fee.into();
entry["to_pool"] = TREASURY.into();
}
self.audit.append(&entry)?;
for (u, a) in stakes {
self.take_held(&uuid_string(*u), *a);
@@ -329,10 +335,6 @@ impl Economy {
.entry(uuid_string(winner))
.or_default();
account.balance = account.balance.saturating_add(prize);
if fee > 0 {
self.pool_add(TREASURY, fee);
self.count_treasury("gambling_fee", fee, Some(winner), "", "");
}
self.touch();
Ok(())
}
@@ -351,11 +353,6 @@ impl Economy {
self.accounts.pools.get(name).copied().unwrap_or(0)
}
/// What went into and out of the Treasury.
pub fn treasury(&self) -> &Treasury {
&self.accounts.treasury
}
fn pool_add(&mut self, name: &str, delta: i64) {
let p = self.accounts.pools.entry(name.to_owned()).or_default();
*p = p.saturating_add(delta);
@@ -364,61 +361,8 @@ impl Economy {
}
}
/// Counts a Treasury movement (`amount` > 0 income, < 0 spending).
fn count_treasury(
&mut self,
kind: &str,
amount: i64,
player: Option<u128>,
reason: &str,
by: &str,
) {
if amount == 0 {
return;
}
let day = self.now.div_euclid(DAY_MS);
let player = player
.and_then(|u| self.name_of(u).map(str::to_owned))
.unwrap_or_default();
let by = super::smp_store::parse_uuid(by)
.and_then(|u| self.name_of(u).map(str::to_owned))
.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;
}
let (all, today) = if amount > 0 {
(&mut t.income, &mut t.income_today)
} else {
(&mut t.spent, &mut t.spent_today)
};
for map in [all, today] {
let v = map.entry(kind.to_owned()).or_default();
*v = v.saturating_add(amount.abs());
}
t.log.push(Movement {
time: self.now,
kind: kind.to_owned(),
amount,
player,
reason: reason.to_owned(),
by,
});
if t.log.len() > LOG {
let extra = t.log.len() - LOG;
t.log.drain(..extra);
}
}
/// A tax, a fee, a stake lost: `amount` of `uuid`'s balance goes into
/// `pool`, logged as `kind`. An Err when they hold less.
/// A bid, a bet: `amount` of `uuid`'s balance goes into `pool`, logged
/// as `kind`. An Err when they hold less.
pub fn to_pool(
&mut self,
kind: &str,
@@ -463,16 +407,13 @@ impl Economy {
.expect("checked")
.balance -= amount;
self.pool_add(pool, amount);
if pool == TREASURY {
self.count_treasury(kind, amount, Some(uuid), reason, by);
}
self.touch();
Ok(())
}
/// A payment from `pool` to `uuid` (a grant, a refund, a payout),
/// A payment from `pool` to `uuid` (a refund, a payout),
/// logged as `kind`: never more than the pool holds.
#[allow(dead_code)] // the casino, the auction house and the futures
#[allow(dead_code)] // the casino and the auction house
pub fn from_pool(
&mut self,
kind: &str,
@@ -521,16 +462,13 @@ impl Economy {
.expect("checked")
.balance += amount;
self.pool_add(pool, -amount);
if pool == TREASURY {
self.count_treasury(kind, -amount, Some(uuid), reason, by);
}
self.touch();
Ok(())
}
/// Moves `amount` from one pool to another (the casino's profit to the
/// Treasury), logged as `kind`; an Err when `from` holds less.
#[allow(dead_code)] // the casino, the auction house and the futures
/// Moves `amount` from one pool to another, logged as `kind`; an Err
/// when `from` holds less.
#[allow(dead_code)] // the casino and the auction house
pub fn pool_move(
&mut self,
kind: &str,
@@ -557,12 +495,29 @@ impl Economy {
self.audit.append(&entry)?;
self.pool_add(from, -amount);
self.pool_add(to, amount);
if to == TREASURY {
self.count_treasury(kind, amount, None, reason, "");
}
if from == TREASURY {
self.count_treasury(kind, -amount, None, reason, "");
self.touch();
Ok(())
}
/// Money made for a pool (the casino's float, once), logged as `kind`
/// and counted as made.
pub fn mint_pool(
&mut self,
kind: &str,
pool: &str,
amount: i64,
reason: &str,
) -> Result<(), String> {
if amount <= 0 || amount > MAX_AMOUNT {
return Err("The amount must be a positive whole number.".into());
}
let entry = serde_json::json!({
"time": self.now, "kind": kind, "amount": amount,
"to_pool": pool, "pool_before": self.pool(pool), "reason": reason,
});
self.audit.append(&entry)?;
self.pool_add(pool, amount);
self.count_flow(kind, amount);
self.touch();
Ok(())
}
@@ -570,7 +525,7 @@ impl Economy {
/// A bet against a pool decided: `uuid`'s held `stake` goes into
/// `pool`, then `payout` (at most what the pool then holds) comes out
/// to them; one log line. What was paid. Never makes money.
#[allow(dead_code)] // the casino, the auction house and the futures
#[allow(dead_code)] // the casino and the auction house
pub fn settle_pool(
&mut self,
kind: &str,
@@ -595,9 +550,6 @@ impl Economy {
self.pool_add(pool, stake - paid);
let account = self.accounts.accounts.entry(key).or_default();
account.balance = account.balance.saturating_add(paid);
if pool == TREASURY {
self.count_treasury(kind, stake - paid, Some(uuid), "", "");
}
self.touch();
Ok(paid)
}
@@ -768,6 +720,18 @@ impl Economy {
/// Adds (or with a negative `delta`, takes) money from the server: the
/// market, games, operators. The balance never goes below zero.
pub fn adjust(&mut self, kind: &str, uuid: u128, delta: i64, by: &str) -> Result<i64, String> {
self.adjust_why(kind, uuid, delta, by, "")
}
/// [`Self::adjust`] with the operator's reason in the log.
pub fn adjust_why(
&mut self,
kind: &str,
uuid: u128,
delta: i64,
by: &str,
reason: &str,
) -> Result<i64, String> {
let key = uuid_string(uuid);
let Some(a) = self.accounts.accounts.get(&key) else {
return Err("No such account.".into());
@@ -784,13 +748,23 @@ impl Economy {
} else {
(None, Some(uuid))
};
let entry = self.entry(kind, from, to, delta.abs(), by);
let mut entry = self.entry(kind, from, to, delta.abs(), by);
if !reason.is_empty() {
entry["reason"] = reason.into();
}
self.audit.append(&entry)?;
self.accounts
.accounts
.get_mut(&key)
.expect("checked")
.balance = after;
self.count_flow(kind, delta);
self.touch();
Ok(after)
}
/// Counts money made (`delta` > 0) or destroyed, by the kind's first word.
fn count_flow(&mut self, kind: &str, delta: i64) {
let flows = &mut self.accounts.flows;
flows.roll(self.now.div_euclid(DAY_MS));
let family = kind.split(' ').next().unwrap_or(kind).to_owned();
@@ -800,8 +774,6 @@ impl Economy {
&mut flows.burned
};
*map.entry(family).or_default() += delta.abs();
self.touch();
Ok(after)
}
/// Appends a line of another kind to the audit log (a trade's items),
@@ -905,41 +877,56 @@ impl Economy {
out
}
/// `/eco take <player> <amount> [reason]`: an operator takes money from
/// a player (a cheat's gains) into the Treasury. Nobody can make money:
/// `give` and `set` say where grants come from now.
/// `/eco give <player> <amount> <reason>` (made for them: an event
/// prize, a bug refund) and `/eco take <player> <amount> [reason]` (a
/// cheat's gains, destroyed): operators of level 3. A gift is told to
/// everyone, with its reason.
fn eco(&mut self, me: &Player, args: &[&str]) -> Vec<Out> {
if me.level < 3 {
return vec![Out::Error("Only operators of level 3 may use /eco.".into())];
}
const USAGE: &str = "Usage: /eco take <player> <amount> [reason]";
match args {
["give" | "set" | "add", ..] => vec![Out::Error(
"Nobody can make money, operators included. Pay from the Treasury: /treasury pay <player> <amount> <reason>"
.into(),
)],
["take", who, amount, reason @ ..] => {
let Some(amount) = parse_amount(amount) else {
return vec![Out::Error(
"The amount must be a positive whole number.".into(),
)];
};
let Some((uuid, account)) = self.find(who) else {
return vec![Out::Error(format!("No player named {who} has an account."))];
};
let name = account.name.clone();
let reason = reason.join(" ");
let by = uuid_string(me.uuid);
match self.into_pool("eco_take", uuid, TREASURY, amount, &reason, &by) {
Ok(()) => vec![Out::Reply(format!(
"Took {} from {name} into the Treasury. They have {}.",
self.money(amount),
self.money(self.balance(uuid))
))],
Err(e) => vec![Out::Error(e)],
}
const USAGE: &str =
"Usage: /eco give <player> <amount> <reason>, /eco take <player> <amount> [reason]";
let (give, who, amount, reason) = match args {
[verb @ ("give" | "take"), who, amount, reason @ ..] => {
(*verb == "give", *who, *amount, reason.join(" "))
}
_ => vec![Out::Error(USAGE.into())],
_ => return vec![Out::Error(USAGE.into())],
};
let Some(amount) = parse_amount(amount) else {
return vec![Out::Error(
"The amount must be a positive whole number.".into(),
)];
};
let reason = reason.trim().to_owned();
if give && reason.chars().count() < 3 {
return vec![Out::Error(
"Say why: /eco give <player> <amount> <reason> (an event prize, a bug refund...)"
.into(),
)];
}
let Some((uuid, account)) = self.find(who) else {
return vec![Out::Error(format!("No player named {who} has an account."))];
};
let name = account.name.clone();
let by = uuid_string(me.uuid);
let (kind, delta) = if give {
("eco_give", amount)
} else {
("eco_take", -amount)
};
match self.adjust_why(kind, uuid, delta, &by, &reason) {
Ok(_) if give => vec![Out::All(format!(
"{} gave {} to {name}: {reason}.",
me.name,
self.money(amount)
))],
Ok(after) => vec![Out::Reply(format!(
"Took {} from {name}. They have {}.",
self.money(amount),
self.money(after)
))],
Err(e) => vec![Out::Error(e)],
}
}
}
@@ -1067,90 +1054,151 @@ mod tests {
}
#[test]
fn nobody_makes_money_and_eco_take_goes_to_the_treasury() {
fn operators_give_with_a_reason_and_take_and_both_are_logged() {
let (mut e, dir) = economy("eco");
let op = player(ALEX, "Alex", 4);
for line in [
"eco give Steve 5",
"money give * 1000",
"eco set Steve 1234567",
"eco add Steve 1",
] {
assert!(
matches!(&e.command(&op, &[], line).unwrap()[0], Out::Error(s) if s.starts_with("Nobody can make money")),
"{line}"
);
}
assert_eq!(
e.command(&player(ALEX, "Alex", 2), &[], "eco take Steve 5")
e.command(&player(ALEX, "Alex", 2), &[], "eco give Steve 5 a prize")
.unwrap(),
[Out::Error("Only operators of level 3 may use /eco.".into())]
);
assert!(
matches!(&e.command(&op, &[], "eco give Steve 5").unwrap()[0], Out::Error(s) if s.starts_with("Say why")),
"a gift needs its reason"
);
assert!(
matches!(&e.command(&op, &[], "eco set Steve 5").unwrap()[0], Out::Error(s) if s.starts_with("Usage"))
);
assert_eq!(
e.command(&op, &[], "money give Steve 50 build contest")
.unwrap(),
[Out::All(
"Alex gave 50 coins to Steve: build contest.".into()
)]
);
assert_eq!(
e.command(&op, &[], "eco take Steve 200").unwrap(),
[Out::Error("Only 100 coins there.".into())]
[Out::Error("Only 150 coins there.".into())]
);
assert_eq!(
e.command(&op, &[], "eco take Steve 40 duped diamonds")
.unwrap(),
[Out::Reply(
"Took 40 coins from Steve into the Treasury. They have 60 coins.".into()
"Took 40 coins from Steve. They have 110 coins.".into()
)]
);
assert_eq!((e.balance(STEVE), e.pool(TREASURY)), (60, 40));
assert_eq!(e.treasury().income["eco_take"], 40);
let m = e.treasury().log.last().unwrap();
assert_eq!(e.balance(STEVE), 110);
let flows = e.flows();
assert_eq!(
(m.player.as_str(), m.reason.as_str(), m.by.as_str()),
("Steve", "duped diamonds", "Alex")
(flows.minted["eco_give"], flows.burned["eco_take"]),
(50, 40)
);
let log = log_lines(&dir);
let last = log.last().unwrap();
let [.., give, take] = &log[..] else {
panic!("{log:?}")
};
assert_eq!(
(last["kind"].as_str(), last["to_pool"].as_str()),
(Some("eco_take"), Some("treasury"))
(
give["kind"].as_str(),
give["reason"].as_str(),
give["amount"].as_i64()
),
(Some("eco_give"), Some("build contest"), Some(50))
);
assert_eq!(last["by"], uuid_string(ALEX));
// Total money (balances + pools) never changed: 2 accounts of 100.
assert_eq!(e.balance(ALEX) + e.balance(STEVE) + e.pool(TREASURY), 200);
assert_eq!(
(
take["kind"].as_str(),
take["reason"].as_str(),
take["from"].as_str()
),
(
Some("eco_take"),
Some("duped diamonds"),
Some(uuid_string(STEVE).as_str())
)
);
assert_eq!(take["by"], uuid_string(ALEX));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn pools_pay_only_what_they_hold_and_survive_a_restart() {
let (mut e, dir) = economy("pools");
e.to_pool("vat", ALEX, TREASURY, 30, "").unwrap();
e.to_pool("ah_bid", ALEX, "ah", 30, "").unwrap();
assert!(
e.to_pool("vat", ALEX, TREASURY, 71, "").is_err(),
e.to_pool("ah_bid", ALEX, "ah", 71, "").is_err(),
"more than Alex has"
);
assert!(
e.from_pool("grant", TREASURY, STEVE, 31, "event").is_err(),
"more than the Treasury holds"
e.from_pool("ah_sale", "ah", STEVE, 31, "").is_err(),
"more than the pool holds"
);
e.from_pool("grant", TREASURY, STEVE, 10, "event prize")
e.from_pool("ah_sale", "ah", STEVE, 10, "").unwrap();
e.pool_move("test", "ah", "casino", 5, "").unwrap();
e.mint_pool("casino_float", "casino", 100, "the casino's float")
.unwrap();
e.pool_move("insurance_fund", TREASURY, INSURANCE, 5, "")
.unwrap();
assert_eq!((e.pool(TREASURY), e.pool(INSURANCE)), (15, 5));
assert_eq!((e.pool("ah"), e.pool("casino")), (15, 105));
assert_eq!((e.balance(ALEX), e.balance(STEVE)), (70, 110));
let t = e.treasury();
assert_eq!(
(t.income["vat"], t.spent["grant"], t.spent["insurance_fund"]),
(30, 10, 5)
);
assert_eq!(t.income_today["vat"], 30);
assert_eq!(e.flows().minted["casino_float"], 100);
// Pools are nobody's: not in the ranking.
assert_eq!(e.accounts().len(), 2);
e.save().unwrap();
let again = Economy::open(&dir, Config::default()).unwrap();
assert_eq!((again.pool(TREASURY), again.pool(INSURANCE)), (15, 5));
assert_eq!(again.treasury().log.len(), 3);
assert_eq!((again.pool("ah"), again.pool("casino")), (15, 105));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn settle_pool_never_pays_more_than_the_pool_and_award_fees_go_to_the_treasury() {
fn a_world_from_before_0_30_gets_its_savings_and_margins_back() {
let dir = std::env::temp_dir().join(format!("smp-eco-retire-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let (a, s) = (uuid_string(ALEX), uuid_string(STEVE));
let old = serde_json::json!({
"accounts": {a.clone(): {"name": "Alex", "balance": 10}, s.clone(): {"name": "Steve", "balance": 20}},
"pools": {"treasury": 5000, "insurance": 300, "bank": 700, "futures": 90, "casino": 40},
"treasury": {"income": {"vat": 12}, "log": []},
"ext": {
"bank": {"accounts": {a.clone(): {"amount": 500, "low": 500, "earned": 3}, s.clone(): {"amount": 200}}},
"futures": {"positions": [{"id": 1, "owner": s.clone(), "name": "Steve", "item": "minecraft:diamond",
"long": true, "size": 1.0, "entry": 200.0, "margin": 90, "leverage": 2, "opened": 0}]},
"jobs": {"day": 3},
"tax": {"week": 2},
"auction": {"listings": []}
}
});
fs::write(dir.join("economy.json"), old.to_string()).unwrap();
let mut e = Economy::open(&dir, Config::default()).unwrap();
assert_eq!((e.balance(ALEX), e.balance(STEVE)), (510, 310));
assert_eq!(
e.pools().keys().collect::<Vec<_>>(),
["casino"],
"the retired pools are gone"
);
assert!(
e.ext("bank").is_none()
&& e.ext("futures").is_none()
&& e.ext("jobs").is_none()
&& e.ext("tax").is_none()
);
assert!(e.ext("auction").is_some(), "the auction house stays");
let kinds: Vec<_> = log_lines(&dir)
.iter()
.map(|l| l["kind"].as_str().unwrap().to_owned())
.collect();
assert_eq!(kinds, ["bank_closed", "bank_closed", "futures_closed"]);
e.save().unwrap();
let saved = fs::read_to_string(dir.join("economy.json")).unwrap();
assert!(!saved.contains("treasury"), "{saved}");
// Only once.
let again = Economy::open(&dir, Config::default()).unwrap();
assert_eq!((again.balance(ALEX), again.balance(STEVE)), (510, 310));
assert_eq!(log_lines(&dir).len(), 3);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn settle_pool_never_pays_more_than_the_pool_and_award_pays_the_stakes() {
let (mut e, dir) = economy("settle");
e.hold("bet", ALEX, 50).unwrap();
// The pool held nothing: a 1,000 payout is capped at the stake.
@@ -1166,21 +1214,14 @@ mod tests {
e.settle_pool("roulette", ALEX, 10, 0, "casino").is_err(),
"nothing held"
);
// A coinflip with a 10% fee: the fee is the Treasury's.
// A coinflip: the winner gets both stakes, no fee.
e.hold("coinflip_stake", ALEX, 50).unwrap();
e.hold("coinflip_stake", STEVE, 50).unwrap();
e.award("coinflip_win", &[(ALEX, 50), (STEVE, 50)], STEVE, 90)
e.award("coinflip_win", &[(ALEX, 50), (STEVE, 50)], STEVE)
.unwrap();
assert_eq!(
(e.balance(ALEX), e.balance(STEVE), e.pool(TREASURY)),
(10, 140, 10)
);
assert_eq!(e.treasury().income["gambling_fee"], 10);
assert_eq!((e.balance(ALEX), e.balance(STEVE)), (10, 150));
// Nothing made: 200 at the start.
assert_eq!(
e.balance(ALEX) + e.balance(STEVE) + e.pool(TREASURY) + e.pool("casino"),
200
);
assert_eq!(e.balance(ALEX) + e.balance(STEVE) + e.pool("casino"), 200);
fs::remove_dir_all(dir).unwrap();
}
File diff suppressed because it is too large. Load diff
+2 -6
View File
@@ -25,8 +25,6 @@ pub struct Config {
pub cooldown_seconds: i64,
/// How long an offer waits before its stake goes back.
pub offer_seconds: i64,
/// What the server keeps of the pot, in percent (0: the winner takes all).
pub fee_percent: i64,
/// The lottery: a ticket's price, the tickets one player may hold in a
/// draw, and the time from a round's first ticket to its draw.
pub ticket_price: i64,
@@ -42,7 +40,6 @@ impl Default for Config {
max_bet: 1000,
cooldown_seconds: 30,
offer_seconds: 60,
fee_percent: 0,
ticket_price: 10,
max_tickets: 100,
lottery_seconds: 3600,
@@ -284,15 +281,14 @@ impl Games {
return vec![Out::Error(e)];
}
let o = self.offers.remove(i);
let pot = amount * 2;
let prize = pot - pot * self.config.fee_percent.clamp(0, 100) / 100;
let prize = amount * 2;
let (winner, loser, winner_name, loser_name) = if heads() {
(o.from, me.uuid, o.from_name.as_str(), me.name.as_str())
} else {
(me.uuid, o.from, me.name.as_str(), o.from_name.as_str())
};
let stakes = [(o.from, amount), (me.uuid, amount)];
if let Err(e) = economy.award("coinflip_win", &stakes, winner, prize) {
if let Err(e) = economy.award("coinflip_win", &stakes, winner) {
// Not decided: both stakes go back (or, failing that, at the next start).
for (u, a) in stakes {
let _ = economy.release("coinflip_cancel", u, a);
+7 -1
View File
@@ -163,7 +163,9 @@ impl<S: Stack> Invsee<S> {
return Some((vec![Out::Error(format!("Usage: /{name} <player>"))], None));
};
match find(who) {
Some((uuid, _)) if uuid == me.uuid => {
// Your own inventory would move stacks into itself; your own
// ender chest is a second container, so an operator may open it.
Some((uuid, _)) if uuid == me.uuid && kind == Kind::Player => {
Some((vec![Out::Error("That is your own inventory.".into())], None))
}
Some((uuid, real)) => Some((Vec::new(), Some((uuid, real, kind)))),
@@ -496,6 +498,10 @@ mod tests {
i.command(&op(), &find, "invsee Boss").unwrap().0,
[Out::Error("That is your own inventory.".into())]
);
assert_eq!(
i.command(&op(), &find, "endersee Boss").unwrap().1,
Some((1, "Boss".into(), Kind::Ender))
);
assert_eq!(
i.command(&op(), &find, "endersee Nobody").unwrap().0,
[Out::Error("No player named Nobody has played here.".into())]
-571
View File
@@ -1,571 +0,0 @@
// The contracts board (`/jobs`): each day at 00:00 UTC the state posts a few
// contracts ("deliver 64 wheat for 160 coins"), paid from the Treasury: the
// taxes go back to players who gather what the server asks for, never new
// money. A delivery takes plain items from the player's inventory (they
// leave the game: a sink for items) and pays at once. Never AFK income: only
// a command with the items in hand pays; each contract has a few deliveries
// for the whole server, and a player makes at most `max_per_player` a day.
//
// Prices: each item pays its market sell price plus `premium_percent`, and
// never more than `ask_percent` of what the market asks for it (checked
// again at delivery, as the market's prices move): buying from the market to
// deliver never pays. One day's contracts together cost at most
// `budget_percent` of the Treasury when posted. The board lives in
// `economy.json` (`ext.jobs`), written with the money. No game types; the
// market's prices come through a closure. Added to the game as
// `game::smp_jobs`; also a module of this crate, for its tests.
use super::smp_economy::{DAY_MS, Economy, Out, Player, TREASURY};
use super::smp_market::{Bag, item_name};
use super::smp_store::uuid_string;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// `config.json`'s `jobs`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub enabled: bool,
pub contracts_per_day: usize,
/// Items in one delivery.
pub batch: u32,
/// Deliveries of one contract for the whole server.
pub deliveries: u32,
/// Deliveries one player makes in a day, all contracts.
pub max_per_player: u32,
/// Over the market's sell price.
pub premium_percent: i64,
/// Of the market's ask (its buy price), at most.
pub ask_percent: i64,
/// The day's contracts cost at most this share of the Treasury.
pub budget_percent: i64,
/// What contracts may ask for (items the market buys).
pub items: Vec<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: true,
contracts_per_day: 4,
batch: 64,
deliveries: 8,
max_per_player: 2,
premium_percent: 30,
ask_percent: 90,
budget_percent: 10,
items: [
"wheat",
"carrot",
"potato",
"beetroot",
"sugar_cane",
"pumpkin",
"melon_slice",
"cactus",
"oak_log",
"spruce_log",
"birch_log",
"cobblestone",
"coal",
"raw_iron",
"raw_copper",
"redstone",
"lapis_lazuli",
"leather",
"string",
"bone",
"feather",
"gunpowder",
"kelp",
"bamboo",
]
.map(|i| format!("minecraft:{i}"))
.into(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Contract {
pub item: String,
pub count: u32,
/// Coins a delivery pays (at most; less if the market's ask fell).
pub pay: i64,
pub left: u32,
}
/// `ext.jobs`.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Board {
pub day: i64,
pub contracts: Vec<Contract>,
/// Deliveries today, by player.
pub done: BTreeMap<String, u32>,
pub paid_today: i64,
pub paid: i64,
}
pub struct Jobs {
pub config: Config,
pub board: Board,
dirty: bool,
}
/// `(sell, buy)`: what the market pays for one item and what it asks; None
/// when it doesn't trade it.
pub type PriceOf<'a> = &'a dyn Fn(&str) -> Option<(i64, i64)>;
/// A number from `day` and `i`, the same on every run (no world random).
fn pick(day: i64, i: u64) -> u64 {
let mut x =
(day as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ i.wrapping_mul(0xBF58_476D_1CE4_E5B9);
x ^= x >> 31;
x = x.wrapping_mul(0x94D0_49BB_1331_11EB);
x ^ (x >> 29)
}
impl Jobs {
pub fn open(economy: &Economy, config: Config) -> Self {
let board = economy
.ext("jobs")
.and_then(|v| serde_json::from_value::<Board>(v.clone()).ok())
.unwrap_or_default();
Self {
config,
board,
dirty: false,
}
}
pub fn store(&mut self, economy: &mut Economy) {
if self.dirty {
economy.set_ext(
"jobs",
serde_json::to_value(&self.board).unwrap_or_default(),
);
self.dirty = false;
}
}
/// One item's pay: the sell price plus the premium, below the ask.
fn unit(&self, sell: i64, buy: i64) -> i64 {
let mut unit = sell * (100 + self.config.premium_percent) / 100;
if buy > 0 {
unit = unit.min(buy * self.config.ask_percent / 100);
}
unit.max(0)
}
/// Once a second from the glue; at 00:00 UTC, the day's contracts.
pub fn tick(&mut self, economy: &Economy, price: PriceOf, now: i64) -> Vec<Out> {
let day = now.div_euclid(DAY_MS);
if day == self.board.day || !self.config.enabled {
return Vec::new();
}
self.board.day = day;
self.board.done.clear();
self.board.paid_today = 0;
self.board.contracts.clear();
self.dirty = true;
let mut pool: Vec<(String, i64)> = self
.config
.items
.iter()
.filter_map(|i| {
let (sell, buy) = price(i)?;
let unit = self.unit(sell, buy);
(sell > 0 && unit > 0).then(|| (i.clone(), unit))
})
.collect();
let mut budget =
economy.pool(TREASURY).max(0) * self.config.budget_percent.clamp(0, 100) / 100;
for n in 0..self.config.contracts_per_day as u64 {
if pool.is_empty() {
break;
}
let (item, unit) = pool.remove((pick(day, n) % pool.len() as u64) as usize);
let pay = unit * i64::from(self.config.batch);
let left = (budget / pay.max(1)).min(i64::from(self.config.deliveries)) as u32;
if left == 0 {
continue;
}
budget -= pay * i64::from(left);
self.board.contracts.push(Contract {
item,
count: self.config.batch,
pay,
left,
});
}
if self.board.contracts.is_empty() {
return Vec::new();
}
vec![Out::All(format!(
"Today's contracts are up (/jobs): {}.",
self.board
.contracts
.iter()
.map(|c| format!(
"{} {} for {}",
c.count,
item_name(&c.item),
economy.money(c.pay)
))
.collect::<Vec<_>>()
.join(", ")
))]
}
/// `/jobs ...`; None for another command. `trades`: the player is in
/// survival or adventure (a creative player could make the items).
pub fn command(
&mut self,
economy: &mut Economy,
me: &Player,
trades: bool,
bag: &mut dyn Bag,
price: PriceOf,
line: &str,
) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
if words.first() != Some(&"jobs") {
return None;
}
if !self.config.enabled {
return Some(vec![Out::Error(
"There are no contracts on this server.".into(),
)]);
}
Some(match words[1..] {
[] => self.list(economy, me),
["deliver", n] => match n.trim_start_matches('#').parse::<usize>() {
Ok(n) if n >= 1 => self.deliver(economy, me, trades, bag, price, n - 1),
_ => vec![Out::Error("Usage: /jobs deliver <contract number>".into())],
},
_ => vec![Out::Error(
"Usage: /jobs, /jobs deliver <contract number>".into(),
)],
})
}
fn list(&self, economy: &Economy, me: &Player) -> Vec<Out> {
if self.board.contracts.is_empty() {
return vec![Out::Reply(
"No contracts today: the Treasury has nothing to spend.".into(),
)];
}
let done = self
.board
.done
.get(&uuid_string(me.uuid))
.copied()
.unwrap_or(0);
let mut out = vec![Out::Reply(format!(
"Today's contracts, paid by the Treasury (you've done {done} of {} today):",
self.config.max_per_player
))];
for (i, c) in self.board.contracts.iter().enumerate() {
out.push(Out::Reply(format!(
"#{} deliver {} {} for {} ({} left) · /jobs deliver {}",
i + 1,
c.count,
item_name(&c.item),
economy.money(c.pay),
c.left,
i + 1
)));
}
out
}
fn deliver(
&mut self,
economy: &mut Economy,
me: &Player,
trades: bool,
bag: &mut dyn Bag,
price: PriceOf,
i: usize,
) -> Vec<Out> {
let err = |s: String| vec![Out::Error(s)];
if !trades {
return err("Contracts are for players in survival or adventure mode.".into());
}
let Some(c) = self.board.contracts.get(i).cloned() else {
return err(format!("There's no contract #{} today (/jobs).", i + 1));
};
if c.left == 0 {
return err(format!("Contract #{} is done for today.", i + 1));
}
let key = uuid_string(me.uuid);
let done = self.board.done.get(&key).copied().unwrap_or(0);
if done >= self.config.max_per_player {
return err(format!(
"You've made your {} deliveries today; new contracts at 00:00 UTC.",
self.config.max_per_player
));
}
// The market's prices now: buying there to deliver never pays.
let pay = match price(&c.item) {
Some((sell, buy)) => c.pay.min(self.unit(sell, buy) * i64::from(c.count)),
None => c.pay,
};
if pay <= 0 || economy.pool(TREASURY) < pay {
return err("The Treasury can't pay that contract now.".into());
}
if bag.count(&c.item) < c.count {
return err(format!(
"You need {} plain {} in your inventory.",
c.count,
item_name(&c.item)
));
}
let took = bag.take(&c.item, c.count);
if took < c.count {
bag.give(&c.item, took);
return err(format!(
"You need {} plain {} in your inventory.",
c.count,
item_name(&c.item)
));
}
if let Err(e) = economy.from_pool(
&format!("contract {} {}", c.count, c.item),
TREASURY,
me.uuid,
pay,
"",
) {
bag.give(&c.item, took);
return err(e);
}
let contract = &mut self.board.contracts[i];
contract.left -= 1;
self.board.done.insert(key, done + 1);
self.board.paid_today += pay;
self.board.paid += pay;
self.dirty = true;
vec![Out::Reply(format!(
"Delivered {} {}: the Treasury paid you {}.",
c.count,
item_name(&c.item),
economy.money(pay)
))]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::smp_economy::Config as EcoConfig;
use std::collections::HashMap;
const A: u128 = 1;
const B: u128 = 2;
const T0: i64 = 1_793_577_600_000;
struct Inv(HashMap<String, u32>);
impl Bag for Inv {
fn held(&self) -> Option<(String, u32, bool)> {
None
}
fn carried(&self) -> Vec<(String, u32)> {
self.0.iter().map(|(k, v)| (k.clone(), *v)).collect()
}
fn count(&mut self, item: &str) -> u32 {
self.0.get(item).copied().unwrap_or(0)
}
fn take(&mut self, item: &str, count: u32) -> u32 {
let have = self.0.entry(item.into()).or_default();
let t = count.min(*have);
*have -= t;
t
}
fn room(&mut self, _: &str) -> u32 {
u32::MAX
}
fn give(&mut self, item: &str, count: u32) -> u32 {
*self.0.entry(item.into()).or_default() += count;
count
}
}
fn me(u: u128) -> Player {
Player {
uuid: u,
name: format!("P{u}"),
level: 0,
}
}
/// wheat sells 2, asks 3; cobblestone sells 1, asks 4; sand sells 1,
/// asks 1 (no room for a premium: never posted).
fn price(id: &str) -> Option<(i64, i64)> {
match id {
"minecraft:wheat" => Some((2, 3)),
"minecraft:cobblestone" => Some((1, 4)),
"minecraft:sand" => Some((1, 1)),
_ => None,
}
}
fn setup(name: &str, treasury: i64) -> (Economy, Jobs) {
let dir = std::env::temp_dir().join(format!("smp-jobs-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut e = Economy::open(&dir, EcoConfig::default()).unwrap();
e.now = T0;
e.seen(A, "PA");
e.seen(B, "PB");
if treasury > 0 {
e.adjust("sell", B, treasury, "").unwrap();
e.to_pool("seed", B, TREASURY, treasury, "").unwrap();
}
let config = Config {
items: ["wheat", "cobblestone", "dirt", "sand"]
.map(|i| format!("minecraft:{i}"))
.into(),
..Config::default()
};
let j = Jobs::open(&e, config);
(e, j)
}
#[test]
fn contracts_pay_from_the_treasury_below_the_market_ask_within_the_budget() {
let (mut e, mut j) = setup("post", 10_000);
let out = j.tick(&e, &price, T0);
assert_eq!(
j.board.contracts.len(),
2,
"dirt isn't traded, sand leaves no premium"
);
let wheat = j
.board
.contracts
.iter()
.find(|c| c.item == "minecraft:wheat")
.unwrap();
// 2 × 1.3 = 2 (whole coins), under 90% of 3: 128 a delivery.
assert_eq!(wheat.pay, 128);
assert!(matches!(&out[0], Out::All(s) if s.starts_with("Today's contracts are up")));
let cost: i64 = j
.board
.contracts
.iter()
.map(|c| c.pay * i64::from(c.left))
.sum();
assert!(cost <= 1_000, "{cost}");
assert!(j.tick(&e, &price, T0 + 5_000).is_empty(), "once a day");
let _ = &mut e;
}
#[test]
fn a_delivery_takes_the_items_and_pays_once_within_the_limits() {
let (mut e, mut j) = setup("deliver", 10_000);
j.tick(&e, &price, T0);
let n = j
.board
.contracts
.iter()
.position(|c| c.item == "minecraft:wheat")
.unwrap()
+ 1;
let mut bag = Inv(HashMap::from([("minecraft:wheat".to_owned(), 200)]));
let line = format!("jobs deliver {n}");
let out = j
.command(&mut e, &me(A), false, &mut bag, &price, &line)
.unwrap();
assert!(
matches!(&out[0], Out::Error(s) if s.contains("survival")),
"creative can't"
);
let out = j
.command(&mut e, &me(A), true, &mut bag, &price, &line)
.unwrap();
assert_eq!(
out,
[Out::Reply(
"Delivered 64 wheat: the Treasury paid you 128 coins.".into()
)]
);
assert_eq!(
(e.balance(A), e.pool(TREASURY), bag.0["minecraft:wheat"]),
(128, 9_872, 136)
);
j.command(&mut e, &me(A), true, &mut bag, &price, &line)
.unwrap();
let out = j
.command(&mut e, &me(A), true, &mut bag, &price, &line)
.unwrap();
assert!(
matches!(&out[0], Out::Error(s) if s.contains("2 deliveries today")),
"{out:?}"
);
assert_eq!(bag.0["minecraft:wheat"], 72, "nothing taken when refused");
// Too few items: nothing taken, nothing paid.
let mut few = Inv(HashMap::from([("minecraft:wheat".to_owned(), 63)]));
let out = j
.command(&mut e, &me(B), true, &mut few, &price, &line)
.unwrap();
assert!(matches!(&out[0], Out::Error(s) if s.starts_with("You need 64")));
assert_eq!(few.0["minecraft:wheat"], 63);
}
#[test]
fn a_falling_ask_lowers_the_pay_so_buying_to_deliver_never_pays() {
let (mut e, mut j) = setup("ask", 10_000);
j.tick(&e, &price, T0);
let n = j
.board
.contracts
.iter()
.position(|c| c.item == "minecraft:wheat")
.unwrap()
+ 1;
// The market now asks 2 for wheat: a delivery pays 64 × 1 (90% of 2), not 128.
let cheap = |id: &str| {
if id == "minecraft:wheat" {
Some((1, 2))
} else {
price(id)
}
};
let mut bag = Inv(HashMap::from([("minecraft:wheat".to_owned(), 64)]));
j.command(
&mut e,
&me(A),
true,
&mut bag,
&cheap,
&format!("jobs deliver {n}"),
)
.unwrap();
assert!(
e.balance(A) < 64 * 2,
"less than buying them cost: {}",
e.balance(A)
);
}
#[test]
fn an_empty_treasury_posts_nothing_and_the_board_survives_a_restart() {
let (e, mut j) = setup("empty", 0);
assert!(j.tick(&e, &price, T0).is_empty());
assert!(j.board.contracts.is_empty());
let (mut e, mut j) = setup("restart", 10_000);
j.tick(&e, &price, T0);
j.store(&mut e);
e.save().unwrap();
let dir = std::env::temp_dir().join(format!("smp-jobs-restart-{}", std::process::id()));
let e2 = Economy::open(&dir, EcoConfig::default()).unwrap();
let mut j2 = Jobs::open(&e2, Config::default());
assert_eq!(j2.board.contracts, j.board.contracts);
assert!(
j2.tick(&e2, &price, T0 + 1000).is_empty(),
"not posted twice"
);
}
}
+2 -3
View File
@@ -133,9 +133,8 @@ impl Lottery {
ticket < 0
})
.expect("the ticket is below the total");
let pot = total * price;
let prize = pot - pot * config.fee_percent.clamp(0, 100) / 100;
match economy.award("lottery_win", &stakes, winner.0, prize) {
let prize = total * price;
match economy.award("lottery_win", &stakes, winner.0) {
Ok(()) => vec![Out::All(format!(
"Lottery: {} won {} with {} of {total} tickets!",
winner.1,
+16 -118
View File
@@ -12,7 +12,7 @@
// lends the player's items as a `Bag`. Added to the game as
// `game::smp_market`; also a module of this crate, for its tests.
use super::smp_economy::{Economy, MAX_AMOUNT, Out, Player, TREASURY, parse_amount};
use super::smp_economy::{Economy, MAX_AMOUNT, Out, Player, parse_amount};
use super::smp_market_prices::{
Book, Dynamic, MINUTE_MS, Point, Quote, Span, arrow, base_mid, one_price, to_get, to_pay,
};
@@ -328,9 +328,6 @@ pub struct Market {
pub warnings: Vec<String>,
/// Each item made of or into others: those others (`families`).
family: BTreeMap<String, Vec<String>>,
/// Items whose history `open` scaled to their one price, by how much
/// (for the futures' entries).
pub rebased: BTreeMap<String, f64>,
}
/// The market's items made of one another (by `smp_recipes::chains`), in
@@ -412,7 +409,6 @@ impl Market {
book_dirty: !rebased.is_empty(),
warnings: Vec::new(),
family: BTreeMap::new(),
rebased,
};
market.link();
(market, problems)
@@ -431,7 +427,6 @@ impl Market {
book_dirty: false,
warnings: Vec::new(),
family: BTreeMap::new(),
rebased: BTreeMap::new(),
};
m.link();
m
@@ -591,7 +586,7 @@ impl Market {
}
/// What the market asks and pays for one `id` now, and the mid; None
/// when it isn't traded. For futures and charts.
/// when it isn't traded. For the charts.
pub fn spot(&self, id: &str) -> Option<Quote> {
let b = self.config.items.get(id)?;
Some(if self.config.dynamic.enabled {
@@ -610,32 +605,6 @@ impl Market {
})
}
#[allow(
dead_code,
reason = "read by the futures (smp-econ), not the market itself"
)]
/// The time-weighted mean mid of `id` over the last `minutes` minutes.
pub fn twap(&self, id: &str, minutes: u32, now: i64) -> Option<f64> {
self.config
.items
.contains_key(id)
.then(|| self.book.twap(id, minutes, now))
.flatten()
}
#[allow(
dead_code,
reason = "read by the futures (smp-econ), not the market itself"
)]
/// The 10-minute TWAP, rounded, from at least 5 one-minute points.
pub fn mark_price(&self, id: &str, now: i64) -> Option<i64> {
self.config
.items
.contains_key(id)
.then(|| self.book.mark_price(id, now))
.flatten()
}
/// Items of `id` traded (both sides) in the last `hours`.
pub fn volume(&self, id: &str, now: i64, hours: u32) -> u64 {
self.book.volume(id, now, hours)
@@ -804,12 +773,6 @@ impl Market {
"{name}: {} each, {ways}.",
each_money(economy, self.each(&id))
))];
if self.config.dynamic.enabled && self.config.dynamic.tax_percent > 0.0 {
out.push(Out::Reply(format!(
"Tax: {}% of each trade, to the Treasury. Big trades move the price as they go.",
self.config.dynamic.tax_percent
)));
}
if self.config.dynamic.enabled {
out.push(Out::Reply(format!(
"Moves: {}; traded in 24 h: {}. Chart: /market {}",
@@ -943,23 +906,13 @@ impl Market {
match economy.adjust(&kind, me.uuid, pay, "") {
Ok(_) => {
self.count_sale(me.uuid, now, &id, taken, pay);
let due = self.tax_on(pay);
let tax = self.tax(economy, me.uuid, due, &format!("sell {taken} {id}"));
self.moved(&id, taken, false);
total = total.saturating_add(pay - tax);
total = total.saturating_add(pay);
let each = each_money(economy, pay as f64 / f64::from(taken));
out.push(Out::Reply(format!(
"Sold {taken} {name} at {each} each: {}.",
economy.money(pay)
)));
if tax > 0 {
out.push(Out::Reply(format!(
"Tax {}%: -{}. You get {}.",
self.config.dynamic.tax_percent,
economy.money(tax),
economy.money(pay - tax)
)));
}
if taken < limited {
let hours = self.config.dynamic.round_trip_hours;
out.push(Out::Reply(format!(
@@ -1030,16 +983,10 @@ impl Market {
if cost > MAX_AMOUNT {
return vec![Out::Error("That costs too much at once.".into())];
}
let tax = self.tax_on(cost);
if economy.balance(me.uuid) < cost + tax {
if economy.balance(me.uuid) < cost {
return vec![Out::Error(format!(
"{count} {name} cost {}{}; you have {}.",
"{count} {name} cost {}; you have {}.",
economy.money(cost),
if tax > 0 {
format!(" + {} tax", economy.money(tax))
} else {
String::new()
},
economy.money(economy.balance(me.uuid))
))];
}
@@ -1059,12 +1006,6 @@ impl Market {
} else {
self.value(&id, given, true).0
};
let tax = if given > 0 {
let due = self.tax_on(paid);
self.tax(economy, me.uuid, due, &format!("buy {given} {id}"))
} else {
0
};
if given > 0 {
self.moved(&id, given, true);
let now = economy.now;
@@ -1076,14 +1017,6 @@ impl Market {
"Bought {given} {name} at {each} each: {}.",
economy.money(paid)
))];
if tax > 0 {
out.push(Out::Reply(format!(
"Tax {}%: -{}. You paid {}.",
self.config.dynamic.tax_percent,
economy.money(tax),
economy.money(paid + tax)
)));
}
if count < wanted {
out.push(Out::Reply(format!(
"Only {count} of {wanted}: the price moved {}% this minute, as far as it may.",
@@ -1117,34 +1050,6 @@ impl Market {
}
impl Market {
/// The tax on a trade of `amount` coins (0 without moving prices).
pub fn tax_on(&self, amount: i64) -> i64 {
if self.config.dynamic.enabled {
self.config.dynamic.tax(amount)
} else {
0
}
}
/// Takes `tax` from `uuid` into the Treasury (logged as `market_tax`):
/// what it took (0 when the log refused).
fn tax(&mut self, economy: &mut Economy, uuid: u128, tax: i64, what: &str) -> i64 {
let tax = tax.min(economy.balance(uuid));
if tax <= 0 {
return 0;
}
match economy.to_pool("market_tax", uuid, TREASURY, tax, what) {
Ok(()) => {
self.book.taxes = self.book.taxes.saturating_add(tax);
tax
}
Err(e) => {
self.warnings.push(format!("market tax: {e}"));
0
}
}
}
/// A trade's move of the price (at once, with its family's).
fn moved(&mut self, id: &str, n: u32, buy: bool) {
if !self.config.dynamic.enabled {
@@ -1288,8 +1193,8 @@ impl WebView {
"generated": now,
"currency": currency,
"moving": self.config.enabled && d.enabled,
"tax_percent": d.tax_percent,
"taxes_collected": self.book.taxes,
// No tax since 0.30: kept at 0 for older readers.
"tax_percent": 0,
"daily_summary": {
"day": self.book.summary_day * DAY_MS,
"lines": self.book.last_summary,
@@ -1742,7 +1647,7 @@ mod tests {
fn no_trades_on_moving_prices_ever_earn_once_the_items_are_back() {
// Five players pump and dump every item for 2,000 minutes, with the
// drift between; then every position is closed. On every item the
// market ends up with the money, before any tax.
// market ends up with the money.
let c = moving();
let mut m = Market::new(c.clone());
let t0 = 1_800_000_000_000i64;
@@ -1888,13 +1793,11 @@ mod tests {
"{} >= {start}",
e.balance(me.uuid)
);
// The Treasury got the tax on every trade.
assert!(e.pool(TREASURY) > 0);
fs::remove_dir_all(dir).ok();
}
#[test]
fn moving_prices_tax_the_receipt_and_keep_bought_items_from_coming_back() {
fn moving_prices_pay_the_whole_receipt_and_keep_bought_items_from_coming_back() {
let mut c = Config::default();
let _ = validate(&mut c);
let (mut e, _, me, dir) = setup("dyn");
@@ -1906,21 +1809,16 @@ mod tests {
// One price, 200; three diamonds move it 1.5% as they go.
let out = m.sell(&mut e, &me, &mut b, &["diamond"]);
assert_eq!(
out[..2],
[
Out::Reply("Sold 3 diamond at 198 coins each: 595 coins.".into()),
Out::Reply("Tax 2%: -12 coins. You get 583 coins.".into())
]
out[0],
Out::Reply("Sold 3 diamond at 198 coins each: 595 coins.".into())
);
assert_eq!(e.pool(TREASURY), 12);
assert_eq!(e.balance(me.uuid), 10_000 + 595, "no tax taken");
let out = m.buy(&mut e, &me, &mut b, &["iron_ingot", "10"]);
assert_eq!(
out[..2],
[
Out::Reply("Bought 10 iron ingot at 15 coins each: 151 coins.".into()),
Out::Reply("Tax 2%: -3 coins. You paid 154 coins.".into())
]
out[0],
Out::Reply("Bought 10 iron ingot at 15 coins each: 151 coins.".into())
);
assert_eq!(e.balance(me.uuid), 10_000 + 595 - 151);
let out = m.sell(&mut e, &me, &mut b, &["iron_ingot"]);
assert_eq!(
out[0],
@@ -1934,7 +1832,7 @@ mod tests {
"{out:?}"
);
assert_eq!(
out[2],
out[1],
Out::Reply("10 iron ingot kept: bought from the market in the last 24 h.".into())
);
b.items.insert("minecraft:iron_ingot".into(), 10);
+7 -71
View File
@@ -5,7 +5,7 @@
// (rare, dear items move faster than common ones). A trade of n items costs
// the area under the price along the way it moves the price (`fill`): the
// same curve both ways, so a sale exactly retraces a purchase, and a round
// trip earns nothing but the tax, whoever trades and however the price was
// trip earns nothing, whoever trades and however the price was
// pushed. Within a minute the price moves at most `max_minute_move_percent`
// from where the minute began: the rest of a trade waits for the next minute
// (never priced flat: buying slowly while the price drifts back, then
@@ -15,8 +15,7 @@
// 1 (mean reversion). Items made of one another (`smp_recipes`) share one
// factor, with an elasticity by value, so crafting can't beat the market. The
// history: one point a minute for 24 h, one an hour for 30 days
// (`market_prices.json`), the time-weighted mean for futures (`twap`,
// `mark_price`), the day's movers and news (`summary`). No game types. Added
// (`market_prices.json`), the day's movers and news (`summary`). No game types. Added
// to the game as `game::smp_market_prices`; also a module of this crate, for
// its tests.
@@ -44,9 +43,6 @@ pub struct Dynamic {
pub max_player_impact_percent: f64,
/// How far the price may move within one minute (either way).
pub max_minute_move_percent: f64,
/// The share of each trade that goes to the Treasury (a money sink for
/// the players, shown on the receipt).
pub tax_percent: f64,
/// What a player bought from the market can't be sold back to it for
/// this long (that many of that item).
pub round_trip_hours: i64,
@@ -68,7 +64,6 @@ impl Default for Dynamic {
max_trade_impact_percent: 2.0,
max_player_impact_percent: 2.0,
max_minute_move_percent: 5.0,
tax_percent: 2.0,
round_trip_hours: 24,
depth: BTreeMap::new(),
news_percent: 30.0,
@@ -103,16 +98,9 @@ impl Dynamic {
50.0,
d.max_minute_move_percent,
);
self.tax_percent = ok(self.tax_percent, 0.0, 50.0, d.tax_percent);
self.round_trip_hours = self.round_trip_hours.clamp(0, 24 * 30);
self.depth.retain(|_, v| v.is_finite() && *v > 0.0);
}
/// The tax on `amount` coins (rounded to the nearest coin).
pub fn tax(&self, amount: i64) -> i64 {
let bp = (self.tax_percent * 100.0).round() as i64;
(amount.saturating_mul(bp) + 5_000) / 10_000
}
}
/// One point of history: its start (ms), the mid price then, the items
@@ -190,8 +178,6 @@ pub struct Book {
/// What each player bought (item, when, how many), for the round-trip
/// rule.
pub bought: BTreeMap<String, Vec<(String, i64, u32)>>,
/// The Treasury's take so far (display).
pub taxes: i64,
/// The last daily summary's lines (for the web page and Kira).
pub last_summary: Vec<String>,
/// The base price each item's history is quoted in (since 0.29).
@@ -366,7 +352,7 @@ impl Book {
/// At load: each minute's band opens where its price is, and a book
/// from before 0.29 (no `bases`), whose history is quoted in the old
/// mid of buy and sell (`old`), gets it scaled to the one price of now
/// (`bases`), so the charts, the moves and the futures' TWAP see no
/// (`bases`), so the charts and the moves see no
/// cliff. The items it scaled, by how much.
pub fn settle(
&mut self,
@@ -500,46 +486,6 @@ impl Book {
}
}
#[allow(
dead_code,
reason = "read by the futures (smp-econ), not the market itself"
)]
/// The minute points of `id` within the last `minutes` minutes.
fn recent(&self, id: &str, minutes: u32, now: i64) -> impl Iterator<Item = &Point> {
let since = now - i64::from(minutes) * MINUTE_MS;
self.items
.get(id)
.into_iter()
.flat_map(move |t| t.minutes.iter().rev().take_while(move |p| p.t >= since))
}
#[allow(
dead_code,
reason = "read by the futures (smp-econ), not the market itself"
)]
/// The time-weighted mean mid over the last `minutes` complete minutes
/// (each point is one minute); None without a point there.
pub fn twap(&self, id: &str, minutes: u32, now: i64) -> Option<f64> {
let (n, sum) = self
.recent(id, minutes, now)
.fold((0u32, 0.0), |(n, s), p| (n + 1, s + p.price));
(n > 0).then(|| sum / f64::from(n))
}
#[allow(
dead_code,
reason = "read by the futures (smp-econ), not the market itself"
)]
/// The 10-minute TWAP, rounded, from at least 5 points: a mark price
/// one player can't move much.
pub fn mark_price(&self, id: &str, now: i64) -> Option<i64> {
let n = self.recent(id, 10, now).count();
(n >= 5)
.then(|| self.twap(id, 10, now))
.flatten()
.map(|p| p.round() as i64)
}
/// Items of `id` traded in the last `hours` (minutes for a day, hours
/// beyond).
pub fn volume(&self, id: &str, now: i64, hours: u32) -> u64 {
@@ -732,8 +678,7 @@ mod tests {
}
#[test]
fn a_round_trip_earns_nothing_but_the_tax_for_1_64_and_past_the_band() {
let c = Dynamic::default();
fn a_round_trip_earns_nothing_for_1_64_and_past_the_band() {
// 1, 64, and 2,000 (far past the minute's 5% band: only what fits
// goes, and the same count comes back).
for n in [1u32, 64, 2000] {
@@ -742,11 +687,9 @@ mod tests {
let (a, done) = deal(&mut b, n, first_buy);
let (z, back) = deal(&mut b, done, !first_buy);
assert_eq!(back, done);
let net = a + z - c.tax(a.abs()) - c.tax(z.abs());
assert!(a + z <= 0, "{n} {first_buy}: {a} {z}");
assert!(net < 0, "{n} {first_buy}: {net}");
// Within the band the curve is retraced exactly: only the
// rounding of whole coins (at most 2) and the tax remain.
// rounding of whole coins (at most 2) remains.
if n <= 8 {
assert!(a + z >= -2, "{n} {first_buy}: {a} {z}");
}
@@ -839,18 +782,14 @@ mod tests {
}
#[test]
fn twap_mark_price_volume_and_history() {
fn volume_and_history() {
let c = Dynamic::default();
let mut b = book();
assert_eq!(b.mark_price("minecraft:diamond", T0), None);
for m in 1..=12 {
b.trade(&c, D, &[], 250.0, delta(), 2, true);
b.tick(&c, &mids(), T0 + m * MINUTE_MS);
}
let now = T0 + 12 * MINUTE_MS;
let mark = b.mark_price("minecraft:diamond", now).unwrap();
let spot = b.quote(&c, D, 250.0, delta()).mid;
assert!(mark as f64 <= spot && mark > 250, "{mark} {spot}");
assert_eq!(b.volume("minecraft:diamond", now, 24), 24);
assert_eq!(b.history("minecraft:diamond", Span::Minute).len(), 12);
let h = b.history("minecraft:diamond", Span::Minute);
@@ -904,7 +843,7 @@ mod tests {
}
#[test]
fn the_round_trip_window_and_the_tax() {
fn the_round_trip_window_and_one_price() {
let c = Dynamic::default();
let mut b = book();
b.bought("u", "minecraft:diamond", 5, T0);
@@ -918,9 +857,6 @@ mod tests {
0
);
assert!(b.bought.is_empty());
assert_eq!(c.tax(100), 2);
assert_eq!(c.tax(25), 1);
assert_eq!(c.tax(24), 0);
// One price from the old two: their geometric mean, whole.
assert_eq!(one_price(400, 100), 200);
assert_eq!(one_price(30, 8), 15);
+7 -72
View File
@@ -30,33 +30,6 @@ pub struct Config {
pub homes: Vec<[i64; 2]>,
/// `/rules`, a line each.
pub rules: Vec<String>,
/// What each kind of teleport costs (coins, to the Treasury), paid when
/// it happens: a called-off one costs nothing.
pub fees: Fees,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Fees {
/// Free: a lost or new player can always get back.
pub spawn: i64,
pub home: i64,
pub warp: i64,
pub back: i64,
/// Paid by the one who moves.
pub tpa: i64,
}
impl Default for Fees {
fn default() -> Self {
Self {
spawn: 0,
home: 5,
warp: 10,
back: 10,
tpa: 10,
}
}
}
impl Default for Config {
@@ -71,7 +44,6 @@ impl Default for Config {
"No griefing, no stealing, no cheats.".into(),
"The currency is earned in game only: never bought or sold for real money.".into(),
],
fees: Fees::default(),
}
}
}
@@ -121,7 +93,6 @@ enum To {
pub struct Pending {
pub uuid: u128,
to: To,
fee: i64,
from: Place,
health: f32,
due: i64,
@@ -143,8 +114,7 @@ pub struct Teleport {
pub to: Place,
/// What the player is told once there.
pub done: String,
/// What it costs, and what for (the audit log's reason).
pub fee: i64,
/// Where to, in words (`where you died`).
pub what: String,
/// The cooldown and `/back` before it: `call_off` puts them back.
undo: (Option<i64>, Option<(Place, bool)>),
@@ -271,22 +241,7 @@ impl Places {
.unwrap_or(0)
}
/// What the command in `line` will cost its player (`/tpaccept`: the
/// one who asked pays, when they move).
pub fn fee(&self, line: &str) -> i64 {
let f = &self.config.fees;
let fee = match line.split_whitespace().next() {
Some("spawn") => f.spawn,
Some("home") => f.home,
Some("warp") => f.warp,
Some("back") => f.back,
Some("tpa") => f.tpa,
_ => 0,
};
fee.max(0)
}
/// A teleport the game couldn't make or the player couldn't pay: no
/// A teleport the game couldn't make: no
/// cooldown, and `/back` goes where it went before.
pub fn call_off(&mut self, t: &Teleport) {
match t.undo.0 {
@@ -301,14 +256,6 @@ impl Places {
/// Starts a teleport's warmup, or says why not.
fn start(&mut self, me: &Body, to: To, now: i64) -> Vec<Out> {
let fee = match &to {
To::Player(..) => self.config.fees.tpa,
To::Place(_, what) if what == "the spawn" => self.config.fees.spawn,
To::Place(_, what) if what.starts_with("home") => self.config.fees.home,
To::Place(_, what) if what.starts_with("warp") => self.config.fees.warp,
To::Place(..) => self.config.fees.back,
}
.max(0);
if let Some(&t) = self.last.get(&me.uuid) {
let wait = t + self.config.cooldown_seconds * 1000 - now;
if wait > 0 {
@@ -322,19 +269,13 @@ impl Places {
self.pending.push(Pending {
uuid: me.uuid,
to,
fee,
from: me.at.clone(),
health: me.health,
due: now + self.config.warmup_seconds.max(0) * 1000,
});
let cost = if fee > 0 {
format!(" (fee {fee} coins)")
} else {
String::new()
};
if self.config.warmup_seconds > 0 {
vec![Out::Reply(format!(
"Teleporting in {} s{cost}: don't move.",
"Teleporting in {} s: don't move.",
self.config.warmup_seconds
))]
} else {
@@ -634,7 +575,6 @@ impl Places {
name: me.name.clone(),
to,
done: format!("Teleported to {what}."),
fee: p.fee,
what,
undo,
});
@@ -808,7 +748,7 @@ mod tests {
let out = p.command(&player(B, 0), &both, 1_000, "tpaccept").unwrap();
assert_eq!(
out[1],
Out::Tell(A, "Teleporting in 3 s (fee 10 coins): don't move.".into())
Out::Tell(A, "Teleporting in 3 s: don't move.".into())
);
// Steve walked on meanwhile: Alex lands where Steve is then.
let later = [body(A, 0.0), body(B, 120.0)];
@@ -846,19 +786,14 @@ mod tests {
dead.health = 0.0;
p.tick(&[dead], 0);
let alive = [body(A, 0.0)];
assert_eq!(p.fee("back"), 10);
assert_eq!(p.fee("spawn"), 0);
assert_eq!(p.fee("tpaccept"), 0, "the one who moves pays");
let out = p.command(&player(A, 0), &alive, 1_000, "back").unwrap();
assert_eq!(
out,
vec![Out::Reply(
"Teleporting in 3 s (fee 10 coins): don't move.".into()
)]
vec![Out::Reply("Teleporting in 3 s: don't move.".into())]
);
let (_, go) = p.tick(&alive, 4_000);
assert_eq!((go[0].fee, go[0].what.as_str()), (10, "where you died"));
// Couldn't pay: /back still goes where they died, at once.
assert_eq!(go[0].what, "where you died");
// The game couldn't make it: /back still goes where they died, at once.
p.call_off(&go[0]);
p.command(&player(A, 0), &alive, 4_000, "back").unwrap();
let (_, go) = p.tick(&alive, 7_000);
+49 -137
View File
@@ -1,16 +1,16 @@
// 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
// sits, what the server made and destroyed today, 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 super::smp_economy::{DAY_MS, Economy, Out};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashMap};
use std::collections::BTreeMap;
/// `config.json`'s `report`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -41,16 +41,11 @@ pub struct Day {
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).
/// Players' money: wallets, money held (bids, stakes).
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,
@@ -73,8 +68,8 @@ pub struct Report {
minute: i64,
}
/// Pools that are the state's, not players' money.
const STATE_POOLS: [&str; 3] = [TREASURY, INSURANCE, "casino"];
/// Pools that aren't players' money: the casino's pot.
const HOUSE_POOLS: [&str; 1] = ["casino"];
/// Gini (thousandths) and the top ten's share (percent) of `wealth`.
pub fn spread(mut wealth: Vec<i64>) -> (i64, i64) {
@@ -152,83 +147,53 @@ impl Report {
}
}
/// 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 {
/// The day's numbers now.
pub fn snapshot(economy: &mut Economy, 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 house: i64 = HOUSE_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 (gini_milli, top10_percent) = spread(accounts.iter().map(|a| a.2).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)
let f = economy.flows();
let (minted, burned) = 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)
};
Day {
day,
supply,
players: supply - state,
treasury: economy.pool(TREASURY),
insurance: economy.pool(INSURANCE),
players: supply - house,
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>> {
pub fn command(&self, economy: &mut Economy, 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 d = Self::snapshot(economy, 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()))
.filter(|(k, v)| **v > 0 && !HOUSE_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![
Some(vec![
Out::Reply(format!(
"Money supply: {}{}. Players hold {} (of it in pools: {}), the Treasury {}, the insurance fund {}.",
"Money supply: {}{}. Players hold {} (of it in pools: {}), the casino's pot {}.",
economy.money(d.supply),
since.map_or(String::new(), |s| format!(
" ({}{} since midnight)",
@@ -241,8 +206,7 @@ impl Report {
} else {
pools.join(", ")
},
economy.money(d.treasury),
economy.money(d.insurance)
economy.money(d.supply - d.players)
)),
Out::Reply(format!(
"Today the server made {} ({}) and destroyed {} ({}).",
@@ -251,27 +215,16 @@ impl Report {
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.
/// Whether `tick` has work at `now` (a new minute or day).
pub fn due(&self, now: i64) -> bool {
now.div_euclid(60_000) != self.minute || now.div_euclid(DAY_MS) > self.state.day
}
@@ -279,12 +232,7 @@ impl Report {
/// 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) {
pub fn tick(&mut self, economy: &mut Economy, now: i64) -> (Vec<Out>, bool) {
let minute = now.div_euclid(60_000);
let web = self.config.web_json && minute != self.minute;
self.minute = minute;
@@ -296,7 +244,7 @@ impl Report {
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 d = Self::snapshot(economy, yesterday);
let before = self
.state
.days
@@ -318,7 +266,7 @@ impl Report {
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 yesterday: money supply {}{}; the server made {} and destroyed {}. /eco stats",
economy.money(d.supply),
before.map_or(String::new(), |b| {
let c = d.supply - b;
@@ -329,27 +277,15 @@ impl Report {
)
}),
economy.money(d.minted),
economy.money(d.burned),
economy.money(d.taxes),
economy.money(d.treasury),
economy.money(d.spent)
economy.money(d.burned)
)
}
/// `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 {
/// `web/economy.json`: aggregates and the days kept.
pub fn web(&self, economy: &mut Economy, now: i64) -> Value {
let today = now.div_euclid(DAY_MS);
let d = Self::snapshot(economy, savings, today);
let d = Self::snapshot(economy, 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(
@@ -372,16 +308,6 @@ impl Report {
"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,
})
@@ -419,15 +345,13 @@ mod tests {
}
#[test]
fn stats_show_what_was_made_destroyed_and_taxed_today() {
fn stats_show_what_was_made_and_destroyed_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();
e.to_pool("ah_bid", A, "auction", 25, "").unwrap();
let out = r.command(&mut e, "eco stats").unwrap();
let text: Vec<String> = out
.iter()
.map(|o| match o {
@@ -435,52 +359,40 @@ mod tests {
o => panic!("{o:?}"),
})
.collect();
assert!(
text[0].starts_with("Money supply: 550 coins. Players hold 525 coins"),
"{}",
text[0]
assert_eq!(
text[0],
"Money supply: 550 coins. Players hold 550 coins (of it in pools: auction 25 coins), the casino's pot 0 coins."
);
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());
assert!(!text.iter().any(|l| l.contains("Treasury")), "{text:?}");
assert!(r.command(&mut e, "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);
let (out, web) = r.tick(&mut e, 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"
);
assert!(!r.tick(&mut e, 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);
let (out, _) = r.tick(&mut e, 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"
"Economy yesterday: money supply 100 coins; the server made 100 coins and destroyed 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!(r.tick(&mut e, now + 1000).0.is_empty(), "once");
let json = r.web(&mut e, now);
assert_eq!(json["schema"], 1);
assert_eq!(json["days"][0]["minted"], 100);
assert_eq!(json["treasury"]["balance"], 5);
assert!(json.get("treasury").is_none());
assert!(
json["daily_report"]
.as_str()
-676
View File
@@ -1,676 +0,0 @@
// The tax code and the Treasury's commands. Every tax, fee and house edge
// goes into the Treasury pool (smp_economy's `TREASURY`), never away, and
// operators pay only from it (`/treasury pay`): nobody makes money. Weekly
// taxes (land per claimed chunk, a light progressive wealth tax) are
// collected on Monday 00:00 UTC, looked at once a second for an O(1) "due?".
// `/eco taxes` explains the rates and a player's own bill; `/treasury` shows
// the balance, income by tax and spending; `/treasury log` the last
// movements. The tax state (the week collected, unpaid land tax) is kept in
// `economy.json` with the balances (`Economy::ext("tax")`). No game types.
// Added to the game as `game::smp_tax`; also a module of this crate, for its
// tests.
use super::smp_economy::{DAY_MS, Economy, INSURANCE, Out, Player, TREASURY, parse_amount};
use super::smp_store::uuid_string;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
/// `config.json`'s `taxes`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// Weekly taxes on (land, wealth); the per-sale ones are their parts'.
pub enabled: bool,
/// VAT on sales between players (the auction house), paid by the seller
/// from the price, in percent.
pub vat_percent: i64,
/// Claimed chunks nobody pays for.
pub land_free_chunks: usize,
/// Land tax a week per claimed chunk past the free ones.
pub land_tax_per_chunk: i64,
/// `[from, percent]`: each week, `percent` of the part of a balance
/// above `from` (up to the next bracket's `from`).
pub wealth_brackets: Vec<[i64; 2]>,
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: true,
vat_percent: 5,
land_free_chunks: 4,
land_tax_per_chunk: 5,
wealth_brackets: vec![[50_000, 1], [250_000, 2], [1_000_000, 3]],
}
}
}
/// One weekly collection, for `/treasury` and the report.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Collection {
pub week: i64,
pub land: i64,
pub wealth: i64,
/// Land tax players couldn't pay (added to their arrears).
pub unpaid: i64,
pub payers: usize,
}
/// The tax state, in `economy.json` (`ext.tax`).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct State {
/// The last week collected (Monday-based week number).
pub week: i64,
/// Land tax owed, by UUID: no new claim until it's paid.
pub arrears: BTreeMap<String, i64>,
pub last: Option<Collection>,
}
/// The week number of `now` (ms), weeks starting Monday 00:00 UTC (1970-01-01
/// was a Thursday).
pub fn week(now: i64) -> i64 {
(now.div_euclid(DAY_MS) + 3).div_euclid(7)
}
/// When the week after `week` starts (ms).
pub fn week_start(week: i64) -> i64 {
(week * 7 - 3) * DAY_MS
}
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`.
pub fn span(ms: i64) -> String {
let m = ms.max(0) / 60_000;
let (d, h, m) = (m / 1440, m / 60 % 24, m % 60);
if d > 0 {
format!("{d}d {h}h")
} else if h > 0 {
format!("{h}h {m:02}m")
} else {
format!("{m}m")
}
}
impl Taxes {
/// The taxes, their state read from the economy (none yet: the first
/// collection is next Monday, never weeks back).
pub fn open(economy: &Economy, config: Config) -> Self {
let state = economy
.ext("tax")
.and_then(|v| serde_json::from_value::<State>(v.clone()).ok());
let dirty = state.is_none();
let state = state.unwrap_or_else(|| State {
week: week(economy.now),
..State::default()
});
Self {
config,
state,
dirty,
savings: HashMap::new(),
}
}
/// Writes the state into the economy (saved with the balances).
pub fn store(&mut self, economy: &mut Economy) {
if self.dirty {
economy.set_ext("tax", serde_json::to_value(&self.state).unwrap_or_default());
self.dirty = false;
}
}
/// The land tax a week for `chunks` claimed chunks.
pub fn land_bill(&self, chunks: usize) -> i64 {
let taxed = chunks.saturating_sub(self.config.land_free_chunks) as i64;
taxed.saturating_mul(self.config.land_tax_per_chunk.max(0))
}
/// The wealth tax a week on `balance`.
pub fn wealth_bill(&self, balance: i64) -> i64 {
let mut brackets = self.config.wealth_brackets.clone();
brackets.sort();
let mut bill: i128 = 0;
for (i, [from, percent]) in brackets.iter().enumerate() {
let to = brackets.get(i + 1).map_or(i64::MAX, |b| b[0]);
let part = balance.min(to) - from;
if part > 0 {
bill += i128::from(part) * i128::from((*percent).clamp(0, 100)) / 100;
}
}
bill as i64
}
/// What `uuid` owes in land tax.
pub fn arrears(&self, uuid: u128) -> i64 {
self.state
.arrears
.get(&uuid_string(uuid))
.copied()
.unwrap_or(0)
}
/// Whether `uuid` may claim more land: not while they owe land tax.
pub fn may_claim(&self, economy: &Economy, uuid: u128) -> Result<(), String> {
match self.arrears(uuid) {
0 => Ok(()),
owed => Err(format!(
"You owe {} of land tax: no new claim until it's paid (/eco taxes pay).",
economy.money(owed)
)),
}
}
/// Whether a weekly collection is due (cheap: once a second).
pub fn due(&self, now: i64) -> bool {
self.config.enabled && week(now) > self.state.week
}
/// Collects the week's land and wealth taxes into the Treasury. `chunks`:
/// claimed chunks by owner. Unpaid land tax becomes arrears; the wealth
/// tax takes only what is there (it never exceeds the balance).
pub fn collect(
&mut self,
economy: &mut Economy,
chunks: &HashMap<u128, usize>,
now: i64,
) -> Vec<Out> {
let this = week(now);
if this <= self.state.week {
return Vec::new();
}
self.state.week = this;
self.dirty = true;
let mut c = Collection {
week: this,
..Collection::default()
};
let mut out = Vec::new();
let mut owners: Vec<(u128, usize)> = chunks.iter().map(|(u, n)| (*u, *n)).collect();
owners.sort();
for (uuid, n) in owners {
let key = uuid_string(uuid);
let owed = self.land_bill(n) + self.arrears(uuid);
if owed <= 0 {
continue;
}
let paid = owed.min(economy.balance(uuid));
if paid > 0
&& economy
.to_pool("land_tax", uuid, TREASURY, paid, "")
.is_err()
{
continue;
}
c.land += paid;
c.payers += 1;
let left = owed - paid;
if left > 0 {
c.unpaid += left;
self.state.arrears.insert(key, left);
out.push(Out::Tell(
uuid,
format!(
"Land tax: you paid {} and still owe {}. No new claim until it's paid (/eco taxes pay).",
economy.money(paid),
economy.money(left)
),
));
} else {
self.state.arrears.remove(&key);
out.push(Out::Tell(
uuid,
format!("Land tax for {n} chunks: {}.", economy.money(paid)),
));
}
}
for (uuid, _, balance) in economy.accounts() {
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, "")
.is_ok()
{
c.wealth += bill;
out.push(Out::Tell(
uuid,
format!("Wealth tax this week: {}.", economy.money(bill)),
));
}
}
out.push(Out::All(format!(
"Weekly taxes collected: land {}, wealth {}. Treasury: {}.",
economy.money(c.land),
economy.money(c.wealth),
economy.money(economy.pool(TREASURY))
)));
self.state.last = Some(c);
out
}
/// Runs `/treasury ...` and `/eco taxes ...`; None for another command.
/// `my_chunks`: the chunks `me` holds.
pub fn command(
&mut self,
economy: &mut Economy,
me: &Player,
my_chunks: usize,
line: &str,
) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
Some(match words.as_slice() {
["treasury"] => self.treasury(economy),
["treasury", "log"] => self.log(economy),
["treasury", "pay", who, amount, reason @ ..] => {
self.pay(economy, me, who, amount, &reason.join(" "))
}
["treasury", ..] => vec![Out::Error(
"Usage: /treasury, /treasury log, /treasury pay <player> <amount> <reason>".into(),
)],
["eco" | "taxes", "taxes"] | ["taxes"] => self.explain(economy, me, my_chunks),
["eco" | "taxes", "taxes", "pay"] | ["taxes", "pay"] => self.pay_arrears(economy, me),
_ => return None,
})
}
fn treasury(&self, economy: &Economy) -> Vec<Out> {
let t = economy.treasury();
let list = |m: &BTreeMap<String, i64>| -> String {
let mut v: Vec<(&String, &i64)> = m.iter().collect();
v.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
let parts: Vec<String> = v
.iter()
.map(|(k, a)| format!("{} {}", k.replace('_', " "), economy.money(**a)))
.collect();
if parts.is_empty() {
"nothing".into()
} else {
parts.join(", ")
}
};
let today = t.day == economy.now.div_euclid(DAY_MS);
let empty = BTreeMap::new();
let (inc, out) = if today {
(&t.income_today, &t.spent_today)
} else {
(&empty, &empty)
};
let mut lines = vec![
Out::Reply(format!(
"Treasury: {} (insurance fund {}). Taxes, fees and house edges go in; grants, refunds and prizes come out. Nobody can make money.",
economy.money(economy.pool(TREASURY)),
economy.money(economy.pool(INSURANCE))
)),
Out::Reply(format!("Today in: {}. Out: {}.", list(inc), list(out))),
Out::Reply(format!(
"Since it opened, in: {}. Out: {}.",
list(&t.income),
list(&t.spent)
)),
];
if let Some(c) = &self.state.last {
lines.push(Out::Reply(format!(
"Last weekly taxes: land {}, wealth {}, {} unpaid.",
economy.money(c.land),
economy.money(c.wealth),
economy.money(c.unpaid)
)));
}
lines.push(Out::Reply(
"Every payment: /treasury log. The rates: /eco taxes.".into(),
));
lines
}
fn log(&self, economy: &Economy) -> Vec<Out> {
let t = economy.treasury();
if t.log.is_empty() {
return vec![Out::Reply("The Treasury hasn't moved yet.".into())];
}
let mut out = vec![Out::Reply(
"The Treasury's last movements (newest first):".into(),
)];
for m in t.log.iter().rev().take(10) {
let sign = if m.amount >= 0 { "+" } else { "-" };
let mut line = format!(
"{} ago: {sign}{} {}",
span(economy.now - m.time),
economy.money(m.amount.abs()),
m.kind.replace('_', " ")
);
if !m.player.is_empty() {
line.push_str(&format!(" ({})", m.player));
}
if !m.reason.is_empty() {
line.push_str(&format!(": {}", m.reason));
}
if !m.by.is_empty() {
line.push_str(&format!(", by {}", m.by));
}
out.push(Out::Reply(line));
}
out
}
/// `/treasury pay`: an operator pays a player from the Treasury, with a
/// reason everyone sees.
fn pay(
&mut self,
economy: &mut Economy,
me: &Player,
who: &str,
amount: &str,
reason: &str,
) -> Vec<Out> {
if me.level < 3 {
return vec![Out::Error(
"Only operators of level 3 may pay from the Treasury.".into(),
)];
}
let Some(amount) = parse_amount(amount) else {
return vec![Out::Error(
"The amount must be a positive whole number.".into(),
)];
};
let reason = reason.trim();
if reason.chars().count() < 3 {
return vec![Out::Error(
"Say why: /treasury pay <player> <amount> <reason> (an event prize, a bug refund...)".into(),
)];
}
let Some((uuid, account)) = economy.find(who) else {
return vec![Out::Error(format!("No player named {who} has an account."))];
};
let name = account.name.clone();
match economy.out_of_pool(
"grant",
TREASURY,
uuid,
amount,
reason,
&uuid_string(me.uuid),
) {
Ok(()) => vec![Out::All(format!(
"The Treasury paid {} to {name}: {reason} (by {}). Treasury: {}.",
economy.money(amount),
me.name,
economy.money(economy.pool(TREASURY))
))],
Err(e) => vec![Out::Error(e)],
}
}
/// `/eco taxes`: the tax code, and the player's own bill.
fn explain(&self, economy: &Economy, me: &Player, chunks: usize) -> Vec<Out> {
let c = &self.config;
let mut brackets = c.wealth_brackets.clone();
brackets.sort();
let wealth: Vec<String> = brackets
.iter()
.map(|[from, p]| format!("{p}% above {}", economy.money(*from)))
.collect();
let next = week_start(self.state.week + 1) - economy.now;
let land = self.land_bill(chunks);
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!(
"VAT: {}% of each auction house sale, paid by the seller.",
c.vat_percent
)),
Out::Reply(format!(
"Land tax, weekly: {} a claimed chunk past your first {}. Unpaid tax stops new claims.",
economy.money(c.land_tax_per_chunk),
c.land_free_chunks
)),
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 {
out.push(Out::Reply(
"The weekly taxes are off on this server.".into(),
));
} else {
out.push(Out::Reply(format!(
"Your bill on Monday (in {}): land {} ({chunks} chunks), wealth {}{}.",
span(next),
economy.money(land),
economy.money(wealth_bill),
match self.arrears(me.uuid) {
0 => String::new(),
a => format!(", owed {}", economy.money(a)),
}
)));
}
out
}
/// `/eco taxes pay`: pays the land tax owed, as much as the balance
/// allows.
fn pay_arrears(&mut self, economy: &mut Economy, me: &Player) -> Vec<Out> {
let owed = self.arrears(me.uuid);
if owed == 0 {
return vec![Out::Reply("You owe no tax.".into())];
}
let paid = owed.min(economy.balance(me.uuid));
if paid <= 0 {
return vec![Out::Error(format!(
"You owe {} and have nothing to pay it with.",
economy.money(owed)
))];
}
if let Err(e) = economy.to_pool("land_tax", me.uuid, TREASURY, paid, "arrears") {
return vec![Out::Error(e)];
}
self.dirty = true;
let key = uuid_string(me.uuid);
if paid == owed {
self.state.arrears.remove(&key);
vec![Out::Reply(format!(
"Paid {} of land tax: you owe nothing.",
economy.money(paid)
))]
} else {
self.state.arrears.insert(key, owed - paid);
vec![Out::Reply(format!(
"Paid {}; you still owe {}.",
economy.money(paid),
economy.money(owed - paid)
))]
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::smp_economy::Config as EcoConfig;
use std::fs;
const ALEX: u128 = 1;
const STEVE: u128 = 2;
const RICH: u128 = 3;
/// Monday 2026-11-02 00:00 UTC.
const MONDAY: i64 = 1_793_577_600_000;
fn setup(name: &str) -> (Economy, Taxes, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("smp-tax-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let mut e = Economy::open(&dir, EcoConfig::default()).unwrap();
e.now = MONDAY - 3_600_000;
for (u, n) in [(ALEX, "Alex"), (STEVE, "Steve"), (RICH, "Rich")] {
e.seen(u, n);
}
e.adjust("sell", ALEX, 1_000, "").unwrap();
e.adjust("sell", STEVE, 20, "").unwrap();
e.adjust("sell", RICH, 300_000, "").unwrap();
let t = Taxes::open(&e, Config::default());
(e, t, dir)
}
fn me(uuid: u128, level: u8) -> Player {
Player {
uuid,
name: "x".into(),
level,
}
}
#[test]
fn weeks_start_on_monday() {
assert_eq!(week(MONDAY) - week(MONDAY - 1), 1);
assert_eq!(week_start(week(MONDAY)), MONDAY);
assert_eq!(week(MONDAY + 6 * DAY_MS), week(MONDAY));
}
#[test]
fn bills_are_progressive() {
let t = Taxes {
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);
assert_eq!(t.wealth_bill(50_000), 0);
assert_eq!(t.wealth_bill(60_000), 100);
// 1% of 200k + 2% of 50k.
assert_eq!(t.wealth_bill(300_000), 3_000);
// 1% of 200k + 2% of 750k + 3% of 1M.
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");
let chunks = HashMap::from([(ALEX, 10usize), (STEVE, 14), (RICH, 2)]);
assert!(!t.due(e.now), "not before Monday");
assert!(
{
let now = e.now;
t.collect(&mut e, &chunks, now)
}
.is_empty()
);
let total =
|e: &Economy| e.balance(ALEX) + e.balance(STEVE) + e.balance(RICH) + e.pool(TREASURY);
let before = total(&e);
e.now = MONDAY + 1000;
assert!(t.due(e.now));
let out = {
let now = e.now;
t.collect(&mut e, &chunks, now)
};
assert!(matches!(out.last(), Some(Out::All(_))));
// Alex 6 chunks taxed (30), Steve 10 (50, has 20), Rich wealth 3,000.
assert_eq!(
(e.balance(ALEX), e.balance(STEVE), e.balance(RICH)),
(970, 0, 297_000)
);
assert_eq!(e.pool(TREASURY), 30 + 20 + 3_000);
assert_eq!(total(&e), before, "taxes move money, never make or burn it");
assert_eq!(t.arrears(STEVE), 30);
assert!(t.may_claim(&e, STEVE).is_err() && t.may_claim(&e, ALEX).is_ok());
// Twice the same week: nothing.
assert!(!t.due(e.now));
assert!(
{
let now = e.now + 1000;
t.collect(&mut e, &chunks, now)
}
.is_empty()
);
// Steve earns and pays what he owes.
e.adjust("sell", STEVE, 100, "").unwrap();
let out = t
.command(&mut e, &me(STEVE, 0), 14, "eco taxes pay")
.unwrap();
assert_eq!(
out,
[Out::Reply(
"Paid 30 coins of land tax: you owe nothing.".into()
)]
);
assert!(t.may_claim(&e, STEVE).is_ok());
let tr = e.treasury();
assert_eq!(
(tr.income["land_tax"], tr.income["wealth_tax"]),
(80, 3_000)
);
// The state survives a restart with the balances.
t.store(&mut e);
e.save().unwrap();
let mut again = Economy::open(&dir, EcoConfig::default()).unwrap();
again.now = e.now;
let t2 = Taxes::open(&again, Config::default());
assert_eq!(t2.state.week, week(MONDAY));
assert!(!t2.due(again.now), "a restart doesn't collect again");
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn only_operators_pay_from_the_treasury_and_never_more_than_it_holds() {
let (mut e, mut t, dir) = setup("pay");
e.to_pool("vat", RICH, TREASURY, 500, "").unwrap();
let steve = me(STEVE, 0);
assert!(matches!(
&t.command(&mut e, &steve, 0, "treasury pay Steve 10 because")
.unwrap()[0],
Out::Error(_)
));
let op = Player {
uuid: ALEX,
name: "Alex".into(),
level: 3,
};
assert!(
matches!(&t.command(&mut e, &op, 0, "treasury pay Steve 10").unwrap()[0], Out::Error(s) if s.starts_with("Say why"))
);
assert!(matches!(
&t.command(&mut e, &op, 0, "treasury pay Steve 501 too much")
.unwrap()[0],
Out::Error(_)
));
assert_eq!(
t.command(&mut e, &op, 0, "treasury pay Steve 200 bug refund #12").unwrap(),
[Out::All("The Treasury paid 200 coins to Steve: bug refund #12 (by Alex). Treasury: 300 coins.".into())]
);
assert_eq!(e.balance(STEVE), 220);
let log = t.command(&mut e, &steve, 0, "treasury log").unwrap();
assert!(
matches!(&log[1], Out::Reply(s) if s.contains("-200 coins grant (Steve): bug refund #12, by Alex")),
"{log:?}"
);
let shown = t.command(&mut e, &steve, 0, "treasury").unwrap();
assert!(matches!(&shown[0], Out::Reply(s) if s.starts_with("Treasury: 300 coins")));
let taxes = t.command(&mut e, &steve, 16, "eco taxes").unwrap();
assert!(
taxes
.iter()
.any(|o| matches!(o, Out::Reply(s) if s.contains("land 60 coins (16 chunks)"))),
"{taxes:?}"
);
fs::remove_dir_all(dir).unwrap();
}
}
+39 -55
View File
@@ -3,9 +3,8 @@ Steve, `horizon-ctl --offscreen`) join it. Checked from the clients' own chat an
- the economy opens an account for each player (config.json written with its defaults, then economy.json);
- /balance, /pay (the payer's answer and the payee's line), refused payments (too much, past what the payee may receive today, to oneself, unknown name);
- the Treasury: an operator (made one from the server's console) pays a player from it with a reason (/treasury pay),
which everyone sees in /treasury and /treasury log; /eco give is refused to everyone (nobody can make money), and a
non-operator can't pay from the Treasury; /eco taxes explains the tax code;
- money from an operator (made one from the server's console): /eco give with a reason everyone reads; a
non-operator can't; /treasury, /bank, /jobs, /futures and /eco taxes are gone, and the old Treasury pool is dropped;
- /baltop;
- the commands are in each player's command tree (the chat box's suggestions);
- the market (Steve in survival, items given from the console): /worth, /sell <item>, /sell hand, /buy, with the
@@ -19,10 +18,10 @@ Steve, `horizon-ctl --offscreen`) join it. Checked from the clients' own chat an
- the lottery: two players' tickets held, one drawn after `lottery_seconds`, the winner announced to everyone and paid
the pot;
- after the server stops cleanly and starts again, the balances are the same, and audit.log holds every change;
- a Treasury payment for an operator, then the /shop menu: a category, one item bought by a click and a stack by
- an operator's /eco give, then the /shop menu: a category, one item bought by a click and a stack by
a shift-click, a stack of the player's own sold by clicking it, "Sell all" stopped by the daily limit (the rest
kept, the limit shown in the menu and in /worth, the day's sales in market.json), the close button;
- after the restart, prices moving: a purchase and a sale taxed (receipts, the Treasury), an item bought can't be sold
- after the restart, prices moving: a purchase and a sale with no tax (receipts), an item bought can't be sold
back while one found can, one price for diamond, moved down by a sale and up by a purchase as they go, /market's item
page buying, the 24 h chart given as a map and drawn in the player's hand (market-chart.png), web/market.json, and
market_prices.json written when the world closes;
@@ -603,16 +602,14 @@ def nicks(server, alex, steve, out):
def shop(server, alex):
"""Alex alone, op, in survival: paid from the Treasury (/eco give refused), then the /shop menu: a category, buying one and a stack,
"""Alex alone, op, in survival: given money (/eco give), then the /shop menu: a category, buying one and a stack,
selling a stack of hers, "Sell all" up to the daily limit, closing. Returns the coins it added, and the lines."""
results = {}
# Only what the step gives her: "Sell all" would sell the rest too.
server.console("clear Alex")
time.sleep(1)
results["money"] = alex.say("/treasury pay Alex 2010 proof: shop funds")
assert any("The Treasury paid 2,010 coins to Alex: proof: shop funds (by Alex)." in l for l in results["money"]), results
results["no_printing"] = alex.say("/eco give Alex 10")
assert any("Nobody can make money" in l for l in results["no_printing"]), results
results["money"] = alex.say("/eco give Alex 2010 proof: shop funds")
assert any("Alex gave 2,010 coins to Alex: proof: shop funds." in l for l in results["money"]), results
assert alex.count("minecraft:iron_ingot") == 0
alex.say("/shop")
menu = alex.menu()
@@ -624,17 +621,17 @@ def shop(server, alex):
alex.click(300 + 2) # one iron ingot
assert alex.count("minecraft:iron_ingot") == 1, alex.run("inventory")
results["bought"] = alex.chat()[-3:]
# One price, 15 (2% of it rounds to no tax).
# One price, 15.
assert any("Bought 1 iron ingot at 15 coins each: 15 coins." in l for l in results["bought"]), results
alex.click(300 + 45) # back
alex.click(300 + 14) # Building
alex.run("click-slot", 300 + 0, "shift") # a stack of cobblestone
time.sleep(1)
assert alex.count("minecraft:cobblestone") == 64, alex.run("inventory")
# Along the price as the stack moves it: 64 at 1 coin come to 64.05, 65 whole coins, + 1 tax.
# Along the price as the stack moves it: 64 at 1 coin come to 64.05, 65 whole coins.
results["stack"] = alex.chat()[-4:] # an advancement may come after
assert any("Bought 64 cobblestone at 1.02 coins each: 65 coins." in l for l in results["stack"]), results
assert any("Tax 2%: -1 coins. You paid 66 coins." in l for l in results["stack"]), results
assert not any("Tax" in l for l in results["stack"]), results
# Her own stack of iron, clicked: the ingot she just bought stays (no buying to sell back), one she found sells.
alex.click(alex.slot_of("minecraft:iron_ingot"))
results["round_trip"] = alex.chat()[-1:]
@@ -655,7 +652,7 @@ def shop(server, alex):
alex.click(300 + 49)
results["sell_all"] = alex.chat()[-8:]
assert any("Sold 9 diamond at 196 coins each: 1,760 coins." in l for l in results["sell_all"]), results
assert any("Tax 2%: -35 coins. You get 1,725 coins." in l for l in results["sell_all"]), results
assert not any("Tax" in l for l in results["sell_all"]), results
assert any("21 diamond kept: today's selling limit." in l for l in results["sell_all"]), results
assert alex.count("minecraft:diamond") == 21, alex.run("inventory")
limits = alex.menu()[50]
@@ -666,8 +663,7 @@ def shop(server, alex):
assert not alex.menu()
results["worth"] = alex.say("/worth diamond")
assert any("You may sell 631 more diamond today." in l for l in results["worth"]), results
# The tax: 1 on the cobblestone, 35 on the diamonds, to the Treasury.
return 2000 + 10 - 15 - 65 - 1 + 15 + 1760 - 35, results
return 2000 + 10 - 15 - 65 + 15 + 1760, results
def invsee(alex, data):
@@ -725,9 +721,8 @@ def market(server, alex, out, data):
results["bought"] = alex.chat()[-4:]
# One diamond moves the price 0.5% as it is bought: 191.7, 192 whole coins.
paid = 192
tax = (paid * 200 + 5000) // 10000
assert any(f"Bought 1 diamond at {paid} coins each: {paid} coins." in l for l in results["bought"]), results
assert any(f"Tax 2%: -{tax} coins. You paid {paid + tax} coins." in l for l in results["bought"]), results
assert not any("Tax" in l for l in results["bought"]), results
alex.click(300 + 39) # the 24 h chart
assert alex.count("minecraft:filled_map") == 1, alex.run("inventory")
alex.click(300 + 53) # close
@@ -751,8 +746,7 @@ def market(server, alex, out, data):
# One price, which buying and selling both use.
assert 190 < diamond["price"] < 200 and diamond["buy"] == diamond["sell"] == diamond["price"], results["web"]
assert diamond["volume_24h"] == 10 and diamond["day"]["price"], results["web"]
results["tax"] = tax
return paid + tax, results
return paid, results
def trades(server, alex, steve, port, ctl, resources, root):
@@ -855,23 +849,20 @@ def run(ctl, resources, out):
data.mkdir(parents=True)
(data / "config.json").write_text(json.dumps({"gambling": {"enabled": True, "cooldown_seconds": 20,
"lottery_seconds": 30},
# Free teleports and an instant reward: the sums below
# stay as they were (fees and the reward's delay have
# unit tests).
"places": {"warmup_seconds": 2, "cooldown_seconds": 0,
"fees": {"spawn": 0, "home": 0, "warp": 0,
"back": 0, "tpa": 0}},
# Quick teleports and an instant reward (the reward's
# delay has unit tests).
"places": {"warmup_seconds": 2, "cooldown_seconds": 0},
# Paid once, after the restart, not to change the sums.
"presence": {"daily_reward": 0, "reward_after_minutes": 0},
# Steve plays a few minutes before his nickname.
"nicks": {"min_playtime_minutes": 1},
# Fixed prices, no tax, for the first run's sums; the
# Fixed prices for the first run's sums; the
# moving market is on after the restart.
"market": {"dynamic": {"enabled": False}},
# The casino's pot takes no float from the Treasury here (its
# own proof, proof_casino.py, covers it): the sums stay.
# The casino's pot gets no float here (its own proof,
# proof_casino.py, covers it): the sums stay.
"casino": {"float": 0}}))
# A Treasury with money, the only place operators may pay from (nobody can make money).
# A world from before 0.30, with a Treasury: its pool is dropped at the first start.
(data / "economy.json").write_text(json.dumps({"accounts": {}, "pools": {"treasury": 5000}}))
# A nickname an older version let through, taken off at the first start.
(data / "nicks.json").write_text(json.dumps({"nicks": {"00000000-0000-0000-0000-00000000abcd": {
@@ -885,23 +876,22 @@ 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", "bank", "board", "casino", "claims", "currency", "futures", "gambling", "jobs",
"market", "nicks", "places", "presence", "report", "shop",
"starting_balance", "taxes", "web"], config
assert results["config_keys"] == ["auction", "board", "casino", "claims", "currency", "gambling", "market", "nicks", "places", "presence", "report", "shop",
"starting_balance", "web"], 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"]
assert any("Steve joined for the first time" in l for l in alex.chat()), alex.chat()
assert config["gambling"] == {"enabled": True, "min_bet": 10, "max_bet": 1000, "cooldown_seconds": 20,
"offer_seconds": 60, "fee_percent": 0, "ticket_price": 10,
"offer_seconds": 60, "ticket_price": 10,
"max_tickets": 100, "lottery_seconds": 30}, config
# One price, to buy and to sell.
assert config["market"]["items"]["minecraft:diamond"] == {"buy": 200, "sell": 200}, config
server.console("op Alex")
time.sleep(1)
results["grant"] = alex.say("/treasury pay Alex 500 proof funding")
assert any("The Treasury paid 500 coins to Alex: proof funding (by Alex). Treasury: 4,500 coins." in l
for l in results["grant"]), results
results["grant"] = alex.say("/eco give Alex 500 proof funding")
assert any("Alex gave 500 coins to Alex: proof funding." in l for l in results["grant"]), results
assert any("Alex gave 500 coins to Alex: proof funding." in l for l in steve.chat()[-3:]), "everyone reads it"
results["balance"] = alex.say("/balance")
assert any("Balance: 500 coins" in l for l in results["balance"]), results
results["pay"] = alex.say("/pay Steve 120")
@@ -917,17 +907,13 @@ def run(ctl, resources, out):
"You can't pay yourself.",
"No player named Nobody has an account.")):
assert any(error in l for l in lines), results["refused"]
results["eco_refused"] = steve.say("/eco give Steve 1000")
assert not any("now has" in l for l in results["eco_refused"]), results
results["treasury_refused"] = steve.say("/treasury pay Steve 1000 because")
assert not any("The Treasury paid" in l for l in results["treasury_refused"]), results
results["treasury"] = steve.say("/treasury")
assert any("Treasury: 4,500 coins" in l for l in results["treasury"]), results
assert any("grant 500 coins" in l for l in results["treasury"]), results
results["treasury_log"] = steve.say("/treasury log")
assert any("-500 coins grant (Alex): proof funding, by Alex" in l for l in results["treasury_log"]), results
results["taxes"] = steve.say("/eco taxes")
assert any("Land tax, weekly: 5 coins a claimed chunk past your first 4." in l for l in results["taxes"]), results
results["eco_refused"] = steve.say("/eco give Steve 1000 because")
assert not any("gave" in l for l in results["eco_refused"]), results
# The parts taken out: their commands are gone.
for line in ("/treasury", "/bank", "/jobs", "/futures", "/eco taxes"):
results[line] = steve.say(line)
refused = ("Unknown or incomplete command", "Incorrect argument", "Usage", "Only operators")
assert any(any(r in l for r in refused) for l in results[line]), results
results["baltop"] = steve.say("/baltop")
assert any("1. Alex: 380 coins" in l for l in results["baltop"]), results
assert any("2. Steve: 120 coins" in l for l in results["baltop"]), results
@@ -985,7 +971,7 @@ def run(ctl, resources, out):
assert balances["Alex"] == alex_after and sum(balances.values()) + held == 815, economy
audit = [json.loads(l) for l in (data / "audit.log").read_text().splitlines()]
results["audit"] = [a["kind"] for a in audit]
assert results["audit"] == ["open", "open", "grant", "pay", "sell 60 minecraft:wheat",
assert results["audit"] == ["open", "open", "eco_give", "pay", "sell 60 minecraft:wheat",
"sell 1 minecraft:diamond", "buy 5 minecraft:torch", "trade", "trade_failed",
"trade", "trade_money", "pay", "pay", "coinflip_stake", "coinflip_stake", "coinflip_win",
"coinflip_stake", "lottery_ticket", "lottery_ticket", "lottery_win"] + (
@@ -1027,16 +1013,14 @@ def run(ctl, resources, out):
economy = json.loads((data / "economy.json").read_text())
total = sum(a["balance"] for a in economy["accounts"].values())
assert "held" not in economy and total == 815 + 7 + added, economy
# Every coin an operator gave came out of the Treasury.
# The market's tax went in: 36 in the shop, and on her diamond.
assert economy["pools"]["treasury"] == 5000 - 500 - 2010 + 36 + results["market"]["tax"], economy["pools"]
# The old Treasury pool is gone; no fee or tax took anything.
assert "treasury" not in economy.get("pools", {}), economy.get("pools")
audit = [json.loads(l) for l in (data / "audit.log").read_text().splitlines()]
took = [a for a in audit if a["kind"] == "invsee_take"]
assert [(a["item"], a["count"], a["inventory"]) for a in took] == [("minecraft:torch", 5, "inventory")], took
kinds = [a["kind"] for a in audit if not a["kind"].startswith("invsee_")]
shop_kinds = ["grant", "buy 1 minecraft:iron_ingot", "buy 64 minecraft:cobblestone", "market_tax",
"sell 1 minecraft:iron_ingot", "sell 9 minecraft:diamond", "market_tax",
"buy 1 minecraft:diamond", "market_tax"]
shop_kinds = ["eco_give", "buy 1 minecraft:iron_ingot", "buy 64 minecraft:cobblestone",
"sell 1 minecraft:iron_ingot", "sell 9 minecraft:diamond", "buy 1 minecraft:diamond"]
assert kinds[-len(shop_kinds):] == shop_kinds, kinds[-8:]
kinds = kinds[:-len(shop_kinds)]
assert kinds[-2] in ("held_returned", "coinflip_cancel") and kinds[-1] == "daily_reward", kinds[-3:]
+7 -7
View File
@@ -5,7 +5,7 @@ Run it with `SMP_PROOF=casino python3 native/tools/native.py proof --resources P
Checked from the clients' own menus, chat, titles and action bar, and from the files the server wrote:
- the pot is funded from the Treasury at the first start (no coin made), and `/casino help` shows every game's odds;
- the pot gets its float at the first start (made once), and `/casino help` shows every game's odds;
- `/casino` opens the lobby (its title, the four games, the pot); a click opens a game;
- roulette: a bet on Red, the stake raised, the wheel turns (several different pictures while it spins), the round
is paid and the table shows the result; a second spin with the menu closed mid-spin is still paid, once;
@@ -15,7 +15,7 @@ Checked from the clients' own menus, chat, titles and action bar, and from the f
- blackjack: a hand dealt and stood (or settled at once on a blackjack);
- the bet limits: the most is `max_bet`;
- after the server stops: every bet held is settled exactly once in audit.log, nothing is left held, and the money
in the game (balances + pools) is what the Treasury held at the start; casino.json holds the day's totals; no
in the game (balances + pools) is the float and the operator's two `/eco give`s; casino.json holds the day's totals; no
console command the casino ran was refused.
"""
import importlib.util
@@ -31,7 +31,8 @@ _spec = importlib.util.spec_from_file_location("smp_proof_harness", Path(__file_
harness = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(harness)
TREASURY = 50_000
FLOAT = 20_000
GIVEN = 2 * 3000
NAME = re.compile(r'"?text"?\s*:\s*"((?:[^"\\]|\\.)*)"')
GAMES = ("casino_roulette", "casino_slots", "casino_crash", "casino_blackjack")
@@ -225,8 +226,7 @@ def run(ctl, resources, out):
data.mkdir(parents=True)
(data / "config.json").write_text(json.dumps({
"gambling": {"enabled": True}, "starting_balance": 0, "presence": {"daily_reward": 0},
"casino": {"cooldown_ms": 500, "float": 20_000}}))
(data / "economy.json").write_text(json.dumps({"accounts": {}, "pools": {"treasury": TREASURY}}))
"casino": {"cooldown_ms": 500, "float": FLOAT}}))
server.start()
for line in ("difficulty peaceful", "gamerule spawn_monsters false"):
server.console(line)
@@ -235,7 +235,7 @@ def run(ctl, resources, out):
server.console("op Alex")
time.sleep(2)
for who in ("Alex", "Steve"):
assert any("The Treasury paid" in l for l in alex.say(f"/treasury pay {who} 3000 casino proof"))
assert any(f"Alex gave 3,000 coins to {who}: casino proof." in l for l in alex.say(f"/eco give {who} 3000 casino proof"))
results["help"] = alex.say("/casino help")
text = "\n".join(results["help"])
for want in ("bets 10 coins to 1,000 coins", "pot (20,000 coins now)", "European", "94.2%", "96%", "3 to 2"):
@@ -273,7 +273,7 @@ def run(ctl, resources, out):
results["economy"] = economy
held = sum(economy.get("held", {}).values())
money = sum(a["balance"] for a in economy["accounts"].values()) + held + sum(economy["pools"].values())
assert held == 0 and money == TREASURY, economy
assert held == 0 and money == FLOAT + GIVEN, economy
ledger = json.loads((data / "casino.json").read_text())
results["casino.json"] = ledger
assert sum(t["rounds"] for t in ledger["totals"].values()) == 7, ledger
-8
View File
@@ -3,8 +3,6 @@
//! `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_casino.rs"]
@@ -21,14 +19,10 @@ pub mod smp_casino_slots;
pub mod smp_claims;
#[path = "../mixins/smp/smp_economy.rs"]
pub mod smp_economy;
#[path = "../mixins/smp/smp_futures.rs"]
pub mod smp_futures;
#[path = "../mixins/smp/smp_games.rs"]
pub mod smp_games;
#[path = "../mixins/smp/smp_invsee.rs"]
pub mod smp_invsee;
#[path = "../mixins/smp/smp_jobs.rs"]
pub mod smp_jobs;
#[path = "../mixins/smp/smp_lottery.rs"]
pub mod smp_lottery;
#[path = "../mixins/smp/smp_market.rs"]
@@ -53,7 +47,5 @@ pub mod smp_report;
pub mod smp_shop;
#[path = "../mixins/smp/smp_store.rs"]
pub mod smp_store;
#[path = "../mixins/smp/smp_tax.rs"]
pub mod smp_tax;
#[path = "../mixins/smp/smp_trade.rs"]
pub mod smp_trade;