Compare commits

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

Submission: XqQ7IpgwPtKw1B0Z3zBtC4y-Rba_nrz0knwgwnWtQg0
Co-authored-by: JustKarkat <[email protected]>
2026-11-02 13:01:21 +01:00
Ludovic Marie 245ab9b4f6 Merge pull request #143 from horizon-rust/feat/smp-one-price
SMP 0.29.0: one market price per item, to buy and to sell
2026-11-02 00:43:42 +01:00
Ludovic MarieandClaude Opus 5.5 61e99d5df7 SMP 0.29.0: carry the old price history and futures entries over
A book from before 0.29 quotes its history in the old mid of buy and sell
(diamond 250); the one price is 200. Scaled once at load (the book now
records its bases), so charts, 1 h/24 h moves, the news and the futures'
TWAP see no cliff; open futures positions get the same ratio on their
entry, so none gains or loses by it. Each minute's band opens at the
loaded price rather than at the base.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:28:31 +01:00
Ludovic MarieandClaude Opus 5.5 59f2388a0f SMP 0.29.0: one market price per item, to buy and to sell
Ludovic: the market's buy and sell prices didn't make sense (diamond bought
for 400 and sold for 100, two numbers everywhere). Each item now has one
price, the geometric mean of the old pair (diamond 200, iron 15, wheat 2),
shown in /worth, /market, /shop, the chart maps and market.json (`price`;
`buy`/`sell` repeat it on the sides the market trades).

- A trade of N is priced along the curve it moves (each unit moves the log
  price by 1%/depth): buying N and selling them back returns exactly what
  was paid less the tax, for 1, 64 or past the band. Rounding goes against
  the trader (up when buying, down when selling).
- Prices move as trades happen; all trades together move an item at most
  5% a minute: past that a purchase is cut short and a sale keeps the
  rest (flat pricing at the edge would let slow buying plus a flat dump
  earn). At the 0.6/1.6 factor bounds the rest goes at the bound.
- Items linked by recipes share one moving price, a block moving it as its
  9 ingots do; the vanilla-recipe test checks every item at the worst
  drift too, and finds a block sold for its ingots once unlinked.
- The tax is its own receipt line ("Tax 2%: -12 coins. You get 583
  coins."). Daily sell limits unchanged.
- A config with two prices gets one at load, logged ("one price now").

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:20:33 +01:00
Ludovic Marie 5b0ae21c96 Merge pull request #141 from horizon-rust/fix/smp-casino-win-cap
SMP 0.28.0: casino daily win cap (10,000 net a day)
2026-11-01 22:53:26 +01:00
Ludovic Marie 097ccd28b0 Merge pull request #135 from horizon-rust/feat/smp-futures
SMP 0.25.0: futures with leverage on items (/futures)
2026-11-01 22:53:01 +01:00
Ludovic MarieandClaude Opus 5.5 31d77513fe SMP 0.28.0: the casino's daily win cap (10,000 net a day, configurable)
A player whose net casino winnings today reach casino.daily_win_cap
(10,000, same as the loss cap) is refused new bets until 00:00 UTC, with
a message saying why. The round that crosses the cap is paid in full; a
blackjack double inside a hand already dealt is still allowed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:43:50 +01:00
Ludovic MarieandClaude Opus 5.5 5ba617927f SMP 0.27.0: futures in the game (/futures menu, minute marking)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:43:47 +01:00
Ludovic MarieandClaude Opus 5.5 7257e32237 SMP futures: the rules (perpetuals on market prices, house = insurance fund)
Not wired into the game yet: the glue waits for the market's mark_price/twap/volume API.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:43:46 +01:00
Ludovic Marie 18102bf315 Merge pull request #134 from horizon-rust/feat/smp-dyn-market
smp 0.26.0: a moving market, /market, price charts on maps, pages pushed to Kira
2026-11-01 22:43:09 +01:00
Ludovic MarieandClaude Opus 5.5 7ec0a0516d smp proof: the selling limit counts Steve's /pay (#139), Moves may read 0.0% on a minute-old history
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:36:10 +01:00
Ludovic MarieandClaude Opus 5.5 c3839da0ef smp 0.26.0: a moving market, /market, price charts on maps, pages pushed to Kira
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 21:02:28 +01:00
Ludovic Marie 2f677e9337 Merge pull request #129 from horizon-rust/feat/smp-invsee
smp 0.25.0: /invsee and /endersee for operators
2026-11-01 19:52:00 +01:00
Ludovic MarieandClaude Opus 5.5 8295b4c1ef smp 0.25.0: /invsee and /endersee for operators
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:42:27 +01:00
Ludovic Marie cacc3360e0 Merge pull request #139 from horizon-rust/fix/smp-pay-cap
SMP 0.24.0: /pay received counts toward the daily selling limit
2026-11-01 19:36:55 +01:00
Ludovic MarieandClaude Opus 5.5 964325b819 SMP 0.24.0: /pay received counts toward the daily selling limit
Selling up to the cap on alts and paying it all to a main bypassed the daily sell
limit (reported by pointofpressure). A /pay now checks the receiver's remaining
daily_sell_coins and is refused past it; a payment made counts toward it. Trades
and auction sales are unaffected.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:30:11 +01:00
Ludovic Marie 9ce6a3f9a0 Merge pull request #137 from horizon-rust/feat/smp-casino
SMP 0.23.0: the casino (roulette, slots, crash, blackjack)
2026-11-01 19:07:18 +01:00
Ludovic MarieandClaude Opus 5.5 693205032b SMP 0.23.0: the casino (roulette, slots, crash, blackjack), paid from its own pot
/casino opens a lobby menu; each game is a chest menu a stock client plays,
animated at most 5 pictures a second per open menu and only while a round
moves, with sounds, titles, the action bar, a boss bar for crash, fireworks
and a line for everyone on a jackpot.

- European roulette (2.70% edge), slots with a published paytable (94.2%
  paid back), one shared crash round (96%), blackjack S17 3:2 (~1% edge).
- Draws by the server (ChaCha20 keyed from the system) before the
  animation. Bet limits, a daily loss cap a player, a cooldown.
- Bets are held, then settled once through settle_pool into the casino's
  pot (capped, never minted); hourly the pot above its float goes to the
  Treasury (casino_profit), below it the Treasury tops it up (casino_float).
- Closing the menu or leaving never loses a round.
- casino.json: the day's ledger, wins, totals. proof_casino.py plays every
  game on a real server with two clients.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:59:50 +01:00
Ludovic Marie e8b243b60d Merge pull request #126 from horizon-rust/feat/smp-nick-ops
smp 0.22.0: nicknames for operators only
2026-11-01 18:32:20 +01:00
Ludovic MarieandClaude Opus 5.5 409cac606e smp 0.22.0: nicknames for operators only
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:24:39 +01:00
Ludovic Marie d4463c5a82 Merge pull request #133 from horizon-rust/feat/smp-jobs
SMP 0.21.0: daily contracts (/jobs), paid from the Treasury
2026-11-01 18:22:51 +01:00
Ludovic Marie e001cceadd Merge pull request #132 from horizon-rust/feat/smp-report
SMP 0.20.0: /eco stats, the daily economy report, web/economy.json
2026-11-01 18:22:47 +01:00
Ludovic Marie 2b62bd35d9 Merge pull request #131 from horizon-rust/feat/smp-bank
SMP 0.19.0: the bank (/bank), savings paid by the Treasury
2026-11-01 18:20:31 +01:00
Ludovic MarieandClaude Opus 5.5 219c9302d9 SMP 0.21.0: daily contracts (/jobs), paid from the Treasury
Four contracts a day from farm, mine and mob produce; a delivery of 64 plain items
pays the market's sell price +30%, never over 90% of its ask (rechecked at
delivery), from the Treasury. 8 deliveries a contract, 2 a player a day, at most
10% of the Treasury a day, survival/adventure only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:18:13 +01:00
Ludovic Marie e9f25bb1d7 Merge pull request #130 from horizon-rust/feat/smp-sinks
SMP 0.18.0: travel fees to the Treasury, a daily reward for playing
2026-11-01 18:14:15 +01:00
Ludovic MarieandClaude Opus 5.5 bb97027039 SMP 0.20.0: /eco stats, the daily economy report, web/economy.json
/eco stats: the supply and where it sits, what the server made and destroyed
today (new per-kind flows in economy.json), the day's taxes, Gini and top-10
share. At 00:00 UTC everyone reads the day before; 60 daily snapshots kept in
ext.report. web/economy.json once a minute, written off the tick thread, with
every Treasury movement and the tax code, for /hub.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:13:49 +01:00
Ludovic MarieandClaude Opus 5.5 27bfc9da13 SMP 0.19.0: the bank (/bank), savings paid by the Treasury
Deposits wait in the `bank` pool; interest 0.1% a day on up to 10,000 coins,
paid at 00:00 UTC from the Treasury on the day's lowest amount (no
deposit-before-midnight trick), at most 5% of the Treasury a night, pro rata.
At most 25,000 saved. The wealth tax counts savings. No loans.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:08:26 +01:00
Ludovic MarieandClaude Opus 5.5 fa61a38111 SMP 0.18.0: travel fees to the Treasury, a daily reward for playing
Teleports cost a fee (home 5, warp 10, back 10, tpa 10, spawn free), checked when
the command runs and paid to the Treasury when the teleport happens; one called off
costs nothing and keeps /back and the cooldown. The daily reward waits for 10
minutes of play that day (moving or commands), so alts that only log in get
nothing. Proof: fees and delay off to keep its sums; config_keys gets `auction`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:04:22 +01:00
Ludovic Marie 169ac59750 Merge pull request #128 from horizon-rust/feat/smp-auction
SMP 0.17.0: the auction house (/ah)
2026-11-01 17:59:05 +01:00
Ludovic MarieandClaude Opus 5.5 7c850a3056 SMP 0.17.0: the auction house (/ah)
Players sell to players: fixed-price listings or timed auctions, browsed
in a chest menu by category (or a search), cheapest a unit first.

- Listing fee 1% (min 1 coin) and 5% VAT on each sale go to the Treasury.
- Listed items leave the inventory at once; unsold ones, auction wins and
  what didn't fit come back by mail (/ah claim, or the menu's mail page).
- A purchase is one step under the SMP's lock: the listing leaves first,
  so of two buyers at once the second finds it gone.
- Bids are held in the `auction` pool; the outbid get theirs back at once;
  a bid in the last 60 s moves the end back to 60 s; offline sellers are
  paid and get a note when they're back.
- Listings, bids, mail and notes are written in economy.json with the
  money (ext.auction): a crash can't split items from coins.
- Plain items only until native_give_stack is stamped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:51:31 +01:00
Ludovic Marie e3ee0a7c45 Merge pull request #127 from horizon-rust/feat/smp-treasury
SMP 0.16.0: a state Treasury, nobody makes money, the tax code
2026-11-01 17:51:16 +01:00
Ludovic MarieandClaude Opus 5.5 3f115b7e06 SMP: the pools other parts use aren't dead code in the game build
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:47:43 +01:00
Ludovic MarieandClaude Opus 5.5 a864f877ec SMP 0.16.0: a state Treasury, nobody makes money, the tax code
Every tax, fee and house edge now goes into a Treasury pool kept in
economy.json with the balances; operators pay only from it, with a reason
everyone sees (/treasury pay, logged as `grant`). /eco give, /eco set and
/eco give * are gone: nobody can make money, operators included. /eco take
moves a cheat's gains into the Treasury.

- smp_economy: system pools (`pools`: treasury, insurance, casino...),
  to_pool / from_pool / pool_move / settle_pool, each one audit line; the
  Treasury's income and spending by kind (today, all time) and its last 50
  movements; `ext` for other parts' state written in the same step as the
  money. award()'s fee goes to the Treasury.
- smp_tax: the tax code (VAT 5% for the auction house, land tax 5 coins a
  week per chunk past 4, wealth tax 1/2/3% above 50k/250k/1M a week),
  collected on Monday 00:00 UTC (an O(1) check each second), unpaid land tax
  owed and blocking new claims; /treasury, /treasury log, /eco taxes,
  /eco taxes pay.
- proof: funds players from a seeded Treasury instead of /eco give, and
  checks /treasury, /treasury log, /eco taxes and the refusals.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:39:49 +01:00
Ludovic Marie 41874a0e0c Merge pull request #121 from horizon-rust/submit/astral_cruiser-0.1.0
Add astral_cruiser 0.1.0
2026-11-01 08:32:48 +01:00
Ludovic Marie 48ed3ea061 Merge pull request #122 from horizon-rust/submit/basic_hud-0.1.0
Add basic_hud 0.1.0
2026-11-01 08:32:11 +01:00
Ludovic MarieandClaude Opus 5.5 8e5a70b52b astral_cruiser: say what blasting breaks and where the ship is saved
Review fix by Ludovic's AI agent (Claude): the nose guns and beams blow up
blocks when blasting is on (off by default), and the ship is kept in the
world's folder (astral_cruiser/ship.json).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:31:51 +01:00
Ludovic Marieandrepositorykreml1n d31c2d7992 Add basic_hud 0.1.0
Submitted by @repositorykreml1n through the form.

Submission: aXw6Cm5ItBnfJZ5XYEO0w5xfcjfLs6I5Vvv5h9vqr6M
Co-authored-by: repositorykreml1n <[email protected]>
2026-11-01 07:15:41 +01:00
Ludovic Marieandrepositorykreml1n 9f691e7e83 Add astral_cruiser 0.1.0
Submitted by @repositorykreml1n through the form.

Submission: CCBrVRl_Te6V2LQUa8oSqf6mTpFOgWk62yydwskMFw4
Co-authored-by: repositorykreml1n <[email protected]>
2026-11-01 05:43:12 +01:00
Ludovic Marie c5d504a734 Merge pull request #119 from horizon-rust/docs/native-mods-build-path
docs: point native-mod build instructions at the Mods page
2026-11-01 02:54:07 +01:00
Ludovic MarieandClaude Sonnet 5.5 6c2a827c34 docs: point native-mod build instructions at the Mods page
"Settings → Game → Native mods (experimental)" no longer exists. The launcher builds
native mods from the Mods page (Build on the native build row); the game lists and
toggles them under Options → Mods. Prose only: no sha256, file or version changes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 02:52:17 +01:00
Ludovic Marie 90f06bd473 Merge pull request #117 from horizon-rust/fix/smp-nick-abuse
SMP 0.15.0: nicknames that can't pass for someone else
2026-11-01 01:02:38 +01:00
Ludovic MarieandClaude Opus 5.5 3395149802 SMP 0.15.0: nicknames that can't pass for someone else
rdbt (Team moderator) reported /nick abuse on the official server. Nicknames now:
- show as ~name in chat, the Tab list and the sidebar;
- are compared case-insensitively, with _ dropped and look-alikes folded (0/o, 1/i/l, 3/e...);
- are refused when equal to, or containing, a known player's name (economy accounts, usercache.json), an
  operator's, a Team member's (nicks.protected) or another player's nickname, or when they read as a reserved
  word (server, console, admin, mod, owner, staff, op, horizon, kira, notch, mojang, herobrine...);
- need nicks.min_playtime_minutes (60) of play first, operators excepted; the chat filter and cooldown stay;
- that break these rules are taken off at start, each logged.

/pay, /tpa, /trade, /playtime, /tpaccept, /tpdeny and /eco take a nickname where they take a player's name;
/nick alone says how to take it off. The proof covers each rule end to end.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:58:13 +01:00
Ludovic Marie 3fc6964447 Merge pull request #116 from horizon-rust/feat/smp-scoreboard
SMP 0.14.0: the sidebar as an SMP scoreboard, the player's own block first
2026-10-31 23:50:36 +01:00
Ludovic MarieandClaude Opus 5.5 3749d679bd SMP 0.14.0: the sidebar as an SMP scoreboard, the player's own block first
Title "Horizon SMP": the player's name (their nickname if set), balance,
playtime and the players online; then the three richest; then the
server's address (board.footer, play.playhorizon.dev). The ranking is still
built once for everyone, on a balance change or every refresh_seconds; the
players' own lines are looked at on the same refresh_seconds and sent only
where they changed (and to newcomers). 0.13's untouched defaults (Richest
players, top 10) move to the new ones. Feedback from rdbt (Team moderator).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:46:25 +01:00
Ludovic Marie d933c333e6 Merge pull request #114 from horizon-rust/feat/smp-nick
SMP 0.13.0: nicknames (/nick, /realname)
2026-10-31 22:59:15 +01:00
Ludovic MarieandClaude Opus 5.5 c86a73887a SMP 0.13.0: nicknames (/nick, /realname)
/nick <name> and /nick off for players, /nick <player> <name|off> and
/realname <nick> for operators. The nickname shows in chat and the Tab list
(horizon API 62: native_set_nickname), the hover keeps the real name, and
the log, the anticheat and Discord keep the real one. Rules: 3-16 letters,
digits or _; never a known player's name, an operator's or another
player's nickname; through the chat filter (native_text_allowed); a
10-minute cooldown. Saved in world/horizon-smp/nicks.json.

Also: the redundant 'eco give * <amount>' literal is gone (the engine left
it out: '*' isn't a command word; <player:word> takes it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:55:57 +01:00
Ludovic Marie 83c4269800 Merge pull request #109 from horizon-rust/docs/reviewable-licences
Policy: list only source reviewers may read; every update is reviewed
2026-10-31 22:46:29 +01:00
Ludovic MarieandClaude Opus 5.5 89d90c3376 Policy: list only source reviewers may read; every update is reviewed
MOD-POLICY gets the licence rule reviewers already follow: a new project or
a new version needs source under a permissive licence (MIT, MIT-0, BSD,
0BSD, Apache-2.0, ISC, zlib, Unlicense, CC0) or a written grant to the
Horizon reviewers. classic_zoom, distant_horizons, fly_speed and serverify,
listed before, stay as they are until their next version.

The README no longer promises owner updates without a review: every version
of a native mod runs with the player's account permissions, so a reviewer
reads it first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:45:40 +01:00
Ludovic Marie 6b71454d1b Merge pull request #96 from horizon-rust/submit/freecam-0.1.0
Add freecam 0.1.0
2026-10-31 22:38:28 +01:00
Ludovic Marie 57f7902809 Merge pull request #94 from horizon-rust/submit/fair_freecam-1.2.0
Add fair_freecam 1.2.0
2026-10-31 22:38:22 +01:00
Ludovic Marie 7c2b9f3b80 Merge pull request #90 from horizon-rust/submit/freecam_plus-1.0.0
Add freecam_plus 1.0.0
2026-10-31 22:38:16 +01:00
Ludovic Marie 2a4307638f Merge pull request #83 from horizon-rust/submit/menu_panorama-panorama-1.0.0
Add menu_panorama panorama-1.0.0
2026-10-31 22:38:10 +01:00
Ludovic MarieandClaude Opus 5.5 9f7346e2e5 freecam: credit and link the original Freecam mod (MOD-POLICY, promise 2)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:35:17 +01:00
Ludovic Marie a04b258daf Merge remote-tracking branch 'origin/main' into fix-freecam 2026-10-31 22:35:16 +01:00
Ludovic MarieandClaude Opus 5.5 1b4ab4efe8 menu_panorama: clean the page, version 1.0.0, icon in the registry
The description started with the form's paste instructions; the version
was "panorama-1.0.0" (the pack's own version is 1.0.0); the launcher reads
icons from icons/<id>.png.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:35:10 +01:00
Ludovic Marie 0eee668de4 Merge remote-tracking branch 'origin/main' into fix-menu_panorama 2026-10-31 22:34:56 +01:00
Ludovic MarieandClaude Opus 5.5 a5fdef87fb freecam_plus: host the icon in the registry
The launcher reads icons from icons/<id>.png, not raw.githubusercontent.com.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:34:44 +01:00
Ludovic Marie 44fa384ad6 Merge remote-tracking branch 'origin/main' into fix-freecam_plus 2026-10-31 22:34:42 +01:00
Ludovic MarieandClaude Opus 5.5 f25ae007a7 fair_freecam: host the icon in the registry
The entry's icon URL pointed at the repository's old name; the launcher
reads icons from icons/<id>.png.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:34:36 +01:00
Ludovic Marie 181b9af7de Merge remote-tracking branch 'origin/main' into fix-fair_freecam 2026-10-31 22:34:35 +01:00
Ludovic Marieandhakergeniusz c64087836d Add freecam 0.1.0
Submitted by @hakergeniusz through the form.

Co-authored-by: hakergeniusz <[email protected]>
2026-10-31 18:19:07 +01:00
Ludovic MarieandLutziano888 9a7b8a0ffc Add fair_freecam 1.2.0
Submitted by @Lutziano888 through the form.

Co-authored-by: Lutziano888 <[email protected]>
2026-10-31 17:29:18 +01:00
Ludovic Marieandumbx-dot 8c837ede87 Add freecam_plus 1.0.0
Submitted by @umbx-dot through the form.

Co-authored-by: umbx-dot <[email protected]>
2026-10-31 13:38:36 +01:00
Ludovic Marieandform1t ff69e902c8 Add menu_panorama panorama-1.0.0
Submitted by @form1t through the form.

Co-authored-by: form1t <[email protected]>
2026-10-31 11:53:17 +01:00
77 changed files with 17488 additions and 464 deletions

No files matched your search

+15
View File
@@ -36,6 +36,21 @@ Submitting a project, through the form or a pull request, means you confirm that
You alone are responsible for what you publish and for any claim about it. The Horizon project may refuse, hide or
remove any project, at any time, without having to give a reason.
## Licences reviewers can read
Reviewers read a project's source before each version is listed; that is how they check it holds nothing harmful.
They may only read source under a licence that lets them, so a new project, and every new version of a listed one,
needs source under one of:
- MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, 0BSD, Apache-2.0, ISC, zlib, Unlicense or CC0-1.0;
- or any licence, with a written grant from its authors that lets the Horizon reviewers read the source to review it
(say so in the pull request, with a link to the grant).
Source under GPL, LGPL, MPL or all rights reserved, without such a grant, can't be reviewed, so it can't be listed or
updated. The projects already listed under other licences when this rule was written (classic_zoom,
distant_horizons, fly_speed, serverify) stay listed as they are; their next version needs a licence above or a
grant. A port also has to follow promise 2 above.
## Copyright and takedowns
If a project in this registry uses your work without your permission:
+5 -4
View File
@@ -18,8 +18,8 @@ and the launcher to have shipped first.
**Who is responsible.** Except the projects marked Official (made by the Horizon project), everything here is made and
owned by its own authors, not by the Horizon project or its maintainers. A listing is not an endorsement. See the
[mod policy](MOD-POLICY.md): what authors promise when they submit (ports of other mods included) and how to have
a project taken down.
[mod policy](MOD-POLICY.md): what authors promise when they submit (ports of other mods included), the licences
whose source reviewers can read, and how to have a project taken down.
## Submit
@@ -41,8 +41,9 @@ cargo install --git https://github.com/horizon-rust/horizon cargo-horizon-mod
cargo horizon-mod check target/horizon-mod/my_mod.hmod
```
**Updates** come the same way: the form adds a version to your entry. Only an entry's `owners` publish its updates
without a reviewer's extra look. A published file never changes: publish a new version.
**Updates** come the same way: the form adds a version to your entry, and a reviewer reads it before it is
published. Only an entry's `owners` can add versions, and every version of a native mod is reviewed, owner or
not: its code runs with the player's account permissions. A published file never changes: publish a new version.
**Signing** is optional and recommended: put your [minisign](https://jedisct1.github.io/minisign/) public key in your
entry, and every later version must be signed with it, so nobody else can publish an update in your name.
Binary file not shown.

After

Width:  |  Height:  |  Size: 855 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+32
View File
@@ -0,0 +1,32 @@
id = "astral_cruiser"
kind = "native-mod"
name = "Astral Cruiser"
summary = "A huge pilotable starship with a walkable interior, lasers, dash and live portals between dimensions."
description = """
A huge starship you fly yourself, with a large walkable interior that moves with it.
- **Flight:** WASD with the ship following your view, and F5 views including the ship's own.
- **Weapons:** turrets, nose guns and two beams.
- **Travel:** coordinate dash and jump. Long dashes show only a small strip of far terrain around the ship.
- **Portals:** a portal shows the real destination dimension live, and you cross it aboard or on foot.
- **Start:** `/astral spawn`, then `/astral board`. N opens the control panel; keys are in Options > Mods > Settings.
**What it does as a native mod.** It is compiled into the game with no sandbox. It changes the game's rendering, ticking and chunk loading for the ship and portals, and registers a thread-exit callback in builds made with MinGW so the allocator frees finished threads' memory.
**What it does not do.** It adds no network access of its own beyond the game's own, and it reads no accounts or files outside the game's data. It keeps the ship in the world's own folder (`astral_cruiser/ship.json`). It holds no Minecraft code or assets.
Weapons hurt the mobs they hit. With blasting turned on in the control panel (off by default), the nose guns and beams also blow up the blocks they hit. Portals can hang in the air, so a player on foot falls if there is no ground at the exit, including into the End's void.
Built for this Horizon commit only: `68ac6f647e7022b2c696e81863d7cface28c9416`."""
authors = ["repositorykreml1n"]
owners = ["repositorykreml1n"]
license = "MIT"
source = "https://github.com/repositorykreml1n/astral-cruiser"
[[versions]]
version = "0.1.0"
published = "2026-11-01"
game = "26.3"
file = "astral_cruiser-0.1.0.hnmod"
sha256 = "69100219f92307f9190712da2854bfd43f15cf81773d89d89d6dd22411caa2f2"
changelog = "First release, built for Horizon 68ac6f6."
+34
View File
@@ -0,0 +1,34 @@
id = "basic_hud"
kind = "native-mod"
name = "The Basic HUD"
summary = "Effect timers, potions, golden apples and totems carried, totems used by each player, and armour, elytra and shield durability."
description = """
A small HUD box in a corner of the screen.
## What it shows
- Active effects with their time left. Potion effects are pinned by default; any other effect can be pinned on the mod's settings page, or all of them shown at once.
- Potions of each kind, golden apples, enchanted golden apples and totems in your inventory.
- Totems each player has used, above that player's name. Your own count is in the box.
- Durability of worn armour, elytra and a held shield.
The count of a player's totems resets when they die or when you leave the world.
## What it does not do
- It builds on one Horizon commit only, 68ac6f6 (Minecraft 26.3). Counting totems and writing above a player have no hook point, so it hooks two of the game's own functions (totem_event, name_tag_geometry). The rest uses hook points but reads the game's internal data.
- It knows only what the client is told: a totem used out of your sight is not counted.
- Its own texts are in English. Effect and item names follow the game's language.
- Tested on Windows in single player and over LAN with two clients, not on a dedicated server."""
authors = ["repositorykreml1n"]
owners = ["repositorykreml1n"]
license = "MIT"
source = "https://github.com/repositorykreml1n/the-basic-hud"
[[versions]]
version = "0.1.0"
published = "2026-11-01"
game = "26.3"
file = "basic_hud-0.1.0.hnmod"
sha256 = "fe2c5d9da681b4f575bff867df70d6e2f61614b0215a206fb7a0c3b0027db994"
changelog = "First release."
+1 -1
View File
@@ -3,7 +3,7 @@ kind = "native-mod"
name = "BiomeCompass"
summary = "Craft a Biome Compass, pick a biome and its needle points to the nearest one."
description = """
**A native Horizon mod**: the launcher builds it into the game (Settings → Game → Native mods, then Build). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
**A native Horizon mod**: the launcher builds it into the game (install it on the launcher's Mods page, then press Build there). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
Craft a **Biome Compass** (a compass with a sapling on each of its four sides, any kinds), right-click it and pick a biome from the list (with a search box). The world generator is searched around you, a little every tick, and the compass's needle then points to the nearest one, with its distance and direction on the action bar.
+1 -1
View File
@@ -3,7 +3,7 @@ kind = "native-mod"
name = "Carry Block"
summary = "Sneak and right-click with an empty hand to pick up a chest (with its items) or a small mob, carry it and put it down."
description = """
**A native Horizon mod**: the launcher builds it into the game (Settings → Game → Native mods, then Build). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
**A native Horizon mod**: the launcher builds it into the game (install it on the launcher's Mods page, then press Build there). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
Sneak and right-click with an empty hand to pick up a block with its contents (chests, furnaces, barrels...) or a small, friendly mob, and carry it; right-click to put it down where you want, with everything it held.
+1 -1
View File
@@ -32,7 +32,7 @@ Both can be installed at once: they use different keys.
- Written from scratch for Horizon's mod API. Not affiliated with Wurst-Imperium.
- Some servers forbid zoom mods: check the rules before you join.
**Now a native mod (2.0.0)**: the launcher builds it into the game (Settings → Game → Native mods (experimental), then Build). Native mods are not sandboxed and run with your account's permissions, so read the source before you build it: it is one small bundle on Horizon's native hook points (key, wheel, zoom, mods list). Tested against Horizon `6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a` with Horizon Mixins `0d6b103c70633d8fd9443d8d0acd3aadaacedb06`. A key set in 1.0.0 carries over.
**Now a native mod (2.0.0)**: the launcher builds it into the game (install it on the launcher's Mods page, then press Build there). Native mods are not sandboxed and run with your account's permissions, so read the source before you build it: it is one small bundle on Horizon's native hook points (key, wheel, zoom, mods list). Tested against Horizon `6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a` with Horizon Mixins `0d6b103c70633d8fd9443d8d0acd3aadaacedb06`. A key set in 1.0.0 carries over.
Source and issues: [github.com/SU4G3/classic-zoom](https://github.com/SU4G3/classic-zoom)"""
authors = ["Surge"]
+1 -1
View File
@@ -11,7 +11,7 @@ Your block position and the direction you face in the top-left corner of the scr
It reads the player's position, draws on the HUD and writes to chat: nothing else.
Now a native mod (2.0.0): built into the game by the launcher, on the Horizon you play, like any native mod
(Settings → Game → Native mods (experimental), then Build). The time of
(install it on the launcher's Mods page, then press Build there). The time of
day line is gone, since Horizon's hook points don't give the game's clock yet. Turn it off in Options > Mods: it
rests at once, and the next build leaves it out. Its source is
[horizon-mods/native/coords-hud](https://github.com/horizon-rust/horizon-mods/tree/main/native/coords-hud).
+33
View File
@@ -0,0 +1,33 @@
id = "crossversion"
kind = "native-mod"
name = "CrossVersion"
summary = "Allows connecting to older servers"
description = """
A native Horizon mod that lets the 26.3 client join servers that speak an older Minecraft protocol.
Horizon talks exactly one protocol (777, Minecraft 26.3). This mod watches a join, asks the server what protocol it speaks in its status reply, and — if it is not 26.3 — starts a ViaProxy instance on a loopback port and points the join at it. The proxy speaks 26.3 to Horizon and the server's own version to the server. Nothing else in the client changes, and a join to a 26.3 server never starts anything.
Horizon client ──26.3──▶ 127.0.0.1:<port> ViaProxy ──server's version──▶ the server
Tested against a vanilla 1.20.1 server (protocol 763) and a 1.21.1 server (767): world load, chunks, entities, chat and the tab list all work. ViaProxy itself covers servers from 1.0 to 26.3. An online-mode 1.20.1 server accepts the player's own account through the proxy.
Signed chat works too: from Horizon's Nightly 2026-11-01 on, the client holds a chat key pair and signs its messages, so online-mode servers with enforce-secure-profile=true accept them through the proxy (<JustKarkat> …, not [Not Secure]). On Horizon before that nightly (the mod's 0.1.x pinned an earlier commit) secure-profile servers refused the chat and said "Chat disabled due to missing profile public key" — the client's gap, not the proxy's."""
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=true"
key = "RWTcPJHJYjPW0pV3lPwr45crCu0wJxW5HbDLc3fr3Eom1TzxO++kHOo4"
[[versions]]
version = "0.4.0"
published = "2026-11-02"
game = "26.3"
file = "crossversion-0.4.0.hnmod"
sha256 = "5cf5ed0c16636a72da77d00f25923b2dc9938b66bcba2fc3571f2220f99f3f30"
signature = """
untrusted comment: signature from minisign secret key
RUTcPJHJYjPW0sufgxegROTP9Mp0mOnggtNsendRR9PUL9gvOQAXIpX+gOSNinE/Xsze7pzBkvi7kgWqmR3hAeM/GZ8Wal97CQU=
trusted comment: timestamp:1793593673\tfile:crossversion-0.4.0.hnmod\thashed
NErBRO3f7O8sN/aQHf7Fsrqanubwgo9rCm/kuxSP0Oux21LiX6jcuMxeMykUmjtWP6AJDptFjdyAHlA7m0P9Dw=="""
changelog = "Initial push for CrossVersion"
+1 -1
View File
@@ -12,7 +12,7 @@ An unofficial Rust port of [Distant Horizons](https://gitlab.com/distant-horizon
Settings: Options > Mods > Distant Horizons. In chat: `/dh` for status, `/dh distance 512`, `/dh quality high`, `/dh debug on` and more (Tab completes them).
Native source mod: enable Settings → Game → Native mods (experimental), choose Build and confirm the native-code warning. The launcher downloads its own Rust and builds a separate game (10–30 minutes the first time). It runs with your account's permissions.
Native source mod: install it on the launcher's Mods page, press Build there and confirm the native-code warning. The launcher downloads its own Rust and builds a separate game (10–30 minutes the first time). It runs with your account's permissions.
Pinned to Horizon nightly `d009ebd140476b5bdc958c00f56a75e82abec691` (2026-10-29) and Horizon Mixins `0d6b103c70633d8fd9443d8d0acd3aadaacedb06`; the launcher builds these exact revisions. Other Horizon revisions have not been validated. It builds together with Horizon's official native mods; it can't be built with a native mod pinned to a different Horizon commit. Turn Video Settings > LOD Distance off: Horizon's own far terrain draws in the same place.
+1 -1
View File
@@ -3,7 +3,7 @@ kind = "native-mod"
name = "Enchantment Hints"
summary = "A short description under every enchantment in item tooltips, in English and Russian."
description = """
**A native Horizon mod**: the launcher builds it into the game (Settings → Game → Native mods, then Build). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
**A native Horizon mod**: the launcher builds it into the game (install it on the launcher's Mods page, then press Build there). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
Every enchantment on books and gear gets a one-line description in its tooltip: what it does, in plain words. Our own text for every 26.3 enchantment, in English and Russian.
+34
View File
@@ -0,0 +1,34 @@
id = "fair_freecam"
kind = "native-mod"
name = "Fair Freecam"
summary = "Fly the camera freely while your player stays where it is, with an optional fair play mode."
description = """
Press **F4** to leave your body and fly the camera anywhere. Your player stays where it is and you see it from outside. Press **F4** again to snap back.
**Controls while flying:**
- **W A S D**: fly where you look
- **Space / Shift**: up / down
- **Ctrl**: 2.5x faster
- **Mouse wheel**: change the speed
**Settings** (Options > Mods > Fair Freecam):
- **Speed**: 2, 5, 10, 20, 40 or 80 blocks per second.
- **Fair play**: the camera can't fly through blocks, so no peeking into caves or bases through walls. Glass, fences and doors stop it, grass and flowers don't.
- **Allowed on servers**: turn it off to only use freecam in your own worlds.
While flying, your player doesn't move, attack, mine or use items.
Some servers don't allow freecam, so check their rules first."""
authors = ["Lutziano888"]
owners = ["Lutziano888"]
license = "MIT"
source = "https://github.com/Lutziano888/horizon-freecam"
icon = "icons/fair_freecam.png"
[[versions]]
version = "1.2.0"
published = "2026-10-31"
game = "26.3"
file = "fair_freecam-1.2.0.hnmod"
sha256 = "4fdd0b3067a00d23fe30ee4d594293ef0705325bb5e6e5b292f90c1e8b8a5f8a"
changelog = "First release as Fair Freecam (id fair_freecam), so it doesn't clash with other freecam mods."
+1 -1
View File
@@ -12,7 +12,7 @@ Horizon's own FPS counter, formerly an extra in Options > Horizon Extras.
It reads the game's own frame counter and draws on the HUD: nothing else.
Now a native mod (2.0.0): built into the game by the launcher, on the Horizon you play, like any native mod
(Settings → Game → Native mods (experimental), then Build). Your settings carry over from 1.0.0. Turn it off in
(install it on the launcher's Mods page, then press Build there). Your settings carry over from 1.0.0. Turn it off in
Options > Mods: it rests at once, and the next build leaves it out. Its source is
[horizon-mods/native/fps-overlay](https://github.com/horizon-rust/horizon-mods/tree/main/native/fps-overlay).
"""
+23
View File
@@ -0,0 +1,23 @@
id = "freecam"
kind = "native-mod"
name = "Freecam"
summary = "Freecam mod ported for Horizon"
description = "A rewrite for Horizon of the Freecam mod by the Freecam authors (https://github.com/MinecraftFreecam/Freecam, MIT; on Modrinth as freecam). Its licence notice is kept in the source."
authors = ["hakergeniusz"]
owners = ["hakergeniusz"]
license = "MIT"
source = "https://github.com/hakergeniusz/freecam"
key = "RWR4cJC6Jj3EWGi26o29Rs3hAIPWqf8koFdXV4bBLF/Tm84QPfRAYjO5"
[[versions]]
version = "0.1.0"
published = "2026-10-31"
game = "26.3"
file = "freecam-0.1.0.hnmod"
sha256 = "6314ed87d3b58ed0244bed388201c64bffb8deab4f5ff7beed2a3791b391a6da"
signature = """
untrusted comment: signature from rsign secret key
RUR4cJC6Jj3EWN7yDLuEFss9Xir36oWeHI3yyIK+PlIffoXzHXcZ4eBAN/UcOwGpniH+lJl9uU1vzg8yRe9i6SKNMFhI6qbHkAQ=
trusted comment: freecam 0.1.0 for Horizon (Minecraft 26.3), built from github.com/hakergeniusz/freecam commit 78d8f05
6sfFvM54vZFE+02rwu0EJVbCnv5dJAJ7PDEWp64QnyEjlWYEnzXDzDAyLzKdZAfXwtvX+igaCS2KJKCk5UY5CA=="""
changelog = "Release."
+27
View File
@@ -0,0 +1,27 @@
id = "freecam_plus"
kind = "native-mod"
name = "Freecam+"
summary = "Fly the camera anywhere while your player stays perfectly still."
description = """
Press U and your camera leaves your body. Fly it with WASD, Space and Shift, hold Ctrl to go faster and use the mouse wheel to change the speed. Your player does not move or turn, so the server sees you standing still. Through walls and speed can be changed in Options, Mods, Freecam.
Since this is a native mod it will not work in v0.2.97 as there are no native hooks possible. This works on all nightly releases and LIKELY on version upwards from v0.2.97."""
authors = ["_idom_"]
owners = ["umbx-dot"]
license = "MIT"
source = "https://github.com/umbx-dot/horizon-freecam"
icon = "icons/freecam_plus.png"
key = "RWT9Ujo/oCprPGHHiSLHFNOJV1ZzoJdAU6EdZyiQcBwT2iMXfiYms4hD"
[[versions]]
version = "1.0.0"
published = "2026-10-31"
game = "26.3"
file = "freecam_plus-1.0.0.hnmod"
sha256 = "264218191c32e7107982eab297b30a43076c5f1467d4e292cb844213258b5eb1"
signature = """
untrusted comment: signature from minisign secret key\r
RUT9Ujo/oCprPAR7aucj7Ez3exPIc7MGDVH8ns9azj3ilxe+G4JTWb0PnF0YYxAm0FQJJs3DcmHxHjEEfiiWymsAnTG/cdggrQI=\r
trusted comment: timestamp:1793450278\tfile:freecam_plus-1.0.0.hnmod\thashed\r
/4Di5+zUUSOmYB56u/GDYran5upJjtmfdUff5uKER6lknPGYjsgeLfENH8trB+d8g2hhn70b5+lGxy9mrBfJBg=="""
changelog = "Initial Release. "
+1 -1
View File
@@ -3,7 +3,7 @@ kind = "native-mod"
name = "MoreGamma"
summary = "Brightness past 100%: set your own gamma with /gamma, or toggle full brightness with /fullbright."
description = """
A native Horizon mod: the launcher builds it into the game (Settings → Game → Native mods, then Build). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Uses only Horizon's native hook points (hooks 1); tested on Horizon 41672b1 and horizon-mixins 0d6b103.
A native Horizon mod: the launcher builds it into the game (install it on the launcher's Mods page, then press Build there). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Uses only Horizon's native hook points (hooks 1); tested on Horizon 41672b1 and horizon-mixins 0d6b103.
See in the dark without torches or night vision. MoreGamma lets you set the brightness (gamma) far past the video settings' 100% "Bright" limit, with a chat command, and switch full brightness on and off.
+1 -1
View File
@@ -3,7 +3,7 @@ kind = "native-mod"
name = "Item New"
summary = "Newly picked-up items glow in your hotbar and inventory until you look at them."
description = """
**A native Horizon mod**: the launcher builds it into the game (Settings → Game → Native mods, then Build). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
**A native Horizon mod**: the launcher builds it into the game (install it on the launcher's Mods page, then press Build there). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
Items you just got (picked up, crafted, taken from a chest, given) get a soft glow in the hotbar and in inventory and container screens, until you point at them or close the screen.
+1 -1
View File
@@ -12,7 +12,7 @@ Horizon's own keystrokes overlay, formerly an extra in Options > Horizon Extras.
It reads which keys you hold and your clicks, and draws on the HUD: nothing else.
Now a native mod (2.0.0): built into the game by the launcher, on the Horizon you play, like any native mod
(Settings → Game → Native mods (experimental), then Build). Your settings carry over from 1.0.0. Turn it off in
(install it on the launcher's Mods page, then press Build there). Your settings carry over from 1.0.0. Turn it off in
Options > Mods: it rests at once, and the next build leaves it out. Its source is
[horizon-mods/native/keystrokes](https://github.com/horizon-rust/horizon-mods/tree/main/native/keystrokes).
"""
+1 -1
View File
@@ -11,7 +11,7 @@ The block or entity under your crosshair at the top of the screen, drawn as a va
**Settings** (Options → Mods → LookAt): range, position (top, top left, top right), size, background opacity, item icon, where it comes from, position and distance, entities, health, harvest tool. Boxes keep clear of F3, boss bars, the minimap and effect icons.
**Now a native mod (0.2.1)**: the launcher builds it into the game (Settings → Game → Native mods (experimental), then Build). Native mods are not sandboxed and run with your account's permissions, so read the source before you build it. Tested against Horizon `142388e1c136e622652ec7505af15d8ad195e041` with Horizon Mixins `0d6b103c70633d8fd9443d8d0acd3aadaacedb06`; the launcher builds these exact pinned revisions. Settings from 0.1.0 carry over."""
**Now a native mod (0.2.1)**: the launcher builds it into the game (install it on the launcher's Mods page, then press Build there). Native mods are not sandboxed and run with your account's permissions, so read the source before you build it. Tested against Horizon `142388e1c136e622652ec7505af15d8ad195e041` with Horizon Mixins `0d6b103c70633d8fd9443d8d0acd3aadaacedb06`; the launcher builds these exact pinned revisions. Settings from 0.1.0 carry over."""
authors = ["d3grando"]
owners = ["d3grando"]
license = "MIT"
+38
View File
@@ -0,0 +1,38 @@
id = "menu_panorama"
kind = "resourcepack"
name = "Menu Panorama"
summary = "Put your own wallpaper behind the main menu in Horizon — Minecraft 26.3, rewritten in Rust."
description = """
Menu Panorama is a resource pack for **Horizon** — Minecraft: Java Edition 26.3, rewritten from scratch in **Rust**.
It replaces the six textures of the main-menu background with a picture you choose. It is a plain resource pack, not a Java mod: no mod loader, no Java, and no code from the pack is executed. Horizon loads it natively.
- **Any photo** in the `room` mode: the picture is shown whole on the four walls, with mirrored seams and soft colours above and below.
- **A 360° equirectangular panorama** in the `360` mode: the sphere is cut into six cube faces, so the view stays seamless when the camera turns.
- **Auto** picks the mode from the image's aspect ratio.
Build a pack in the browser with `panorama-maker.html`: drop an image, choose a face size, download the `.zip`. Everything runs locally; the picture never leaves the computer. `make_panorama.py` does the same from the command line.
The pack only changes `assets/minecraft/textures/gui/title/background/panorama_0..5.png`; gameplay, sounds and other textures are untouched. `pack.mcmeta` is made for Horizon 26.3 (resource format 97) and also declares formats 15–999, so the same file loads in Minecraft Java 1.21+. A demo pack is attached to every release.
MIT licensed. Source, the browser generator and the CLI are in the repository."""
authors = ["form1t"]
owners = ["form1t"]
license = "MIT"
source = "https://github.com/form1t/menu-panorama"
icon = "icons/menu_panorama.png"
[[versions]]
version = "1.0.0"
published = "2026-10-31"
game = "26.3"
file = "menu_panorama-1.0.0.zip"
sha256 = "e0403b003e6c93dec683037202cebb47880025cfe21e2d8fc7818321ee362401"
changelog = """
First release.
- Demo sunset pack: six 1024×1024 faces and a 128×128 icon.
- `panorama-maker.html`: browser generator with drag & drop, auto/room/360 modes and 512/1024/2048 face sizes.
- `make_panorama.py`: CLI with the same modes, preview output and `--description`.
- `pack.mcmeta` for Horizon 26.3: resource format 97, `pack_format` 34, formats 15–999.
- MIT licence."""
+1 -1
View File
@@ -12,7 +12,7 @@ Horizon's own minimap, formerly an extra in Options > Horizon Extras.
It shows a map in a corner of the screen: nothing else.
Now a native mod (2.0.0): built into the game by the launcher, on the Horizon you play, like any native mod
(Settings → Game → Native mods (experimental), then Build). Your settings carry over from 1.0.0. Turn it off in
(install it on the launcher's Mods page, then press Build there). Your settings carry over from 1.0.0. Turn it off in
Options > Mods: it rests at once, and the next build leaves it out. Its source is
[horizon-mods/native/minimap](https://github.com/horizon-rust/horizon-mods/tree/main/native/minimap).
"""
+1 -1
View File
@@ -3,7 +3,7 @@ kind = "native-mod"
name = "Mouse Tricks"
summary = "Inventory mouse tricks: drag to spread or gather, the wheel moves items one by one, and R sorts."
description = """
**A native Horizon mod**: the launcher builds it into the game (Settings → Game → Native mods, then Build). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
**A native Horizon mod**: the launcher builds it into the game (install it on the launcher's Mods page, then press Build there). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
- **Right-drag**: one item into each slot you pass.
- **Left-drag**: gather the same items; with Shift, quick-move many stacks.
+1 -1
View File
@@ -15,7 +15,7 @@ Horizon's own recipe viewer.
It reads the game's items and recipes, hears its own keys and your clicks on the panel, and places recipes or gives items only when you ask: nothing else.
Now a native mod (2.0.0): built into the game by the launcher, on the Horizon you play, like any native mod
(Settings → Game → Native mods (experimental), then Build). It needs Horizon's native hook points version 2, in the
(install it on the launcher's Mods page, then press Build there). It needs Horizon's native hook points version 2, in the
Nightlies from 4baa571 on. Your settings and keys carry over from 1.0.0. Turn it off in Options > Mods: it rests at
once, and the next build leaves it out. Its source is
[horizon-mods/native/recipes](https://github.com/horizon-rust/horizon-mods/tree/main/native/recipes).
+1 -1
View File
@@ -33,7 +33,7 @@ Horizon cannot join servers that check Minecraft accounts yet, which is most pub
## Install
Install it from the launcher's Mods page, then Settings > Native mods (experimental) > Build, and play the Native mods instance. Guide: https://github.com/Louchatfroff/serverify/tree/main/horizon/serverify#readme
Install it from the launcher's Mods page, then press Build there, and play that instance. Guide: https://github.com/Louchatfroff/serverify/tree/main/horizon/serverify#readme
Native mods are not sandboxed. The source is public and licensed GPL-3.0-only: https://github.com/Louchatfroff/serverify/tree/main/horizon/serverify"""
authors = ["Louchatfroff"]
+1 -1
View File
@@ -3,7 +3,7 @@ kind = "native-mod"
name = "SmartF3"
summary = "A tidier, configurable F3 debug screen: the same information in modules you can colour, reorder and turn off."
description = """
**A native Horizon mod**: the launcher builds it into the game (Settings → Game → Native mods, then Build). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
**A native Horizon mod**: the launcher builds it into the game (install it on the launcher's Mods page, then press Build there). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
The F3 debug screen, grouped into 14 modules: FPS, server, coordinates, chunk, facing, world, biome, light, chart keys, system, rendering, and the targeted block, fluid and entity.
+1 -1
View File
@@ -7,7 +7,7 @@ An unofficial Rust port by levzzz5154. Mines connected veins, including diagonal
through Horizon's player-mining code, preserving loot, XP, tool wear and server block events.
Includes per-player toggles, configurable block/tool groups, a block limit and cooldown.
Native source mod: enable Settings → Game → Native mods (experimental), choose Build,
Native source mod: install it on the launcher's Mods page, press Build there,
and confirm the native-code warning. A supported launcher downloads its own Rust toolchain
and builds a separate game using Horizon Mixins. Requires Git and Horizon's platform build
dependencies, and runs with your account's permissions.
+1 -1
View File
@@ -13,7 +13,7 @@ Horizon's own zoom, formerly an extra in Options > Horizon Extras.
It hears its own key and the mouse wheel while you hold it, and zooms the camera: nothing else.
Now a native mod (2.0.0): built into the game by the launcher, on the Horizon you play, like any native mod
(Settings → Game → Native mods (experimental), then Build). Your settings carry over from 1.0.0. Turn it off in
(install it on the launcher's Mods page, then press Build there). Your settings carry over from 1.0.0. Turn it off in
Options > Mods: it rests at once, and the next build leaves it out. Its source is
[horizon-mods/native/zoom](https://github.com/horizon-rust/horizon-mods/tree/main/native/zoom).
"""
+2 -1
View File
@@ -11,7 +11,8 @@ The time of day line is gone: Horizon's hook points don't give the game's clock
## Install
In the Horizon launcher: Mods → Coordinates HUD → Install, then Settings → Game → Native mods (experimental) → Build. The
In the Horizon launcher: Mods → Coordinates HUD → Install, then **Build** on the same page (once built, switch it
on or off in the game under Options → Mods). The
launcher downloads its own Rust and builds the game with your native mods; see Horizon's
[native mods](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md).
+2 -1
View File
@@ -7,7 +7,8 @@ launcher builds into the game. It keeps the settings of earlier versions: the sa
## Install
In the Horizon launcher: Mods → FPS Counter → Install, then Settings → Game → Native mods (experimental) → Build. The
In the Horizon launcher: Mods → FPS Counter → Install, then **Build** on the same page (once built, switch it
on or off in the game under Options → Mods). The
launcher downloads its own Rust and builds the game with your native mods; see Horizon's
[native mods](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md).
+2 -1
View File
@@ -7,7 +7,8 @@ launcher builds into the game. It keeps the settings of earlier versions: the sa
## Install
In the Horizon launcher: Mods → Keystrokes → Install, then Settings → Game → Native mods (experimental) → Build. The
In the Horizon launcher: Mods → Keystrokes → Install, then **Build** on the same page (once built, switch it
on or off in the game under Options → Mods). The
launcher downloads its own Rust and builds the game with your native mods; see Horizon's
[native mods](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md).
+2 -1
View File
@@ -7,7 +7,8 @@ launcher builds into the game. It keeps the settings of earlier versions: the sa
## Install
In the Horizon launcher: Mods → Minimap → Install, then Settings → Game → Native mods (experimental) → Build. The
In the Horizon launcher: Mods → Minimap → Install, then **Build** on the same page (once built, switch it
on or off in the game under Options → Mods). The
launcher downloads its own Rust and builds the game with your native mods; see Horizon's
[native mods](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md).
+2 -1
View File
@@ -13,7 +13,8 @@ launcher builds into the game. It keeps the settings of earlier versions: the sa
## Install
In the Horizon launcher: Mods → Recipes → Install, then Settings → Game → Native mods (experimental) → Build. The
In the Horizon launcher: Mods → Recipes → Install, then **Build** on the same page (once built, switch it
on or off in the game under Options → Mods). The
launcher downloads its own Rust and builds the game with your native mods; see Horizon's
[native mods](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md).
+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "horizon-mod-smp"
version = "0.12.0"
version = "0.29.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "horizon-mod-smp"
version = "0.12.0"
version = "0.29.0"
edition = "2024"
license = "MIT"
publish = false
+247 -33
View File
@@ -8,8 +8,8 @@ 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, 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 of the richest players, the Tab list's
This version: the economy with its Treasury and taxes, the auction house, the bank, the server market and its `/shop` menu, player trades, the coinflip, the lottery, places (spawn, homes, warps,
teleport requests, the rules), land claims, and the scoreboard (a sidebar with your balance, playtime and the richest players, the Tab list's
header and footer).
| Command | Does |
@@ -17,10 +17,22 @@ 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 give\|take\|set <player> <amount>`, `/money ...` | operators of level 3: corrections, logged (`/money` is the same command); the player given money is told |
| `/eco give * <amount>` | operators of level 3: to every player on, one logged change each |
| `/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 |
| `/ah` | the auction house: a chest menu of listings by category, cheapest a unit first; click one to buy it or bid; your listings (click to take one back) and your mail |
| `/ah sell <price> [count]` | lists the stack in your hand (or part of it) at a fixed price, for `listing_hours` (48); the listing fee (1%, at least 1 coin) goes to the Treasury |
| `/ah auction <start> [hours]` | auctions the stack in your hand from a starting bid, for 1 to 24 hours (12) |
| `/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" |
| `/worth [item]` | what the market pays and asks for an item (the one in your hand without a name), and what you may still sell today |
| `/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 |
| `/sell hand [count]`, `/sell <item> [count]`, `/sell all` | sells to the market: the stack in your hand, an item, or everything it buys |
| `/buy <item> [count]` | buys from the market, only what fits in your inventory |
| `/trade <player>` | asks a player to trade (60 s to answer) |
@@ -29,6 +41,8 @@ header and footer).
| `/trade cancel` | ends your trade, or withdraws your request |
| `/coinflip <amount> [player]` | stakes an amount for anyone (or one player) to match; off unless `gambling.enabled` |
| `/lottery`, `/lottery buy <tickets>` | the pot, your tickets and the time to the draw; buys tickets (off unless `gambling.enabled`) |
| `/casino`, `/casino roulette\|slots\|crash\|blackjack` | the casino's lobby menu, or a game's table (needs `gambling.enabled` and `casino.enabled`) |
| `/casino help`, `/casino top`, `/casino stats` | every game's odds and house edge and the limits; today's biggest wins; what you bet, won and may still lose today |
| `/coinflip accept <player>`, `/coinflip cancel`, `/coinflip list` | takes an offer (a fair coin decides, the winner gets both stakes), withdraws yours, lists those waiting |
| `/spawn` | to the server's spawn; `/setspawn` (operators of level 3) sets it where you stand |
| `/home [name]`, `/sethome [name]`, `/delhome [name]`, `/homes` | your homes (`home` when unnamed): go, set or move one where you stand, delete, list |
@@ -42,42 +56,112 @@ header and footer).
| `/claim show` | draws the border of the chunk you stand in for 10 s, for you only: green yours or a trusting owner's, red someone else's, white free |
| `/claims` | your chunks, how many you may hold, and who you trust |
| `/trust <player>`, `/untrust <player>` | lets a player (or no longer) build and use things on all your land |
| `/sidebar off`, `/sidebar on`, `/sidebar` | hides the richest players' sidebar for you (remembered), shows it again |
| `/nick <name>`, `/nick off`, `/nick` | operators of level 3 (with `ops_only`, the default): another name for you in chat and the Tab list, shown as `~name` (hovering it gives your real one); `/nick off` (anyone with a nickname) takes it off; `/nick` alone says how |
| `/nick <player> <name\|off>` | operators of level 3: sets or takes off a player's nickname |
| `/realname <nick>` | who goes by that nickname |
| `/invsee <player>`, `/endersee <player>` | operators of level 3: the player's inventory (main, hotbar, armour, off hand) or ender chest in a chest menu, on or off the server; click one of theirs to take it, one of yours to give it; every move in `audit.log`, a summary in `staff.log` |
| `/sidebar off`, `/sidebar on`, `/sidebar` | hides the scoreboard sidebar for you (remembered), shows it again |
The market trades only plain items (not worn, named or enchanted), and only with players in survival or adventure
mode. Items move first, then money, both logged; a sale the log refuses gives the items back, and a purchase checks the
money and the room before anything moves.
**No way to get rich from the market.** The market makes money only from what players gather, and only so much a day:
- Buying always costs more than selling: an item whose `sell` is not below its `buy` isn't bought (the server log
says so at the start).
- 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
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,
and an item the market would buy for more than that isn't bought. The tests walk every vanilla recipe
(`SMP_VANILLA_DATA=<.local/horizon-client/26.3/data/minecraft> cargo test`); the server checks the iron, gold,
copper, diamond, emerald, coal, redstone, lapis, wheat, wood and food chains at each start.
- Items a recipe links (an ingot and its block, wheat and hay...) share one moving price, the block moving it as
its 9 ingots do, so crafting never beats the market however prices move; the vanilla-recipe test checks every item
at the worst drift (what makes it at 0.6 times its price, it at 1.6) too.
- Each player sells at most `daily_sell_coins` (2,000 coins) and `daily_sell_items` (640 of each item) a UTC day,
`/worth` and the shop show what is left, and `market.json` keeps the day's count across restarts.
- Money received with `/pay` counts toward the receiver's same `daily_sell_coins`: selling up to the limit on alts
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.
Default prices (coins, buy / sell; `-` not traded that way):
Default prices (coins; "buy only": the market sells it but doesn't buy it, "sell only" the other way):
| Category | Items |
|---|---|
| Farming | wheat 3/1, wheat seeds 2/-, carrot 3/1, potato 3/1, beetroot 3/1, sugar cane 3/1, pumpkin 8/3, melon slice 2/1, cactus 3/1, bone meal 3/- |
| Mining | coal 8/2, copper ingot 6/2, iron ingot 30/8, gold ingot 40/10, redstone 4/1, lapis 8/2, quartz 8/2, emerald 80/20, diamond 400/100 |
| Wood | the eight logs 4/1 |
| Mob drops | rotten flesh -/1, bone 3/1, string 3/1, gunpowder 8/2, spider eye 4/1, slime ball 10/2, blaze rod 30/8, leather 6/2, feather 2/1 |
| Building | cobblestone 1/-, stone 2/-, dirt 1/-, sand 2/-, gravel 2/-, glass 3/-, bricks 4/-, stone bricks 3/- |
| Food | bread 5/-, cooked beef 6/-, cooked porkchop 6/-, cooked chicken 5/-, baked potato 4/-, apple 4/-, golden carrot 40/- |
| Tools | torch 1/-, ladder 2/-, crafting table 4/-, chest 6/-, furnace 6/- |
| Farming | wheat 2, carrot 2, potato 2, beetroot 2, sugar cane 2, pumpkin 5, melon slice 1, cactus 2; buy only: wheat seeds 2, bone meal 3 |
| Mining | coal 4, copper ingot 3, iron ingot 15, gold ingot 20, redstone 2, lapis 4, quartz 4, emerald 40, diamond 200 |
| Wood | the eight logs 2 |
| Mob drops | bone 2, string 2, gunpowder 4, spider eye 2, slime ball 4, blaze rod 15, leather 3, feather 1; sell only: rotten flesh 1 |
| Building (buy only) | cobblestone 1, stone 2, dirt 1, sand 2, gravel 2, glass 3, bricks 4, stone bricks 3 |
| Food (buy only) | bread 5, cooked beef 6, cooked porkchop 6, cooked chicken 5, baked potato 4, apple 4, golden carrot 40 |
| Tools (buy only) | torch 1, ladder 2, crafting table 4, chest 6, furnace 6 |
A `config.json` still holding the 18 prices of versions 0.1 to 0.11, never edited, gets these at the first start of
0.12.0; edited prices are kept. Only items that stack to 64 belong in the market: a shift-click buys 64, and what
doesn't fit falls at the player's feet for them.
**/invsee and /endersee.** For operators of level 3 (luzW, bunchy7s...). The menu shows the main inventory in its
first three rows, the hotbar in the fourth, then the armour (head to feet) and the off hand; the ender chest fills
three rows. A click on one of the player's stacks moves it whole into the operator's inventory; a click on one of the
operator's own moves it into the player's (main inventory or ender chest, where it fits whole). A stack moves only if
it is still exactly what the menu showed: the game checks it and moves it, or nothing (no copy, no loss). A player who
isn't on is read from and written to their saved file; while they log in or are dead nothing moves and the menu says
why. The menu is read again once a second while open; nothing runs when none is open. Each move is in `audit.log`
before it is made (`invsee_take`, `invsee_put`: operator, player, inventory, item, count; a refused one again with
its reason); closing the menu writes one line to the server log (`[smp] staff: ...`), `staff.log`, and the chat of
the other operators on. Needs horizon#6106 (`native_inventory`, `native_move_stack`).
**Nicknames.** With `ops_only` (the default since 0.22) only operators of level 3 set nicknames: their own, or a
player's with `/nick <player> <name>`; a player may take off the one given to them (`/nick off`). At each start, a
nickname a player who isn't an operator picked themselves is taken off and logged (`for operators only`). 3 to 16 letters, digits or `_`, shown with a `~` in front (`~Rocky`) so a nickname never passes for
a real name. Names are compared case-insensitively with `_` dropped and look-alikes folded (`0`→o, `1`/`i`→l, `3`→e,
`4`→a, `5`→s, `7`→t, `8`→b, `9`→g). Refused: a nickname equal to, or containing (from 4 letters), the name of anyone
who has played here (the economy's accounts and `usercache.json`), an operator (`ops.json`), the Team (`protected`)
or another player's nickname; one that reads as a reserved word (`reserved`: server, console, admin, mod, owner,
staff, op, horizon, kira, notch, mojang, herobrine...); one the server's chat filter refuses. A player needs
`min_playtime_minutes` of play before their first nickname (operators don't). At each start, nicknames that break
these rules are taken off, each logged (`[smp] nickname X of <uuid> taken off: why`). The change is made at the next
second. The nickname shows in chat (messages, `/me`, `/msg`) and the Tab list, and hovering it gives the real name;
the real name stays everywhere else: the server's log, the anticheat, selectors, the Discord bridge. `/pay`, `/tpa`,
`/trade`, `/playtime`, `/tpaccept`, `/tpdeny` and `/eco` take a nickname (with or without `~`) wherever they take a
player's name. The player's profile (name, UUID) never changes.
`config.json`'s `nicks`: `enabled`, `ops_only` (true), `cooldown_seconds` (600), `min_playtime_minutes` (60), `protected` (the Team's
names), `reserved` (the words above).
**Moving prices** (`market.dynamic`, on by default since 0.26). Each item has one price: its base price (`items`)
times a factor that supply and demand move as each trade happens. Every item bought raises the log of the factor by
`1% / depth`, every item sold lowers it as much, where the depth follows the item's rarity (400 / its price, between
2 and 400: a diamond moves 50 times faster per item than cobblestone; `depth` overrides it per item). A trade of N is
worth the area under that curve (`price / step × (e^(N × step) - 1)`), rounded up to whole coins when buying and down
when selling: one diamond at 200 costs 201 and sells for 199, 64 cobblestone at 1 cost 65, and a sale walks back the
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
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)`.
**`/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,
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.
**The shop.** `/shop` opens a 6-row chest menu on the market's prices: the categories of `config.json`'s `shop` (an
item in none goes under "Other"), each item named with its prices. A click buys one, a shift-click 64; a click on a
stack in your own inventory, below the menu, sells it; "Sell all" sells everything the market buys. The bottom row
@@ -106,12 +190,41 @@ from a round's first ticket to its draw). Tickets are held like coinflip stakes;
random among all sold (more tickets, more chances), its owner gets the pot (less `fee_percent`), 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
stock client can play, animated by changing its items at most 5 times a second for each open menu (and only while a
round moves), with note-block sounds, titles and the action bar for results, fireworks and a line for everyone on a
jackpot:
- European roulette: one zero, red/black, odd/even, 1-18/19-36 pay 1 to 1, dozens and columns 2 to 1, a number 35 to
1; house edge 2.70% on every bet. The wheel turns and slows down on the drawn pocket.
- Slots: 3 reels of 32 stops with item symbols, one payline, a published paytable (three Nether Stars 500x down to
three Berries 6x, two Berries 2x); 94.2% paid back (house edge 5.8%), one pull in 4.5 pays.
- Crash: one shared round; bet in the 10 s before take-off, then cash out (or set an automatic cash-out) before it
crashes. P(crash at x or more) = 96% / x, capped at 100x: 96% paid back whatever you do. Each player in the round
sees the multiplier in their own boss bar and the action bar.
- Blackjack: 6 decks shuffled each hand, the dealer stands on all 17s and peeks, blackjack pays 3 to 2, double on the
first two cards, no split; about 1% house edge with basic strategy.
Every draw is made by the server (ChaCha20 keyed from the system) before the animation starts. Bets go from `min_bet`
10 to `max_bet` 1000; a player may lose at most `daily_loss_cap` (10,000) a day (UTC, all games together) and win at most
`daily_win_cap` (10,000) net (once reached, no new bet until 00:00 UTC; the round that crosses it is paid in full), and
`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
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
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.
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.
**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
@@ -125,22 +238,114 @@ and players aren't in it). Not guarded yet: right clicking an entity (rotating a
items, shearing, leads), which waits for Horizon's hook (#4648). The server's `spawn-protection` (vanilla's) guards
the spawn.
**The scoreboard.** A sidebar on the right lists the `board.top` (10) richest players with their balance (`125.4k`,
`3.2M`), then a gap and your own rank and balance. It's rebuilt only when a balance changed, at
most every `board.refresh_seconds` (60), and sent only to the players whose lines changed: nothing runs per tick per
player. The Tab list shows `board.tab_header` and `board.tab_footer` (`{server}`, `{tps}`, `{players}` filled in,
every `board.tab_refresh_seconds`, 5). `board.sidebar` and `board.tab` turn either off; `board.title` and
`board.server_name` name them. The sidebar uses one scoreboard objective of its own (`horizon:native_sidebar`) and
leaves the world's scoreboard alone.
**The scoreboard.** A sidebar on the right, titled `board.title` ("Horizon SMP"): your own block first (your name, or
your nickname; your balance, `125.4k`, `3.2M`; your playtime, `2h 05m`; the players online), then the `board.top` (3)
richest players with their balance, then `board.footer` (`play.playhorizon.dev`, empty for none). The ranking is
built once for everyone, only when a balance changed (at most every `board.min_seconds`, 5) or every
`board.refresh_seconds` (60); each player's own lines are looked at on the same `board.refresh_seconds`, and a
sidebar is sent only to the players whose lines changed and to newcomers: nothing runs per tick per player. The Tab
list shows `board.tab_header` and `board.tab_footer` (`{server}`, `{tps}`, `{players}` filled in, every
`board.tab_refresh_seconds`, 5). `board.sidebar` and `board.tab` turn either off; `board.server_name` names the Tab
list. A server that kept 0.13's defaults (`Richest players`, the top 10) gets these at its first start of 0.14. The
sidebar uses one scoreboard objective of its own (`horizon:native_sidebar`) and leaves the world's scoreboard alone.
**Presence.** A player's first join is announced to everyone (`presence.first_join`, empty for none) and they read
`presence.welcome` and the rules. Each player gets `presence.daily_reward` (50) once a day (UTC) for being on, logged
as `daily_reward` (0 turns it off). A player who hasn't moved, turned or run a command for `presence.afk_minutes` (5),
`presence.welcome` and the rules. Each player gets `presence.daily_reward` (50) once a day (UTC) after
`presence.reward_after_minutes` (10) of play that day, logged as `daily_reward` (0 turns it off). Only seconds with a
move, a turn or a command in the minute before count, so an alt that logs in and stands still gets nothing (on
2026-11-01, 1,242 players got the reward; 323 of them had played 10 minutes in all). The count isn't kept over a
restart. A player who hasn't moved, turned or run a command for `presence.afk_minutes` (5),
or typed `/afk`, is AFK: they don't count for sleeping (vanilla's `players_sleeping_percentage`) until they move or
get in a bed; `presence.afk_announce` tells everyone instead of only them. A player runs at most
`presence.spam_commands` (8) of the SMP's commands in `presence.spam_seconds` (5); operators of level 3 aren't
limited. All of it is looked at once a second.
**The auction house.** Players sell to players: a listing is a fixed price or a timed auction. The listed items
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
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
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),
`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.
**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
with money, the richest ten's share). At 00:00 UTC everyone reads the day before in one line (`report.daily_report`).
A snapshot of each day is kept 60 days (`report.history_days`) in `economy.json` (`ext.report`). Once a minute
`world/horizon-smp/web/economy.json` (`report.web_json`) is written off the tick thread for the site's /hub:
`{"schema": 1, "generated": <ms>, "currency", "today", "pools", "minted_today", "burned_today", "treasury": {balance,
insurance, income, spent, income_today, spent_today, log}, "taxes": {rates, last_collection}, "days": [...],
"daily_report": "<yesterday's line>"}`. The server's Discord relay posts only chat, joins, leaves and deaths: the
daily line reaches Discord through whoever reads that file (Kira).
Other parts reach the pools through `Economy::to_pool`, `from_pool`, `pool_move` and `settle_pool` (a bet against a
pool: the stake goes in, the payout comes out, never more than the pool holds), each one audit line.
The currency is earned in game only: it can't be bought with real money or cashed out (Mojang's usage guidelines).
## Data
@@ -150,14 +355,19 @@ 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`); 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, and `held`: coinflip stakes and lottery tickets being decided |
| `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) |
| `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 |
| `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`, `eco_give`...), `amount`, `from`/`to` with their balance before, `by` for an operator; 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`, `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 |
`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
@@ -169,16 +379,20 @@ It hooks only Horizon's native hook points: `native_register_commands` and `nati
acting player's items (API 9), the server menus and `native_exchange_items` (API 10), the players' health and
`native_run_command` (API 21), `native_destroy_block` (API 14), `native_right_click_block`, the acting player and the
action bar (API 21), `native_block_spread`, `native_piston_move`, `native_explosion_detonate`, `native_fill_bucket` and
`native_attack_entity` (API 22), `native_sidebar`, `native_tab_list` and `native_tick_stats` (API 49), the server tick
and the world lifecycle. `compatibility.json` names `"hooks": 49`.
`native_attack_entity` (API 22), `native_sidebar`, `native_tab_list` and `native_tick_stats` (API 49),
`native_set_nickname` and `native_text_allowed` (API 62), the server tick and the world lifecycle.
`compatibility.json` names `"hooks": 58`.
| File | What it is |
|---|---|
| `mixins/smp/horizon-mixin.toml` | The bundle: thirteen modules and the HEAD hooks |
| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs`, `smp_recipes.rs`, `smp_shop.rs`, `smp_trade.rs`, `smp_games.rs`, `smp_lottery.rs`, `smp_places.rs`, `smp_claims.rs`, `smp_board.rs`, `smp_presence.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_trade`, `game::smp_games`, `game::smp_lottery`, `game::smp_places`, `game::smp_claims`, `game::smp_board`, `game::smp_presence`; also this crate's modules (`src/lib.rs`), for their tests |
| `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/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*`) |
| `mixins/smp/casino_glue.rs` | The casino's effects for a stock client: menu items, sounds, titles, fireworks, boss bars (`game::smp_casino_glue`) |
| `proof.py` | A dedicated server built with the mod and two real clients, on fresh temporary data |
| `proof_casino.py` | The casino's proof (`SMP_PROOF=casino`): both clients play every game through the menus |
## Build and prove
+2 -2
View File
@@ -1,8 +1,8 @@
{
"game": "26.3",
"hooks": 49,
"hooks": 62,
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "5272f3ad64bc68e1f80068a201f38d442ea01fbc",
"horizon_revision": "975c07d9151847e6af13afc4b5ab37b6bbe78ef5",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
}
+333
View File
@@ -0,0 +1,333 @@
// The casino in the server's game: its menus, and what its effects become
// for a stock client (smp_casino's `Fx`): menu items, sounds and titles
// through the console's `playsound` and `title`, the action bar, fireworks
// and particles over a winner, a line for everyone. glue.rs calls it from the
// command, menu and tick hooks with the SMP's state already locked; the
// console commands it builds are run by glue.rs once that lock is let go.
// Added to the game as `game::smp_casino_glue`.
use crate::game::{
Game,
native_server::NativeServerPlayer,
smp_casino::{self, Casino, Color, Fx, Icon},
smp_economy::{Economy, Out, Player},
};
use horizon_client::{
items::ItemStack,
lan::data::component_nbt,
text::{self, Text},
};
/// The crash's boss bars: one a player (a bar's players can't be a list of
/// names, and tags don't reach a server's players), `smp:casino_crash_<i>`
/// for the name in `bars[i]`, and the colour they all have.
static BARS: std::sync::Mutex<(Vec<Option<String>>, &str)> =
std::sync::Mutex::new((Vec::new(), "yellow"));
/// The most bars at once; each taken off at the first hide of a run,
/// whatever it held (a run before may have left one).
const MAX_BARS: usize = 32;
static CLEARED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn bar_id(i: usize) -> String {
format!("smp:casino_crash_{i}")
}
fn color(c: Color) -> u32 {
match c {
Color::White => text::WHITE,
Color::Gray => text::GRAY,
Color::DarkGray => text::DARK_GRAY,
Color::Gold => text::GOLD,
Color::Yellow => text::YELLOW,
Color::Green => text::GREEN,
Color::Red => text::RED,
Color::Aqua => text::AQUA,
}
}
fn stack(icon: Icon) -> ItemStack {
let mut stack = ItemStack {
item: icon.item.into(),
count: u32::from(icon.count.clamp(1, 99)),
damage: 0,
extra: None,
};
let title =
serde_json::json!({ "text": icon.name, "italic": false, "color": icon.color.name() });
stack.data_mut().custom_name = Some(component_nbt(&title));
stack
}
fn items(casino: &Casino, economy: &Economy, uuid: u128, now: i64) -> Vec<Option<ItemStack>> {
casino
.layout(economy, uuid, now)
.into_iter()
.map(|c| c.map(stack))
.collect()
}
/// Opens (again) the player's casino menu on its view: a new window, titled
/// for the game. False when it couldn't open.
fn open(game: &mut Game<'_>, casino: &mut Casino, economy: &Economy, uuid: u128, now: i64) -> bool {
let Some(view) = casino.seats.get(&uuid).map(|s| s.view) else {
return false;
};
let items = items(casino, economy, uuid, now);
let title = Text::literal(view.title()).with_color(text::DARK_GRAY);
match game.native_open_menu(uuid, smp_casino::ROWS, title, items) {
Some(w) => {
if let Some(s) = casino.seats.get_mut(&uuid) {
s.window = Some(w);
}
true
}
None => {
casino.seats.remove(&uuid);
false
}
}
}
/// `/casino ...` (inside the player's command).
pub(in crate::game) fn command(
game: &mut Game<'_>,
casino: &mut Casino,
economy: &mut Economy,
me: &Player,
now: i64,
line: &str,
) -> Vec<Out> {
let Some((mut out, view)) = casino.command(economy, me, now, line) else {
return Vec::new();
};
if let Some(view) = view {
casino.sit(me.uuid, &me.name, view);
if !open(game, casino, economy, me.uuid, now) {
out.push(Out::Error("The casino couldn't open.".into()));
}
}
out
}
/// A click in a player's casino menu: the console commands its effects need,
/// for after the SMP's lock.
pub(in crate::game) fn clicked(
game: &mut Game<'_>,
casino: &mut Casino,
economy: &mut Economy,
uuid: u128,
slot: i32,
shift: bool,
) -> Vec<String> {
let Ok(slot) = usize::try_from(slot) else {
return Vec::new();
};
if slot >= smp_casino::SIZE {
return Vec::new();
}
let now = chrono::Utc::now().timestamp_millis();
economy.now = now;
let fx = casino.click(economy, uuid, slot, shift, now);
apply(game, casino, economy, fx, now)
}
/// 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.
pub(in crate::game) fn tick(
game: &mut Game<'_>,
casino: &mut Casino,
economy: &mut Economy,
now: i64,
online: Option<&[u128]>,
) -> Vec<String> {
let mut fx = casino.tick(economy, now);
// Once a second: crash bets of players who left.
if let Some(online) = online {
fx.extend(casino.gone(economy, online, now));
}
if fx.is_empty() {
return Vec::new();
}
apply(game, casino, economy, fx, now)
}
/// Carries out `fx`: menus and lines now, console commands returned.
fn apply(
game: &mut Game<'_>,
casino: &mut Casino,
economy: &Economy,
fx: Vec<Fx>,
now: i64,
) -> Vec<String> {
let mut commands = Vec::new();
let mut players: Option<Vec<NativeServerPlayer>> = None;
let mut shown = Vec::new();
for f in fx {
// Where a player is, asked once for the whole batch.
let mut find = |uuid: u128, game: &Game<'_>| -> Option<NativeServerPlayer> {
players
.get_or_insert_with(|| game.native_server_players())
.iter()
.find(|p| p.uuid == uuid)
.cloned()
};
match f {
Fx::Out(Out::Tell(uuid, s)) => {
game.native_tell(uuid, Text::literal(s).with_color(text::GOLD));
}
Fx::Out(Out::All(s)) => game.native_broadcast(Text::literal(s).with_color(text::GOLD)),
Fx::Out(Out::Reply(s) | Out::Error(s)) => log::info!("[smp] casino: {s}"),
Fx::Sound {
to,
sound,
volume,
pitch,
} => {
if let Some(p) = find(to, game) {
let [x, y, z] = p.position;
commands.push(format!(
"execute in {} run playsound {sound} master {} {x:.2} {y:.2} {z:.2} {volume} {pitch}",
p.dimension, p.name
));
}
}
Fx::Title {
to,
title,
subtitle,
color: c,
} => {
if let Some(p) = find(to, game) {
let line = |s: &str, bold: bool| {
serde_json::json!({ "text": s, "color": c.name(), "bold": bold })
.to_string()
};
commands.push(format!("title {} times 5 40 15", p.name));
commands.push(format!(
"title {} subtitle {}",
p.name,
line(&subtitle, false)
));
commands.push(format!("title {} title {}", p.name, line(&title, true)));
}
}
Fx::Bar {
to,
text: s,
color: c,
} => {
game.native_action_bar(to, Text::literal(s).with_color(color(c)));
}
Fx::Fireworks(to) => {
if let Some(p) = find(to, game) {
let [x, y, z] = p.position;
// High enough over the player that their blast (5 blocks) never hurts.
for (dx, dz, colours) in [
(-3.0, 0.0, "16766720,16733525"),
(3.0, 1.5, "5635925,16777045"),
(0.0, -3.0, "5636095,16733695"),
] {
commands.push(format!(
"execute in {} run summon minecraft:firework_rocket {:.2} {:.2} {:.2} {{LifeTime:18,FireworksItem:{{id:\"minecraft:firework_rocket\",count:1,components:{{\"minecraft:fireworks\":{{flight_duration:1b,explosions:[{{shape:\"large_ball\",colors:[I;{colours}],has_trail:true,has_twinkle:true}}]}}}}}}}}",
p.dimension,
x + dx,
y + 9.0,
z + dz
));
}
commands.push(format!(
"execute in {} run particle minecraft:totem_of_undying {x:.2} {:.2} {z:.2} 0.6 1 0.6 0.4 60 normal",
p.dimension,
y + 1.0
));
}
}
Fx::Announce(s) => {
log::info!("[smp] casino: {s}");
game.native_broadcast(
Text::literal(format!("[Casino] {s}")).with_color(text::GOLD),
);
}
Fx::Show(uuid) => {
// One picture per menu per batch.
if !shown.contains(&uuid)
&& casino.seats.get(&uuid).is_some_and(|s| s.window.is_some())
{
shown.push(uuid);
let items = items(casino, economy, uuid, now);
game.native_set_menu_items(uuid, items);
}
}
Fx::Open(uuid) => {
shown.push(uuid);
open(game, casino, economy, uuid, now);
}
Fx::Boss {
text: line,
color: c,
value,
} => {
let mut bars = BARS.lock().unwrap_or_else(|e| e.into_inner());
if let Some(c) = c {
bars.1 = match c {
Color::Green => "green",
Color::Red => "red",
_ => "yellow",
};
}
let name = serde_json::json!({ "text": line, "color": "white" });
for (i, _) in bars.0.iter().enumerate().filter(|(_, b)| b.is_some()) {
let id = bar_id(i);
commands.push(format!("bossbar set {id} name {name}"));
commands.push(format!("bossbar set {id} value {value}"));
if c.is_some() {
commands.push(format!("bossbar set {id} color {}", bars.1));
}
}
}
Fx::BossPlayers(names) => {
let mut bars = BARS.lock().unwrap_or_else(|e| e.into_inner());
let colour = bars.1;
for (i, bar) in bars.0.iter_mut().enumerate() {
if bar.as_ref().is_some_and(|n| !names.contains(n)) {
*bar = None;
commands.push(format!("bossbar remove {}", bar_id(i)));
}
}
for name in names {
if bars.0.iter().any(|b| b.as_ref() == Some(&name)) {
continue;
}
let i = match bars.0.iter().position(Option::is_none) {
Some(i) => i,
None if bars.0.len() < MAX_BARS => {
bars.0.push(None);
bars.0.len() - 1
}
None => continue,
};
let id = bar_id(i);
commands.push(format!("bossbar add {id} \"\""));
commands.push(format!("bossbar set {id} max 100"));
commands.push(format!("bossbar set {id} color {colour}"));
commands.push(format!("bossbar set {id} players {name}"));
bars.0[i] = Some(name);
}
}
Fx::BossHide => {
// Every bar taken off.
let mut bars = BARS.lock().unwrap_or_else(|e| e.into_inner());
let all = !CLEARED.swap(true, std::sync::atomic::Ordering::Relaxed);
let n = if all { MAX_BARS } else { bars.0.len() };
for i in 0..n {
if all || bars.0[i].is_some() {
commands.push(format!("bossbar remove {}", bar_id(i)));
}
}
bars.0.clear();
bars.1 = "yellow";
}
}
}
commands
}
+3
View File
@@ -5,6 +5,9 @@ fn smp_perform_command(
command: &str,
ran: &mut bool,
) {
if !*ran {
crate::game::smp_invsee_glue::perform(game, player, command, ran);
}
if !*ran {
crate::game::smp_glue::perform(game, player, command, ran);
}
File diff suppressed because it is too large. Load diff
+112 -4
View File
@@ -1,12 +1,12 @@
[mod]
id = "smp"
version = "0.12.0"
version = "0.29.0"
priority = 100
# The commit of horizon main it was tested on. It uses only the native hook points (hooks 49 in
# compatibility.json): it builds on any Horizon that has them.
# The commit of horizon it was tested on: it needs horizon#6106 (merged at 975c07d): native_inventory,
# native_move_stack, native_send_map, lore on the wire.
[compatibility]
horizon_revision = "5272f3ad64bc68e1f80068a201f38d442ea01fbc"
horizon_revision = "975c07d9151847e6af13afc4b5ab37b6bbe78ef5"
[[module]]
id = "store"
@@ -26,6 +26,24 @@ file = "crates/horizon-client/src/game.rs"
name = "smp_market"
source = "smp_market.rs"
[[module]]
id = "market_prices"
file = "crates/horizon-client/src/game.rs"
name = "smp_market_prices"
source = "smp_market_prices.rs"
[[module]]
id = "market_chart"
file = "crates/horizon-client/src/game.rs"
name = "smp_market_chart"
source = "smp_market_chart.rs"
[[module]]
id = "market_menu"
file = "crates/horizon-client/src/game.rs"
name = "smp_market_menu"
source = "smp_market_menu.rs"
[[module]]
id = "recipes"
file = "crates/horizon-client/src/game.rs"
@@ -38,6 +56,42 @@ file = "crates/horizon-client/src/game.rs"
name = "smp_shop"
source = "smp_shop.rs"
[[module]]
id = "report"
file = "crates/horizon-client/src/game.rs"
name = "smp_report"
source = "smp_report.rs"
[[module]]
id = "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"
@@ -56,6 +110,48 @@ file = "crates/horizon-client/src/game.rs"
name = "smp_lottery"
source = "smp_lottery.rs"
[[module]]
id = "casino"
file = "crates/horizon-client/src/game.rs"
name = "smp_casino"
source = "smp_casino.rs"
[[module]]
id = "casino_roulette"
file = "crates/horizon-client/src/game.rs"
name = "smp_casino_roulette"
source = "smp_casino_roulette.rs"
[[module]]
id = "casino_slots"
file = "crates/horizon-client/src/game.rs"
name = "smp_casino_slots"
source = "smp_casino_slots.rs"
[[module]]
id = "casino_crash"
file = "crates/horizon-client/src/game.rs"
name = "smp_casino_crash"
source = "smp_casino_crash.rs"
[[module]]
id = "casino_blackjack"
file = "crates/horizon-client/src/game.rs"
name = "smp_casino_blackjack"
source = "smp_casino_blackjack.rs"
[[module]]
id = "casino_glue"
file = "crates/horizon-client/src/game.rs"
name = "smp_casino_glue"
source = "casino_glue.rs"
[[module]]
id = "nick"
file = "crates/horizon-client/src/game.rs"
name = "smp_nick"
source = "smp_nick.rs"
[[module]]
id = "places"
file = "crates/horizon-client/src/game.rs"
@@ -80,6 +176,18 @@ file = "crates/horizon-client/src/game.rs"
name = "smp_claims"
source = "smp_claims.rs"
[[module]]
id = "invsee"
file = "crates/horizon-client/src/game.rs"
name = "smp_invsee"
source = "smp_invsee.rs"
[[module]]
id = "invsee_glue"
file = "crates/horizon-client/src/game.rs"
name = "smp_invsee_glue"
source = "invsee_glue.rs"
[[module]]
id = "glue"
file = "crates/horizon-client/src/game.rs"
+314
View File
@@ -0,0 +1,314 @@
// `/invsee` and `/endersee` in the server's game (smp_invsee's rules): the
// menus open, their clicks and closings, the inventories through the native
// helpers (`native_inventory`, `native_move_stack`), each move in the SMP's
// audit log, the staff's summary in the server log, `staff.log` and the
// chat of the other operators on. Its own state, apart from the SMP's: the
// tick costs one atomic load while no menu is open. Added to the game as
// `game::smp_invsee_glue`.
use crate::game::{
Game,
native_inventories::NativeInventoryKind,
native_menus::native_menu_inventory_slot,
native_server::{NativeCommand, NativeServerPlayer},
smp_economy::{Out, Player},
smp_glue,
smp_invsee::{self, Cell, Invsee, Kind, Move},
smp_store,
};
use horizon_client::{
items::ItemStack,
lan::data::component_nbt,
text::{self, Text},
};
use std::sync::{
Mutex,
atomic::{AtomicBool, AtomicU64, Ordering::Relaxed},
};
struct State {
invsee: Invsee<ItemStack>,
/// Asked in a command, opened at the next tick: (operator, target UUID,
/// real name, which).
pending: Vec<(u128, u128, String, Kind)>,
}
static STATE: Mutex<Option<State>> = Mutex::new(None);
/// A menu open or one to open: the tick has work.
static ACTIVE: AtomicBool = AtomicBool::new(false);
static TICKS: AtomicU64 = AtomicU64::new(0);
fn state() -> std::sync::MutexGuard<'static, Option<State>> {
let mut s = STATE.lock().unwrap_or_else(|e| e.into_inner());
s.get_or_insert_with(|| State {
invsee: Invsee::default(),
pending: Vec::new(),
});
s
}
impl smp_invsee::Stack for ItemStack {
fn id(&self) -> String {
self.item.to_string()
}
fn count(&self) -> u32 {
self.count
}
}
fn native(kind: Kind) -> NativeInventoryKind {
match kind {
Kind::Player => NativeInventoryKind::Player,
Kind::Ender => NativeInventoryKind::Ender,
}
}
/// The game's inventories, for the rules.
struct GameWorld<'a, 'g>(&'a mut Game<'g>);
impl smp_invsee::World<ItemStack> for GameWorld<'_, '_> {
fn read(&self, uuid: u128, kind: Kind) -> Result<(Vec<Option<ItemStack>>, bool), String> {
self.0.native_inventory(uuid, native(kind)).map(|i| (i.slots, i.online))
}
fn move_stack(&mut self, from: (u128, Kind, usize), expected: &ItemStack, to: (u128, Kind)) -> Result<(), String> {
self.0.native_move_stack((from.0, native(from.1), from.2), expected, (to.0, native(to.1)))
}
}
pub fn commands(commands: &mut Vec<NativeCommand>) {
for usage in ["invsee <player:word>", "endersee <player:word>"] {
commands.push(NativeCommand::op(usage, smp_invsee::LEVEL));
}
}
/// `/invsee` and `/endersee`; anything else is left to the SMP.
pub(in crate::game) fn perform(game: &mut Game<'_>, player: &NativeServerPlayer, line: &str, ran: &mut bool) {
let word = line.split(' ').next().unwrap_or_default();
if !smp_invsee::COMMANDS.contains(&word) || !game.native_on(smp_glue::ID) {
return;
}
*ran = true;
let me = Player {
uuid: player.uuid,
name: player.name.clone(),
level: player.permission,
};
let find = |name: &str| smp_glue::find_player(game, name);
let mut guard = state();
let s = guard.as_mut().expect("made");
let (out, open) = s.invsee.command(&me, &find, line).unwrap_or_default();
if let Some((target, name, kind)) = open {
s.pending.retain(|p| p.0 != me.uuid);
s.pending.push((me.uuid, target, name, kind));
ACTIVE.store(true, Relaxed);
}
drop(guard);
for o in out {
match o {
Out::Error(e) => game.native_feedback(Text::literal(e).with_color(text::RED)),
Out::Reply(r) => game.native_feedback(Text::literal(r)),
_ => {}
}
}
}
fn named(item: &str, name: &str, color: &str) -> ItemStack {
let mut stack = ItemStack {
item: item.into(),
count: 1,
damage: 0,
extra: None,
};
let title = serde_json::json!({ "text": name, "italic": false, "color": color });
stack.data_mut().custom_name = Some(component_nbt(&title));
stack
}
fn items(invsee: &Invsee<ItemStack>, op: u128) -> Vec<Option<ItemStack>> {
invsee
.layout(op)
.into_iter()
.map(|c| match c {
Cell::Empty => None,
Cell::Stack(s) => Some(s),
Cell::Filler => Some(named("minecraft:gray_stained_glass_pane", " ", "gray")),
Cell::Label(l) => Some(named("minecraft:light_gray_stained_glass_pane", l, "gray")),
Cell::Close => Some(named("minecraft:barrier", "Close", "red")),
Cell::Info { name, online, busy } => {
let line = match &busy {
Some(b) => format!("{name}: {b} (nothing moves now)"),
None if online => format!("{name} (online): click theirs to take, yours to give"),
None => format!("{name} (offline, their saved file): click theirs to take, yours to give"),
};
Some(named("minecraft:player_head", &line, if busy.is_some() { "red" } else { "yellow" }))
}
})
.collect()
}
fn title(name: &str, kind: Kind) -> Text {
Text::literal(match kind {
Kind::Player => format!("{name}'s inventory"),
Kind::Ender => format!("{name}'s ender chest"),
})
}
/// The audit line of a move: written before it is made ("asked"), and
/// again when the game refused it.
fn audit(dir: &std::path::Path, m: &Move, outcome: &str) -> Result<(), String> {
smp_glue::audit(
dir,
serde_json::json!({
"kind": if m.take { "invsee_take" } else { "invsee_put" },
"op": smp_store::uuid_string(m.op),
"target": smp_store::uuid_string(m.target),
"inventory": match m.kind { Kind::Player => "inventory", Kind::Ender => "ender_chest" },
"item": m.item,
"count": m.count,
"outcome": outcome,
}),
)
}
/// The staff's line: the server log, `staff.log`, the other operators on.
fn staff(game: &mut Game<'_>, op: u128, line: &str) {
log::warn!("[smp] staff: {line}");
if let Some(dir) = game.native_world_dir() {
use std::io::Write;
let path = dir.join(smp_store::DIR).join("staff.log");
let stamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
let _ = writeln!(f, "{stamp} {line}");
}
}
for p in game.native_server_players() {
if p.uuid != op && p.permission >= smp_invsee::LEVEL {
game.native_tell(p.uuid, Text::literal(format!("[Staff] {line}")).with_color(text::GRAY));
}
}
}
/// Each tick: one load while nothing is open. Menus to open now; once a
/// second, every open menu read again, and those of operators gone closed.
pub(in crate::game) fn tick(game: &mut Game<'_>) {
if !ACTIVE.load(Relaxed) {
return;
}
let n = TICKS.fetch_add(1, Relaxed);
let mut guard = state();
let s = guard.as_mut().expect("made");
let mut tell = Vec::new();
for (op, target, name, kind) in std::mem::take(&mut s.pending) {
let opened = s.invsee.open(op, target, &name, kind, &GameWorld(game));
match opened {
Ok(()) => {
let window = game.native_open_menu(op, smp_invsee::ROWS, title(&name, kind), items(&s.invsee, op));
match window {
Some(w) => s.invsee.views.get_mut(&op).expect("opened").window = Some(w),
None => {
s.invsee.views.remove(&op);
}
}
}
Err(e) => tell.push((op, format!("Can't open {name}'s inventory: {e}."))),
}
}
let mut summaries = Vec::new();
if n % 20 == 0 {
let on: Vec<(u128, String)> = game.native_server_players().into_iter().map(|p| (p.uuid, p.name)).collect();
let ops: Vec<u128> = s.invsee.views.keys().copied().collect();
for op in ops {
match on.iter().find(|p| p.0 == op) {
None => summaries.extend(s.invsee.close(op, &smp_store::uuid_string(op)).map(|l| (op, l))),
Some(_) => {
if s.invsee.refresh(op, &GameWorld(game)) {
game.native_set_menu_items(op, items(&s.invsee, op));
}
}
}
}
}
ACTIVE.store(!s.invsee.views.is_empty() || !s.pending.is_empty(), Relaxed);
drop(guard);
for (op, line) in tell {
game.native_tell(op, Text::literal(line).with_color(text::RED));
}
for (op, line) in summaries {
staff(game, op, &line);
}
}
/// A click in an `/invsee` menu: true when it was one.
pub(in crate::game) fn menu_clicked(game: &mut Game<'_>, uuid: u128, window: i32, slot: i32, mode: i32) -> bool {
let mut guard = state();
let s = guard.as_mut().expect("made");
if s.invsee.views.get(&uuid).is_none_or(|v| v.window != Some(window)) {
return false;
}
let Some(dir) = game.native_world_dir() else {
return true;
};
if !matches!(mode, 0 | 1) {
return true;
}
let mut asked: Option<Move> = None;
// Each move is in the audit log before it is made; one the log
// refuses isn't made.
let mut record = |m: &Move| {
audit(&dir, m, "asked")?;
asked = Some(m.clone());
Ok(())
};
let own = native_menu_inventory_slot(smp_invsee::SIZE, slot);
let mut world = GameWorld(game);
let result = match own {
Some(i) => match world.0.native_player_stacks(uuid).and_then(|st| st.get(i).cloned().flatten()) {
Some(stack) => s.invsee.click_own(uuid, i, &stack, &mut world, &mut record).map(|_| false),
None => Ok(false),
},
None => match usize::try_from(slot) {
Ok(m) if m < smp_invsee::SIZE => s.invsee.click_menu(uuid, m, &mut world, &mut record).map(|c| c == Some(true)),
_ => Ok(false),
},
};
if let (Some(m), Err(e)) = (&asked, &result)
&& let Err(err) = audit(&dir, m, &format!("refused: {e}"))
{
log::error!("[smp] invsee audit: {err}");
}
let name = game.native_server_players().into_iter().find(|p| p.uuid == uuid).map(|p| p.name);
let close = matches!(result, Ok(true));
let summary = if close { s.invsee.close(uuid, name.as_deref().unwrap_or("?")) } else { None };
let refreshed = (!close).then(|| items(&s.invsee, uuid));
ACTIVE.store(!s.invsee.views.is_empty() || !s.pending.is_empty(), Relaxed);
drop(guard);
if let Err(e) = &result {
game.native_tell(uuid, Text::literal(format!("Nothing moved: {e}.")).with_color(text::RED));
}
if close {
game.native_close_menu(uuid);
} else if let Some(items) = refreshed {
game.native_set_menu_items(uuid, items);
}
if let Some(line) = summary {
staff(game, uuid, &line);
}
true
}
/// An `/invsee` menu closed: true when it was one.
pub(in crate::game) fn menu_closed(game: &mut Game<'_>, uuid: u128, window: i32) -> bool {
let mut guard = state();
let s = guard.as_mut().expect("made");
if s.invsee.views.get(&uuid).is_none_or(|v| v.window != Some(window)) {
return false;
}
let name = game.native_server_players().into_iter().find(|p| p.uuid == uuid).map(|p| p.name);
let summary = s.invsee.close(uuid, name.as_deref().unwrap_or("?"));
ACTIVE.store(!s.invsee.views.is_empty() || !s.pending.is_empty(), Relaxed);
drop(guard);
if let Some(line) = summary {
staff(game, uuid, &line);
}
true
}
+3 -1
View File
@@ -1,4 +1,6 @@
// A click in a menu a native mod opened: the SMP's trade menu, when it is one.
fn smp_menu_clicked(game: &mut crate::game::Game<'_>, uuid: u128, window: i32, slot: i32, mode: i32) {
crate::game::smp_glue::menu_clicked(game, uuid, window, slot, mode);
if !crate::game::smp_invsee_glue::menu_clicked(game, uuid, window, slot, mode) {
crate::game::smp_glue::menu_clicked(game, uuid, window, slot, mode);
}
}
+3 -1
View File
@@ -1,4 +1,6 @@
// A menu a native mod opened closed: a trade's ends, nothing moved.
fn smp_menu_closed(game: &mut crate::game::Game<'_>, uuid: u128, window: i32) {
crate::game::smp_glue::menu_closed(game, uuid, window);
if !crate::game::smp_invsee_glue::menu_closed(game, uuid, window) {
crate::game::smp_glue::menu_closed(game, uuid, window);
}
}
+1
View File
@@ -1,4 +1,5 @@
// The SMP's commands in every player's command tree.
fn smp_register_commands(commands: &mut Vec<crate::game::native_server::NativeCommand>) {
crate::game::smp_glue::commands(commands);
crate::game::smp_invsee_glue::commands(commands);
}
File diff suppressed because it is too large. Load diff
+442
View File
@@ -0,0 +1,442 @@
// The bank (`/bank`): savings that earn a small interest, paid once a day
// from the Treasury (taxes and fees), never minted. A player deposits coins
// (they wait in the `bank` pool) and takes them back at any time, free.
// Interest is paid at 00:00 UTC on the lowest amount the account held since
// the last payment, so a deposit made just before midnight earns nothing
// that night; only the first `max_interest_on` coins of each account earn,
// and one day's interest for everyone is at most `max_treasury_percent` of
// the Treasury (shared out pro rata when it's short). No loans: a loan's
// collateral (items) can't be held safely here, and a loan without it is
// free money for an alt that never pays back.
//
// The accounts live in `economy.json` (`ext.bank`), the coins in its pool:
// money and records are written together. No game types. Added to the game
// as `game::smp_bank`; also a module of this crate, for its tests.
use super::smp_economy::{DAY_MS, Economy, MAX_AMOUNT, Out, Player, TREASURY, parse_amount};
use super::smp_store::{parse_uuid, uuid_string};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
/// The pool savings wait in.
pub const POOL: &str = "bank";
/// `config.json`'s `bank`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub enabled: bool,
/// Interest a day, in hundredths of a percent (10: 0.1% a day).
pub daily_basis_points: i64,
/// Coins of an account that earn interest; more may be saved.
pub max_interest_on: i64,
/// What a player may save in all.
pub max_deposit: i64,
/// One day's interest for everyone is at most this share of the
/// Treasury.
pub max_treasury_percent: i64,
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: true,
daily_basis_points: 10,
max_interest_on: 10_000,
max_deposit: 25_000,
max_treasury_percent: 5,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Saving {
pub amount: i64,
/// The lowest `amount` since the last payment: what earns.
pub low: i64,
pub earned: i64,
}
/// `economy.json`'s `ext.bank`.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Data {
pub accounts: BTreeMap<String, Saving>,
/// The day (since 1970, UTC) interest was last paid.
pub day: i64,
pub paid: i64,
/// The last payment: what it was, and whether the Treasury's cap cut it.
pub last: i64,
pub last_cut: bool,
}
pub struct Bank {
pub config: Config,
pub data: Data,
dirty: bool,
}
impl Bank {
pub fn open(economy: &Economy, config: Config) -> Self {
let mut data = economy
.ext("bank")
.and_then(|v| serde_json::from_value::<Data>(v.clone()).ok())
.unwrap_or_default();
if data.day == 0 {
data.day = economy.now.div_euclid(DAY_MS);
}
Self {
config,
data,
dirty: false,
}
}
pub fn store(&mut self, economy: &mut Economy) {
if self.dirty {
economy.set_ext("bank", serde_json::to_value(&self.data).unwrap_or_default());
self.dirty = false;
}
}
/// What each player has saved (the wealth tax counts it).
pub fn savings(&self) -> HashMap<u128, i64> {
self.data
.accounts
.iter()
.filter_map(|(k, s)| Some((parse_uuid(k)?, s.amount)))
.collect()
}
pub fn saved(&self, uuid: u128) -> i64 {
self.data
.accounts
.get(&uuid_string(uuid))
.map_or(0, |s| s.amount)
}
/// `/bank ...`; None for another command.
pub fn command(&mut self, economy: &mut Economy, me: &Player, line: &str) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
if words.first() != Some(&"bank") {
return None;
}
if !self.config.enabled {
return Some(vec![Out::Error(
"The bank is closed on this server.".into(),
)]);
}
Some(match words[1..] {
[] => self.show(economy, me),
["deposit", amount] => self.deposit(economy, me, amount),
["withdraw", amount] => self.withdraw(economy, me, amount),
_ => vec![Out::Error(
"Usage: /bank, /bank deposit <amount|all>, /bank withdraw <amount|all>".into(),
)],
})
}
fn rate(&self) -> String {
let bp = self.config.daily_basis_points;
format!("{}.{:02}% a day", bp / 100, bp % 100)
}
fn show(&self, economy: &Economy, me: &Player) -> Vec<Out> {
let s = self
.data
.accounts
.get(&uuid_string(me.uuid))
.cloned()
.unwrap_or_default();
let next =
(s.low.min(self.config.max_interest_on) * self.config.daily_basis_points) / 10_000;
vec![
Out::Reply(format!(
"Saved: {} (earned so far {}). Wallet: {}.",
economy.money(s.amount),
economy.money(s.earned),
economy.money(economy.balance(me.uuid))
)),
Out::Reply(format!(
"Interest: {} on up to {}, paid at 00:00 UTC on the lowest amount saved since the last payment, from the Treasury (at most {}% of it a day). Tonight: about {}.",
self.rate(),
economy.money(self.config.max_interest_on),
self.config.max_treasury_percent,
economy.money(next)
)),
Out::Reply(format!(
"/bank deposit <amount|all> (up to {} saved), /bank withdraw <amount|all>: free, at any time. No loans.",
economy.money(self.config.max_deposit)
)),
]
}
fn amount(word: &str, all: i64) -> Option<i64> {
if word == "all" {
(all > 0).then_some(all)
} else {
parse_amount(word)
}
}
fn deposit(&mut self, economy: &mut Economy, me: &Player, word: &str) -> Vec<Out> {
let key = uuid_string(me.uuid);
let saved = self.saved(me.uuid);
let room = (self.config.max_deposit - saved).max(0);
let wallet = economy.balance(me.uuid);
let Some(amount) = Self::amount(word, wallet.min(room)) else {
return vec![Out::Error(if room == 0 {
format!(
"You have saved the most there is: {}.",
economy.money(self.config.max_deposit)
)
} else {
"The amount must be a positive whole number, or all.".into()
})];
};
if amount > room {
return vec![Out::Error(format!(
"You may save {} more (at most {} in all).",
economy.money(room),
economy.money(self.config.max_deposit)
))];
}
if let Err(e) = economy.to_pool("bank_deposit", me.uuid, POOL, amount, "") {
return vec![Out::Error(e)];
}
// `low` stays: what was there all day earns, not the new coins.
let s = self.data.accounts.entry(key).or_default();
s.amount += amount;
self.dirty = true;
vec![Out::Reply(format!(
"Saved {}: {} in the bank. It earns from tomorrow's payment on.",
economy.money(amount),
economy.money(s.amount)
))]
}
fn withdraw(&mut self, economy: &mut Economy, me: &Player, word: &str) -> Vec<Out> {
let key = uuid_string(me.uuid);
let saved = self.saved(me.uuid);
if saved == 0 {
return vec![Out::Error("You have nothing in the bank.".into())];
}
let Some(amount) = Self::amount(word, saved) else {
return vec![Out::Error(if saved == 0 {
"You have nothing in the bank.".into()
} else {
"The amount must be a positive whole number, or all.".into()
})];
};
if amount > saved {
return vec![Out::Error(format!(
"You have {} in the bank.",
economy.money(saved)
))];
}
if let Err(e) = economy.from_pool("bank_withdraw", POOL, me.uuid, amount, "") {
return vec![Out::Error(e)];
}
let s = self.data.accounts.get_mut(&key).expect("saved > 0");
s.amount -= amount;
s.low = s.low.min(s.amount);
let left = s.amount;
if left == 0 && s.earned == 0 {
self.data.accounts.remove(&key);
}
self.dirty = true;
vec![Out::Reply(format!(
"Took {} out: {} left in the bank.",
economy.money(amount),
economy.money(left)
))]
}
/// Once a second from the glue; the work once a day: interest from the
/// Treasury into each account (it stays saved, and earns from then on).
pub fn tick(&mut self, economy: &mut Economy, now: i64) -> Vec<Out> {
let today = now.div_euclid(DAY_MS);
if today <= self.data.day {
return Vec::new();
}
self.data.day = today;
self.dirty = true;
let bp = self.config.daily_basis_points.max(0);
let due: Vec<(String, i64)> = self
.data
.accounts
.iter()
.map(|(k, s)| {
(
k.clone(),
(s.low.clamp(0, self.config.max_interest_on) as i128 * bp as i128 / 10_000)
as i64,
)
})
.collect();
let total: i64 = due.iter().map(|d| d.1).sum();
let cap = economy.pool(TREASURY).max(0) as i128
* self.config.max_treasury_percent.clamp(0, 100) as i128
/ 100;
let cut = i128::from(total) > cap;
let mut paid = 0;
let mut out = Vec::new();
for (key, d) in due {
let pay = if cut {
(i128::from(d) * cap / i128::from(total.max(1))) as i64
} else {
d
};
let pay = pay.min(MAX_AMOUNT);
if pay > 0
&& economy
.pool_move("bank_interest", TREASURY, POOL, pay, "")
.is_ok()
{
paid += pay;
let s = self.data.accounts.get_mut(&key).expect("listed");
s.amount += pay;
s.earned += pay;
if let Some(u) = parse_uuid(&key) {
out.push(Out::Tell(
u,
format!(
"Bank interest: {} (saved: {}).",
economy.money(pay),
economy.money(s.amount)
),
));
}
}
}
// From now on, what's there all day earns.
for s in self.data.accounts.values_mut() {
s.low = s.amount;
}
self.data.paid += paid;
self.data.last = paid;
self.data.last_cut = cut;
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::smp_economy::Config as EcoConfig;
const ALEX: u128 = 1;
const ALT: u128 = 2;
const T0: i64 = 1_793_577_600_000; // a midnight, UTC
fn me(uuid: u128) -> Player {
Player {
uuid,
name: format!("P{uuid}"),
level: 0,
}
}
fn setup(name: &str, treasury: i64) -> (Economy, Bank, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("smp-bank-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut e = Economy::open(&dir, EcoConfig::default()).unwrap();
e.now = T0 + 1000;
for u in [ALEX, ALT] {
e.seen(u, &format!("P{u}"));
e.adjust("sell", u, 50_000, "").unwrap();
}
if treasury > 0 {
e.to_pool("seed", ALT, TREASURY, treasury, "").unwrap();
}
let b = Bank::open(&e, Config::default());
(e, b, dir)
}
fn total(e: &Economy) -> i64 {
e.balance(ALEX) + e.balance(ALT) + e.pool(TREASURY) + e.pool(POOL)
}
fn say(b: &mut Bank, e: &mut Economy, who: u128, line: &str) -> Vec<Out> {
b.command(e, &me(who), line).unwrap()
}
#[test]
fn a_deposit_earns_from_the_next_full_day_and_interest_comes_from_the_treasury() {
let (mut e, mut b, _d) = setup("earn", 10_000);
let start = total(&e);
say(&mut b, &mut e, ALEX, "bank deposit 5000");
assert_eq!((e.balance(ALEX), e.pool(POOL)), (45_000, 5_000));
// The first midnight: deposited during the day, nothing earned.
assert!(b.tick(&mut e, T0 + DAY_MS).is_empty());
// The next: 0.1% of 5,000.
let out = b.tick(&mut e, T0 + 2 * DAY_MS);
assert_eq!(
out,
[Out::Tell(
ALEX,
"Bank interest: 5 coins (saved: 5,005 coins).".into()
)]
);
assert_eq!((e.pool(TREASURY), b.saved(ALEX)), (9_995, 5_005));
assert!(
b.tick(&mut e, T0 + 2 * DAY_MS + 5_000).is_empty(),
"once a day"
);
assert_eq!(total(&e), start, "nothing minted");
}
#[test]
fn deposit_before_midnight_and_withdraw_after_earns_nothing() {
let (mut e, mut b, _d) = setup("game", 10_000);
b.tick(&mut e, T0 + DAY_MS);
say(&mut b, &mut e, ALEX, "bank deposit 10000");
b.tick(&mut e, T0 + 2 * DAY_MS); // deposited today: low was 0
say(&mut b, &mut e, ALEX, "bank withdraw all");
say(&mut b, &mut e, ALEX, "bank deposit 10000");
// Took it out during the day: the low was 0 again.
assert!(b.tick(&mut e, T0 + 3 * DAY_MS).is_empty());
assert_eq!(e.balance(ALEX) + b.saved(ALEX), 50_000);
}
#[test]
fn caps_hold_and_a_poor_treasury_pays_pro_rata() {
let (mut e, mut b, _d) = setup("caps", 100);
let err = |o: Vec<Out>| match &o[0] {
Out::Error(s) => s.clone(),
o => panic!("{o:?}"),
};
assert!(
err(say(&mut b, &mut e, ALEX, "bank deposit 30000"))
.starts_with("You may save 25,000 coins more")
);
assert!(err(say(&mut b, &mut e, ALEX, "bank withdraw 1")).starts_with("You have nothing"));
say(&mut b, &mut e, ALEX, "bank deposit all");
assert_eq!(b.saved(ALEX), 25_000, "all = up to the cap");
say(&mut b, &mut e, ALT, "bank deposit 10000");
b.tick(&mut e, T0 + DAY_MS);
// Due: 10 each (only 10,000 earns); the Treasury (100) pays 5% = 5 in all.
b.tick(&mut e, T0 + 2 * DAY_MS);
assert_eq!((b.data.last, b.data.last_cut), (4, true));
assert_eq!(e.pool(TREASURY), 96);
// An empty Treasury pays nothing; nobody goes below zero.
let (mut e, mut b, _d) = setup("empty", 0);
say(&mut b, &mut e, ALEX, "bank deposit 5000");
b.tick(&mut e, T0 + DAY_MS);
assert!(b.tick(&mut e, T0 + 2 * DAY_MS).is_empty());
assert_eq!(b.saved(ALEX), 5_000);
}
#[test]
fn savings_survive_a_restart_with_the_money() {
let (mut e, mut b, dir) = setup("restart", 1_000);
say(&mut b, &mut e, ALEX, "bank deposit 700");
b.store(&mut e);
e.save().unwrap();
let e2 = Economy::open(&dir, EcoConfig::default()).unwrap();
let b2 = Bank::open(&e2, Config::default());
assert_eq!((b2.saved(ALEX), e2.pool(POOL)), (700, 700));
assert_eq!(b2.savings()[&ALEX], 700);
}
}
+218 -86
View File
@@ -1,11 +1,13 @@
// The SMP's screens: a sidebar with the richest players and the player's own
// balance and rank, which a player hides with `/sidebar off` (kept in
// `board.json`), and the Tab list's header and footer (the server's name,
// its TPS and the players on). Nothing happens per tick: the ranking is built
// again when a balance changed (at most once each `min_seconds`) or every
// `refresh_seconds`, and a sidebar is sent only to the players whose lines
// changed; the Tab list goes out every `tab_refresh_seconds`, and to a player
// who just joined. No game types. Added to the game as `game::smp_board`;
// The SMP's screens: a scoreboard sidebar, the player's own block first (their
// name or nickname, balance, playtime, the players on), then the three
// richest and the server's address, which a player hides with `/sidebar off`
// (kept in `board.json`); and the Tab list's header and footer (the server's
// name, its TPS and the players on). Nothing happens per tick: the ranking is
// built once for everyone when a balance changed (at most once each
// `min_seconds`) or every `refresh_seconds`; the players' own lines are looked
// at on the same `refresh_seconds`, and a sidebar is sent only to the players
// whose lines changed and to newcomers. The Tab list goes out every
// `tab_refresh_seconds`, and to a player who just joined. No game types. Added to the game as `game::smp_board`;
// also a module of this crate, for its tests.
use super::smp_economy::{Out, Player};
@@ -14,20 +16,23 @@ use serde::{Deserialize, Serialize};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};
/// The most lines a sidebar shows (vanilla's 15, less the gap and the
/// player's own line).
const MAX_TOP: usize = 13;
/// The most of the richest a sidebar lists: vanilla's 15 lines, less the
/// player's four, the two gaps, the heading and the address.
const MAX_TOP: usize = 7;
/// `config.json`'s `board` section.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// The sidebar of the richest players (each player may hide it).
/// The sidebar (each player may hide it).
pub sidebar: bool,
pub title: String,
/// How many of the richest players it lists (at most 13).
/// How many of the richest players it lists (at most 7; 0: none).
pub top: usize,
/// The ranking is built again at least this often.
/// Its last line, the server's address ("": none).
pub footer: String,
/// The ranking is built again, and the players' own lines looked at,
/// at least this often.
pub refresh_seconds: i64,
/// After a balance changed, at most this often.
pub min_seconds: i64,
@@ -44,8 +49,9 @@ impl Default for Config {
fn default() -> Self {
Self {
sidebar: true,
title: "Richest players".into(),
top: 10,
title: "Horizon SMP".into(),
top: 3,
footer: "play.playhorizon.dev".into(),
refresh_seconds: 60,
min_seconds: 5,
tab: true,
@@ -57,6 +63,21 @@ impl Default for Config {
}
}
/// SMP 0.13's sidebar defaults (the richest ten), moved to 0.14's when a
/// server never changed them. True when something changed.
pub fn migrate(config: &mut Config) -> bool {
let mut changed = false;
if config.title == "Richest players" {
config.title = Config::default().title;
changed = true;
}
if config.top == 10 {
config.top = Config::default().top;
changed = true;
}
changed
}
/// `board.json`.
#[derive(Default, Serialize, Deserialize)]
#[serde(default)]
@@ -68,6 +89,14 @@ struct Data {
/// A line of a player's sidebar.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Line {
/// The player's name, or their nickname.
Name(String),
/// A value of theirs or the server's: `Balance`, `Playtime`, `Online`.
Stat { label: &'static str, value: String },
/// An empty line.
Gap,
/// The heading over the richest.
Heading,
/// One of the richest: their rank, name and balance; `me` on the
/// player's own line.
Top {
@@ -76,10 +105,25 @@ pub enum Line {
amount: String,
me: bool,
},
/// The empty line before the player's own.
Gap,
/// The player's rank (0: no account yet) and balance.
Me { rank: usize, amount: String },
/// The server's address.
Footer(String),
}
/// What a player's own lines show, from the other modules.
pub struct Me {
/// Their nickname, else their name.
pub name: String,
pub played_seconds: i64,
}
/// `2h 05m`, `12m`: the sidebar's playtime.
pub fn short_duration(seconds: i64) -> String {
let (h, m) = (seconds / 3600, seconds % 3600 / 60);
if h > 0 {
format!("{h}h {m:02}m")
} else {
format!("{m}m")
}
}
pub struct Board {
@@ -97,6 +141,8 @@ pub struct Board {
sent: HashMap<u128, Vec<Line>>,
/// Built since the last `updates`: every player's lines are looked at.
stale: bool,
/// When the players' own lines were last looked at.
personal_at: Option<i64>,
tab_at: Option<i64>,
tab_sent: HashSet<u128>,
}
@@ -140,6 +186,7 @@ impl Board {
built_revision: 0,
sent: HashMap::new(),
stale: false,
personal_at: None,
tab_at: None,
tab_sent: HashSet::new(),
})
@@ -185,46 +232,76 @@ impl Board {
self.stale = true;
}
/// `uuid`'s sidebar from the ranking.
pub fn lines(&self, uuid: u128) -> Vec<Line> {
let mut lines: Vec<Line> = self
.ranked
.iter()
.take(self.config.top.min(MAX_TOP))
.enumerate()
.map(|(i, (u, name, balance))| Line::Top {
rank: i + 1,
name: name.clone(),
amount: compact(*balance),
me: *u == uuid,
})
.collect();
let rank = self.rank_of.get(&uuid).copied().unwrap_or(0);
let balance = rank.checked_sub(1).map_or(0, |i| self.ranked[i].2);
lines.push(Line::Gap);
lines.push(Line::Me {
rank,
amount: compact(balance),
});
/// `uuid`'s sidebar: their own block, the richest from the ranking, the
/// address.
pub fn lines(&self, uuid: u128, me: &Me, online: usize) -> Vec<Line> {
let balance = self
.rank_of
.get(&uuid)
.map_or(0, |&rank| self.ranked[rank - 1].2);
let mut lines = vec![
Line::Name(me.name.clone()),
Line::Stat {
label: "Balance",
value: compact(balance),
},
Line::Stat {
label: "Playtime",
value: short_duration(me.played_seconds),
},
Line::Stat {
label: "Online",
value: online.to_string(),
},
];
let top = self.config.top.min(MAX_TOP);
if top > 0 && !self.ranked.is_empty() {
lines.push(Line::Gap);
lines.push(Line::Heading);
lines.extend(self.ranked.iter().take(top).enumerate().map(
|(i, (u, name, balance))| Line::Top {
rank: i + 1,
name: name.clone(),
amount: compact(*balance),
me: *u == uuid,
},
));
}
if !self.config.footer.is_empty() {
lines.push(Line::Gap);
lines.push(Line::Footer(self.config.footer.clone()));
}
lines
}
/// The sidebars to send now, to the players `on`: those who joined, and
/// after a rebuild those whose lines changed. Players who hid it get
/// after a rebuild or each `refresh_seconds` those whose lines changed.
/// `me` is asked only for the players looked at. Players who hid it get
/// nothing; players gone are forgotten (they get it again on return).
pub fn updates(&mut self, on: &[u128]) -> Vec<(u128, Vec<Line>)> {
pub fn updates(
&mut self,
now: i64,
on: &[u128],
me: impl Fn(u128) -> Me,
) -> Vec<(u128, Vec<Line>)> {
self.sent.retain(|u, _| on.contains(u));
self.tab_sent.retain(|u| on.contains(u));
if !self.config.sidebar || self.built_at.is_none() {
return Vec::new();
}
let stale = std::mem::take(&mut self.stale);
let personal = self
.personal_at
.is_none_or(|t| now - t >= self.config.refresh_seconds * 1000);
if personal {
self.personal_at = Some(now);
}
let all = std::mem::take(&mut self.stale) || personal;
let mut out = Vec::new();
for &uuid in on {
if self.hidden(uuid) || (!stale && self.sent.contains_key(&uuid)) {
if self.hidden(uuid) || (!all && self.sent.contains_key(&uuid)) {
continue;
}
let lines = self.lines(uuid);
let lines = self.lines(uuid, &me(uuid), on.len());
if self.sent.get(&uuid) != Some(&lines) {
self.sent.insert(uuid, lines.clone());
out.push((uuid, lines));
@@ -337,65 +414,120 @@ mod tests {
assert_eq!(compact(7_000_000_000), "7.0B");
}
fn me_of(uuid: u128) -> Me {
Me {
name: format!("P{uuid}"),
played_seconds: 7_500,
}
}
#[test]
fn the_sidebar_lists_the_richest_and_the_players_own_rank() {
fn durations_are_short() {
assert_eq!(short_duration(0), "0m");
assert_eq!(short_duration(12 * 60 + 59), "12m");
assert_eq!(short_duration(7_500), "2h 05m");
}
#[test]
fn the_sidebar_shows_the_player_then_the_three_richest_then_the_address() {
let d = dir();
let mut b = Board::open(&d, Config::default()).unwrap();
assert!(b.due(0, 0));
b.rebuild(accounts(12), 0, 1);
let lines = b.lines(3);
assert_eq!(lines.len(), 12);
let rocky = Me {
name: "Rocky".into(),
played_seconds: 7_500,
};
let stat = |label, value: &str| Line::Stat {
label,
value: value.into(),
};
let top = |rank, name: &str, amount: &str, me| Line::Top {
rank,
name: name.into(),
amount: amount.into(),
me,
};
assert_eq!(
lines[0],
Line::Top {
rank: 1,
name: "P12".into(),
amount: "1,200".into(),
me: false
}
);
assert_eq!(lines[10], Line::Gap);
assert_eq!(
lines[11],
Line::Me {
rank: 10,
amount: "300".into()
}
);
// A player with no account yet.
assert_eq!(
b.lines(99)[11],
Line::Me {
rank: 0,
amount: "0".into()
}
b.lines(11, &rocky, 42),
vec![
Line::Name("Rocky".into()),
stat("Balance", "1,100"),
stat("Playtime", "2h 05m"),
stat("Online", "42"),
Line::Gap,
Line::Heading,
top(1, "P12", "1,200", false),
top(2, "P11", "1,100", true),
top(3, "P10", "1,000", false),
Line::Gap,
Line::Footer("play.playhorizon.dev".into()),
]
);
// A player with no account yet; a server with no address and no top.
assert_eq!(b.lines(99, &rocky, 1)[1], stat("Balance", "0"));
b.config.footer.clear();
b.config.top = 0;
assert_eq!(b.lines(99, &rocky, 1).len(), 4);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn sidebars_go_out_on_join_and_on_change_only() {
fn the_old_defaults_move_to_the_new_ones_and_set_ones_stay() {
let mut old = Config {
title: "Richest players".into(),
top: 10,
..Config::default()
};
assert!(migrate(&mut old));
assert_eq!(old, Config::default());
assert!(!migrate(&mut old));
let mut own = Config {
title: "My server".into(),
top: 5,
..Config::default()
};
assert!(!migrate(&mut own));
assert_eq!((own.title.as_str(), own.top), ("My server", 5));
}
#[test]
fn sidebars_go_out_on_join_on_a_new_ranking_and_on_the_refresh() {
let d = dir();
let mut b = Board::open(&d, Config::default()).unwrap();
b.rebuild(accounts(3), 0, 1);
let sent = b.updates(&[1, 2]);
assert_eq!(sent.len(), 2);
// Nothing new: nothing sent, even when built again.
assert!(b.updates(&[1, 2]).is_empty());
let asked = std::cell::Cell::new(0);
let me = |u| {
asked.set(asked.get() + 1);
me_of(u)
};
assert_eq!(b.updates(0, &[1, 2], me).len(), 2);
// Nothing new within the refresh: nobody is even looked at.
asked.set(0);
assert!(b.updates(1_000, &[1, 2], me).is_empty());
assert_eq!(asked.get(), 0);
assert!(!b.due(1_000, 1), "no change, not a minute yet");
assert!(!b.due(1_000, 2), "changed, but within min_seconds");
assert!(b.due(5_000, 2));
b.rebuild(accounts(3), 5_000, 2);
assert!(b.updates(&[1, 2]).is_empty(), "same lines");
assert!(b.updates(5_000, &[1, 2], me).is_empty(), "same lines");
// Player 1 gets richer: everyone's top changes.
let mut a = accounts(3);
a[0].2 = 1_000;
b.rebuild(a, 10_000, 3);
assert_eq!(b.updates(&[1, 2]).len(), 2);
// A newcomer gets theirs at once; one who left gets it on return.
assert_eq!(b.updates(&[1, 2, 3]).len(), 1);
b.updates(&[2, 3]);
assert_eq!(b.updates(&[1, 2, 3]).len(), 1);
assert_eq!(b.updates(10_000, &[1, 2], me).len(), 2);
// A newcomer gets theirs at once, and only they are looked at.
asked.set(0);
assert_eq!(b.updates(11_000, &[1, 2, 3], me).len(), 1);
assert_eq!(asked.get(), 1);
// One who left gets it on return.
b.updates(12_000, &[2, 3], me);
assert_eq!(b.updates(13_000, &[1, 2, 3], me).len(), 1);
// Within the minute player 2 still shows 2 online; the minute's
// refresh mends it, and sends nothing to the others.
assert_eq!(b.updates(30_000, &[1, 2, 3], me).len(), 0);
let sent = b.updates(60_000, &[1, 2, 3], me);
assert_eq!(sent.iter().map(|s| s.0).collect::<Vec<_>>(), vec![2]);
assert!(b.due(70_000, 3), "a minute passed");
let _ = std::fs::remove_dir_all(d);
}
@@ -408,16 +540,16 @@ mod tests {
let (out, show) = b.command(&me(1), "sidebar off").unwrap();
assert_eq!(show, Some(false));
assert!(matches!(&out[0], Out::Reply(s) if s.contains("off")));
assert_eq!(b.updates(&[1, 2]).len(), 1, "only player 2");
assert_eq!(b.updates(0, &[1, 2], me_of).len(), 1, "only player 2");
b.save().unwrap();
let mut b = Board::open(&d, Config::default()).unwrap();
assert!(b.hidden(1));
b.rebuild(accounts(3), 0, 1);
assert_eq!(b.updates(&[1]).len(), 0);
assert_eq!(b.updates(0, &[1], me_of).len(), 0);
// `/sidebar` toggles it back on, and it is sent at the next update.
let (_, show) = b.command(&me(1), "sidebar").unwrap();
assert_eq!(show, Some(true));
assert_eq!(b.updates(&[1]).len(), 1);
assert_eq!(b.updates(1_000, &[1], me_of).len(), 1);
let _ = std::fs::remove_dir_all(d);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,657 @@
// The casino's blackjack: one player against the dealer, six decks shuffled
// for every hand. The dealer stands on all 17s and checks for blackjack
// first; a blackjack pays 3 to 2; the player may double on their first two
// cards (one more card, the bet doubled); no split, insurance or surrender.
// With basic strategy the house keeps about 1% (the test plays 400,000
// hands). The dealer draws one card every 0.6 s in the menu. Closing the
// menu or leaving stands on the hand, as does a minute without a move: it
// is played out and paid all the same, once. Added to the game as
// `game::smp_casino_blackjack`; also a module of this crate, for its tests.
use super::smp_casino::{Color, Icon, Rng, Seat, Stake, filler, icon};
pub const DECKS: usize = 6;
/// Between two of the dealer's cards.
pub const DEALER_MS: i64 = 600;
/// A hand left this long without a move stands.
pub const IDLE_MS: i64 = 60_000;
pub const HELP: [&str; 1] = [
"Blackjack (6 decks, shuffled every hand): the dealer stands on all 17s; blackjack pays 3 to 2; double on your first two cards; no split or insurance. House edge about 1% with basic strategy.",
];
/// A card: rank 1 (ace) to 13 (king), suit 0 to 3.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Card {
pub rank: u8,
pub suit: u8,
}
impl Card {
pub fn value(self) -> u8 {
self.rank.min(10)
}
pub fn name(self) -> String {
let rank = match self.rank {
1 => "A".to_owned(),
11 => "J".to_owned(),
12 => "Q".to_owned(),
13 => "K".to_owned(),
n => n.to_string(),
};
format!("{rank}{}", ["♠", "♥", "♦", "♣"][usize::from(self.suit % 4)])
}
fn red(self) -> bool {
matches!(self.suit, 1 | 2)
}
}
/// A hand's best total, and whether an ace counts 11 in it (soft).
pub fn total(cards: &[Card]) -> (u8, bool) {
let hard: u8 = cards.iter().map(|c| c.value()).sum();
if cards.iter().any(|c| c.rank == 1) && hard + 10 <= 21 {
(hard + 10, true)
} else {
(hard, false)
}
}
pub fn blackjack(cards: &[Card]) -> bool {
cards.len() == 2 && total(cards).0 == 21
}
/// Six decks, from which a hand draws without putting back.
#[derive(Clone, Debug)]
pub struct Shoe(Vec<Card>);
impl Shoe {
pub fn new() -> Self {
let mut cards = Vec::with_capacity(52 * DECKS);
for _ in 0..DECKS {
for suit in 0..4 {
for rank in 1..=13 {
cards.push(Card { rank, suit });
}
}
}
Self(cards)
}
pub fn draw(&mut self, rng: &mut Rng) -> Card {
let i = rng.below(self.0.len() as u64) as usize;
self.0.swap_remove(i)
}
}
impl Default for Shoe {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Phase {
/// The player's move; since when.
Player { since: i64 },
/// The dealer draws; their next card at.
Dealer { next: i64 },
}
/// How a hand ended, for the line and the payout.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum End {
Blackjack,
Win,
Push,
Lose,
Bust,
DealerBlackjack,
}
impl End {
/// What the hand pays back on `stake` (the stake included).
pub fn payout(self, stake: i64) -> i64 {
match self {
End::Blackjack => stake + stake * 3 / 2,
End::Win => stake * 2,
End::Push => stake,
_ => 0,
}
}
}
#[derive(Clone, Debug)]
pub struct Hand {
pub stake: Stake,
pub player: Vec<Card>,
pub dealer: Vec<Card>,
pub shoe: Shoe,
pub doubled: bool,
pub phase: Phase,
}
impl Hand {
/// How it ends, once the dealer is done (or at once for a blackjack or a
/// bust).
fn end(&self) -> Option<End> {
let (p, _) = total(&self.player);
let (d, _) = total(&self.dealer);
if blackjack(&self.player) {
return Some(if blackjack(&self.dealer) {
End::Push
} else {
End::Blackjack
});
}
if blackjack(&self.dealer) {
return Some(End::DealerBlackjack);
}
if p > 21 {
return Some(End::Bust);
}
if !matches!(self.phase, Phase::Dealer { .. }) || d < 17 {
return None;
}
Some(if d > 21 || p > d {
End::Win
} else if p == d {
End::Push
} else {
End::Lose
})
}
}
/// A hand played out, to be paid.
#[derive(Clone, Debug)]
pub struct Done {
pub hand: Hand,
pub end: End,
}
/// A player's table: the last hand, shown until the next.
#[derive(Clone, Debug, Default)]
pub struct Table {
pub last: Option<(Vec<Card>, Vec<Card>, End)>,
}
#[derive(Default)]
pub struct Blackjack {
pub hands: Vec<Hand>,
}
impl Blackjack {
pub fn busy(&self) -> bool {
!self.hands.is_empty()
}
pub fn playing(&self, uuid: u128) -> bool {
self.hands.iter().any(|h| h.stake.uuid == uuid)
}
fn hand(&mut self, uuid: u128) -> Option<&mut Hand> {
self.hands.iter_mut().find(|h| h.stake.uuid == uuid)
}
/// The bet is held: two cards each. A blackjack ends it at once.
pub fn deal(&mut self, stake: Stake, now: i64, rng: &mut Rng) -> Option<Done> {
let mut shoe = Shoe::new();
let mut player = Vec::with_capacity(6);
let mut dealer = Vec::with_capacity(6);
for _ in 0..2 {
player.push(shoe.draw(rng));
dealer.push(shoe.draw(rng));
}
let hand = Hand {
stake,
player,
dealer,
shoe,
doubled: false,
phase: Phase::Player { since: now },
};
// The dealer checks for blackjack first.
if let Some(end) = hand.end() {
return Some(Done { hand, end });
}
self.hands.push(hand);
None
}
fn finish(&mut self, uuid: u128) -> Option<Done> {
let i = self.hands.iter().position(|h| h.stake.uuid == uuid)?;
let end = self.hands[i].end()?;
Some(Done {
hand: self.hands.swap_remove(i),
end,
})
}
/// One more card; a bust ends the hand.
pub fn hit(&mut self, uuid: u128, now: i64, rng: &mut Rng) -> Option<Done> {
let h = self.hand(uuid)?;
if !matches!(h.phase, Phase::Player { .. }) {
return None;
}
let card = h.shoe.draw(rng);
h.player.push(card);
h.phase = Phase::Player { since: now };
if total(&h.player).0 >= 21 {
if total(&h.player).0 == 21 {
h.phase = Phase::Dealer {
next: now + DEALER_MS,
};
return None;
}
return self.finish(uuid);
}
None
}
/// The dealer plays.
pub fn stand(&mut self, uuid: u128, now: i64) -> bool {
match self.hand(uuid) {
Some(h) if matches!(h.phase, Phase::Player { .. }) => {
h.phase = Phase::Dealer {
next: now + DEALER_MS,
};
true
}
_ => false,
}
}
/// Whether `uuid` may double now (first two cards); the caller holds the
/// second bet first, then calls `doubled`.
pub fn may_double(&self, uuid: u128) -> bool {
self.hands.iter().any(|h| {
h.stake.uuid == uuid && h.player.len() == 2 && matches!(h.phase, Phase::Player { .. })
})
}
/// The bet doubled (held): one card, then the dealer plays.
pub fn doubled(&mut self, uuid: u128, extra: i64, now: i64, rng: &mut Rng) -> Option<Done> {
let h = self.hand(uuid)?;
h.stake.amount += extra;
h.doubled = true;
let card = h.shoe.draw(rng);
h.player.push(card);
h.phase = Phase::Dealer {
next: now + DEALER_MS,
};
if total(&h.player).0 > 21 {
return self.finish(uuid);
}
None
}
/// The dealers' cards due by `now`; hands left idle stand. The hands
/// played out, taken out to be paid once, and those that changed.
pub fn step(&mut self, now: i64, rng: &mut Rng) -> (Vec<Done>, Vec<u128>) {
let mut changed = Vec::new();
for h in &mut self.hands {
match h.phase {
Phase::Player { since } if now - since >= IDLE_MS => {
h.phase = Phase::Dealer {
next: now + DEALER_MS,
};
changed.push(h.stake.uuid);
}
Phase::Dealer { next } if now >= next => {
// The hidden card turns first (shown from this step), then one card a step.
if total(&h.dealer).0 < 17 {
let card = h.shoe.draw(rng);
h.dealer.push(card);
h.phase = Phase::Dealer {
next: now + DEALER_MS,
};
} else {
h.phase = Phase::Dealer { next: i64::MIN };
}
changed.push(h.stake.uuid);
}
_ => {}
}
}
let mut done = Vec::new();
let mut i = 0;
while i < self.hands.len() {
let h = &self.hands[i];
let settled = matches!(h.phase, Phase::Dealer { next: i64::MIN });
match h.end() {
Some(end) if settled || matches!(end, End::Bust) => done.push(Done {
hand: self.hands.swap_remove(i),
end,
}),
_ => i += 1,
}
}
(done, changed)
}
/// The table's cells (all but the bottom row's shared cells).
pub fn layout(&self, cells: &mut [Option<Icon>], uuid: u128, seat: &Seat) {
for c in &mut cells[..45] {
*c = filler("minecraft:green_stained_glass_pane");
}
let hand = self.hands.iter().find(|h| h.stake.uuid == uuid);
let (player, dealer, hidden, message) = match (hand, &seat.blackjack.last) {
(Some(h), _) => {
let hidden = matches!(h.phase, Phase::Player { .. });
let msg = if hidden {
"Hit, stand or double".to_owned()
} else {
"The dealer plays...".to_owned()
};
(&h.player[..], &h.dealer[..], hidden, msg)
}
(None, Some((p, d, end))) => (&p[..], &d[..], false, end_line(*end)),
(None, None) => (
&[][..],
&[][..],
false,
"Place your bet and deal".to_owned(),
),
};
for (i, c) in dealer.iter().take(9).enumerate() {
cells[9 + i] = if hidden && i == 1 {
icon("minecraft:map", "Hidden card", Color::Gray)
} else {
card(*c)
};
}
for (i, c) in player.iter().take(9).enumerate() {
cells[27 + i] = card(*c);
}
if !dealer.is_empty() {
let shown = if hidden {
format!("Dealer shows {}", total(&dealer[..1]).0)
} else {
format!("Dealer: {}", show_total(dealer))
};
cells[4] = icon("minecraft:armor_stand", shown, Color::White);
cells[36] = icon(
"minecraft:name_tag",
format!("You: {}", show_total(player)),
Color::White,
);
}
cells[22] = icon("minecraft:oak_sign", message, Color::Yellow);
if hand.is_some_and(|h| matches!(h.phase, Phase::Player { .. })) {
cells[38] = icon("minecraft:lime_wool", "HIT", Color::Green);
cells[40] = icon("minecraft:red_wool", "STAND", Color::Red);
if hand.is_some_and(|h| h.player.len() == 2) {
cells[42] = icon(
"minecraft:gold_block",
"DOUBLE (one card, twice the bet)",
Color::Gold,
);
}
}
cells[53] = if hand.is_some() {
icon("minecraft:gray_concrete", "Hand in play", Color::Gray)
} else {
icon("minecraft:lime_concrete", "DEAL", Color::Green)
};
}
}
fn show_total(cards: &[Card]) -> String {
match total(cards) {
(t, true) if t < 21 => format!("{t} (soft)"),
(t, _) => t.to_string(),
}
}
/// How a hand ended, in a line.
pub fn end_line(end: End) -> String {
match end {
End::Blackjack => "Blackjack! Paid 3 to 2".into(),
End::Win => "You win".into(),
End::Push => "Push: your bet back".into(),
End::Lose => "The dealer wins".into(),
End::Bust => "Bust".into(),
End::DealerBlackjack => "The dealer has blackjack".into(),
}
}
/// A card as a cell: its blackjack value as the count.
fn card(c: Card) -> Option<Icon> {
let mut i = icon(
"minecraft:paper",
c.name(),
if c.red() { Color::Red } else { Color::White },
);
if let Some(i) = i.as_mut() {
i.count = c.value();
}
i
}
#[cfg(test)]
mod tests {
use super::super::smp_casino::{Fx, Game, POT, View, tests::*};
use super::*;
fn c(rank: u8) -> Card {
Card { rank, suit: 0 }
}
#[test]
fn totals_soft_and_hard() {
assert_eq!(total(&[c(1), c(6)]), (17, true));
assert_eq!(total(&[c(1), c(6), c(10)]), (17, false));
assert_eq!(total(&[c(1), c(1), c(9)]), (21, true));
assert_eq!(total(&[c(13), c(12)]), (20, false));
assert!(blackjack(&[c(1), c(11)]) && !blackjack(&[c(1), c(5), c(5)]));
assert_eq!(End::Blackjack.payout(10), 25);
assert_eq!(End::Blackjack.payout(15), 37, "3 to 2, rounded down");
assert_eq!(
(
End::Win.payout(10),
End::Push.payout(10),
End::Lose.payout(10)
),
(20, 10, 0)
);
}
#[test]
fn the_dealer_stands_on_all_17s() {
let mut rng = Rng::from_seed([3; 32]);
for _ in 0..2000 {
let mut b = Blackjack::default();
let stake = Stake {
uuid: A,
name: "Alex".into(),
game: Game::Blackjack,
amount: 10,
};
if b.deal(stake, 0, &mut rng).is_some() {
continue;
}
b.stand(A, 0);
let mut now = 0;
loop {
now += DEALER_MS;
let (done, _) = b.step(now, &mut rng);
if let Some(d) = done.into_iter().next() {
let (t, _) = total(&d.hand.dealer);
assert!(t >= 17, "{:?}", d.hand.dealer);
// It stopped at the first 17 or more.
assert!(
total(&d.hand.dealer[..d.hand.dealer.len() - 1]).0 < 17
|| d.hand.dealer.len() == 2
);
break;
}
}
}
}
/// Basic strategy for 6 decks, dealer stands on 17, no split.
fn strategy(player: &[Card], up: Card) -> char {
let (t, soft) = total(player);
let u = up.value();
let u = if u == 1 { 11 } else { u };
let two = player.len() == 2;
let d = |yes: bool| {
if yes && two {
'D'
} else if yes {
'H'
} else {
'S'
}
};
if soft {
return match t {
13 | 14 => {
if (5..=6).contains(&u) && two {
'D'
} else {
'H'
}
}
15 | 16 => {
if (4..=6).contains(&u) && two {
'D'
} else {
'H'
}
}
17 => {
if (3..=6).contains(&u) && two {
'D'
} else {
'H'
}
}
18 => {
if (3..=6).contains(&u) {
d(true)
} else if u <= 8 {
'S'
} else {
'H'
}
}
_ => 'S',
};
}
match t {
0..=8 => 'H',
9 => {
if (3..=6).contains(&u) && two {
'D'
} else {
'H'
}
}
10 => {
if u <= 9 && two {
'D'
} else {
'H'
}
}
11 => {
if u <= 10 && two {
'D'
} else {
'H'
}
}
12 => {
if (4..=6).contains(&u) {
'S'
} else {
'H'
}
}
13..=16 => {
if u <= 6 {
'S'
} else {
'H'
}
}
_ => 'S',
}
}
#[test]
fn basic_strategy_gets_back_about_99_percent() {
let mut rng = Rng::from_seed([3; 32]);
let (mut staked, mut back) = (0i64, 0i64);
for _ in 0..400_000 {
let mut b = Blackjack::default();
let stake = Stake {
uuid: A,
name: "Alex".into(),
game: Game::Blackjack,
amount: 100,
};
let mut done = b.deal(stake, 0, &mut rng);
while done.is_none() {
let h = &b.hands[0];
if !matches!(h.phase, Phase::Player { .. }) {
break;
}
done = match strategy(&h.player, h.dealer[0]) {
'H' => b.hit(A, 0, &mut rng),
'D' => b.doubled(A, 100, 0, &mut rng),
_ => {
b.stand(A, 0);
None
}
};
}
let mut now = 0;
while done.is_none() {
now += DEALER_MS;
done = b.step(now, &mut rng).0.into_iter().next();
}
let d = done.unwrap();
staked += d.hand.stake.amount;
back += d.end.payout(d.hand.stake.amount);
}
let rtp = back as f64 / staked as f64;
assert!((0.97..1.0).contains(&rtp), "{rtp}");
}
#[test]
fn a_hand_through_the_casino_pays_once_and_closing_stands() {
let (mut e, mut c, dir) = setup("blackjack", 100_000);
let before = money(&e);
let mut paid_hands = 0;
let mut now = 0;
c.sit(A, "Alex", View::Blackjack);
for round in 0..40 {
c.seats.get_mut(&A).unwrap().window = Some(round);
c.click(&mut e, A, 53, false, now);
c.click(&mut e, A, 53, false, now + 10);
assert!(e.held(A) <= 10, "one deal per click");
if c.blackjack.playing(A) {
if round % 3 == 0 {
// Double, with the second bet held.
c.click(&mut e, A, 42, false, now + 20);
c.click(&mut e, A, 42, false, now + 30);
assert!(e.held(A) <= 20);
} else if round % 3 == 1 {
// The menu closes: the hand stands.
assert!(c.closed(A, round, 0));
c.sit(A, "Alex", View::Blackjack);
} else {
// Stand by hand; a second click does nothing more.
c.click(&mut e, A, 40, false, now + 20);
c.click(&mut e, A, 40, false, now + 30);
}
}
for _ in 0..20 {
now += 300;
c.tick(&mut e, now);
}
if !c.blackjack.playing(A) {
paid_hands += 1;
}
assert_eq!(money(&e), before);
now += 5000;
}
assert_eq!(paid_hands, 40);
assert_eq!(e.held(A), 0);
assert_eq!(c.ledger.totals["blackjack"].rounds, 40);
let _ = (e.pool(POT), Fx::BossHide);
std::fs::remove_dir_all(dir).unwrap();
}
}
+543
View File
@@ -0,0 +1,543 @@
// The casino's crash: one round for everyone. The first bet opens a 10 s
// betting window. Then the multiplier climbs from 1.00x (2x at 8 s, 10x at
// 27 s) until it crashes; whoever cashes out first gets their bet times the
// multiplier at that moment, and the others lose it. The crash point is
// drawn when the flight starts, P(crash >= x) = (100% - edge) / x, so with
// the 4% edge any way of playing gets back 96% on average. A crash at
// 1.00x happens about 5% of the time, and the flight never goes past the cap
// (100x). A player may set an automatic cash-out before the flight. Leaving
// the server mid-flight cashes out at that moment, and closing the menu
// doesn't (/casino crash opens it again). Every menu, the boss bar and the
// action bar show the climb, 5 pictures a second. Added to the game as
// `game::smp_casino_crash`; also a module of this crate, for its tests.
use super::smp_casino::{Color, FRAME_MS, Icon, Rng, Seat, Stake, filler, icon};
use std::collections::VecDeque;
pub const BETTING_MS: i64 = 10_000;
/// How long the crash stays shown before the next round may open.
pub const SHOWN_MS: i64 = 3000;
/// The multiplier doubles every 8 s.
const DOUBLING_MS: f64 = 8000.0;
/// The automatic cash-outs a player can pick, in hundredths (0: none).
pub const AUTOS: [u32; 7] = [0, 150, 200, 300, 500, 1000, 2500];
pub const HELP: [&str; 1] = [
"Crash: bet before the flight, then cash out before it crashes; you get your bet times the multiplier. P(crash at x or more) = 96% / x, capped at 100x: whatever you do, 96% is paid back on average (house edge 4%).",
];
/// The multiplier, in hundredths, `ms` into a flight.
pub fn multiple_at(ms: i64) -> u32 {
let m = 100.0 * (ms.max(0) as f64 * std::f64::consts::LN_2 / DOUBLING_MS).exp();
m.floor().min(f64::from(u32::MAX)) as u32
}
/// The first millisecond the multiplier reaches `h` hundredths.
pub fn time_to(h: u32) -> i64 {
let mut t =
((f64::from(h.max(100)) / 100.0).ln() / std::f64::consts::LN_2 * DOUBLING_MS).ceil() as i64;
while t > 0 && multiple_at(t - 1) >= h {
t -= 1;
}
while multiple_at(t) < h {
t += 1;
}
t
}
/// A crash point, in hundredths: P(>= x) = (1 - edge) / x, at least 1.00x,
/// at most `max` (in whole times the bet).
pub fn draw(rng: &mut Rng, edge_percent: i64, max: i64) -> u32 {
let u = (rng.next_u64() >> 11) as f64 / (1u64 << 53) as f64;
let keep = 1.0 - edge_percent.clamp(0, 99) as f64 / 100.0;
let x = (100.0 * keep / (1.0 - u)).floor();
x.clamp(100.0, (max.max(1) * 100) as f64) as u32
}
/// "2.31x".
pub fn times(h: u32) -> String {
format!("{}.{:02}x", h / 100, h % 100)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Phase {
Idle,
Betting {
ends: i64,
},
Flying {
started: i64,
crash: u32,
crash_at: i64,
},
Crashed {
at: i64,
crash: u32,
},
}
#[derive(Clone, Debug)]
pub struct Bet {
pub stake: Stake,
/// Cash out by itself at this multiplier (hundredths); 0: by hand.
pub auto: u32,
}
/// What moved in a step of the round.
#[derive(Default, Debug)]
pub struct Step {
pub took_off: bool,
/// Bets cashed out by their automatic cash-out: paid at that multiplier.
pub cashed: Vec<(Bet, u32)>,
/// The crash, and the bets still in: lost.
pub crashed: Option<(u32, Vec<Bet>)>,
}
pub struct Crash {
pub phase: Phase,
pub bets: Vec<Bet>,
/// The last rounds' crash points, newest first.
pub history: VecDeque<u32>,
}
impl Default for Crash {
fn default() -> Self {
Self {
phase: Phase::Idle,
bets: Vec::new(),
history: VecDeque::new(),
}
}
}
impl Crash {
pub fn busy(&self) -> bool {
self.phase != Phase::Idle
}
pub fn playing(&self, uuid: u128) -> bool {
self.bets.iter().any(|b| b.stake.uuid == uuid)
}
/// Whether a bet may be placed now (before a flight).
pub fn open(&self) -> bool {
matches!(self.phase, Phase::Idle | Phase::Betting { .. })
}
/// A held bet joins the next flight (`open` was checked).
pub fn join(&mut self, stake: Stake, auto: u32, now: i64) {
if self.phase == Phase::Idle {
self.phase = Phase::Betting {
ends: now + BETTING_MS,
};
}
self.bets.push(Bet { stake, auto });
}
/// `uuid` cashes out: their bet and the multiplier, when the flight is
/// still climbing. Once only: the bet leaves the round.
pub fn cash_out(&mut self, uuid: u128, now: i64) -> Option<(Bet, u32)> {
let Phase::Flying {
started, crash_at, ..
} = self.phase
else {
return None;
};
if now >= crash_at {
return None;
}
let i = self.bets.iter().position(|b| b.stake.uuid == uuid)?;
Some((self.bets.swap_remove(i), multiple_at(now - started)))
}
/// A bet taken back before the flight (its player left): None once flying.
pub fn withdraw(&mut self, uuid: u128) -> Option<Bet> {
if !matches!(self.phase, Phase::Betting { .. }) {
return None;
}
let i = self.bets.iter().position(|b| b.stake.uuid == uuid)?;
let bet = self.bets.swap_remove(i);
if self.bets.is_empty() {
self.phase = Phase::Idle;
}
Some(bet)
}
/// The round moves on to `now`.
pub fn step(&mut self, now: i64, rng: &mut Rng, edge_percent: i64, max: i64) -> Step {
let mut step = Step::default();
if let Phase::Betting { ends } = self.phase
&& now >= ends
{
let crash = draw(rng, edge_percent, max);
self.phase = Phase::Flying {
started: ends,
crash,
crash_at: ends + time_to(crash),
};
step.took_off = true;
}
if let Phase::Flying {
started,
crash,
crash_at,
} = self.phase
{
// Automatic cash-outs below the crash, reached by now.
let mut i = 0;
while i < self.bets.len() {
let b = &self.bets[i];
if b.auto > 0 && b.auto < crash && now >= started + time_to(b.auto) {
let bet = self.bets.swap_remove(i);
let at = bet.auto;
step.cashed.push((bet, at));
} else {
i += 1;
}
}
if now >= crash_at {
step.crashed = Some((crash, std::mem::take(&mut self.bets)));
self.phase = Phase::Crashed {
at: crash_at,
crash,
};
self.history.push_front(crash);
self.history.truncate(8);
}
}
if let Phase::Crashed { at, .. } = self.phase
&& now >= at + SHOWN_MS
{
self.phase = Phase::Idle;
}
step
}
/// The picture the round is at: it changes 5 times a second in flight,
/// once a second before.
pub fn frame(&self, now: i64) -> i64 {
match self.phase {
Phase::Idle => 0,
Phase::Betting { ends } => 1_000_000 + (ends - now).max(0) / 1000,
Phase::Flying { started, .. } => 2_000_000 + (now - started).max(0) / FRAME_MS,
Phase::Crashed { .. } => 3_000_000,
}
}
/// The multiplier now, in flight.
pub fn now_multiple(&self, now: i64) -> Option<u32> {
match self.phase {
Phase::Flying { started, crash, .. } => Some(multiple_at(now - started).min(crash)),
_ => None,
}
}
/// The boss bar's line, colour and fill (0 to 100).
pub fn boss(&self, now: i64, max: i64) -> Option<(String, Color, u32)> {
let fill = |h: u32| {
let f = (f64::from(h) / 100.0).ln() / (max.max(2) as f64).ln();
(f * 100.0).clamp(0.0, 100.0) as u32
};
Some(match self.phase {
Phase::Idle => return None,
Phase::Betting { ends } => (
format!(
"Crash: takes off in {} s · {} bet(s) · /casino crash",
((ends - now).max(0) + 999) / 1000,
self.bets.len()
),
Color::Yellow,
0,
),
Phase::Flying { .. } => {
let h = self.now_multiple(now).unwrap_or(100);
(format!("Crash: {}", times(h)), Color::Green, fill(h))
}
Phase::Crashed { crash, .. } => (
format!("Crash: crashed at {}", times(crash)),
Color::Red,
fill(crash),
),
})
}
/// The crash menu's cells (all but the bottom row's shared cells).
pub fn layout(&self, cells: &mut [Option<Icon>], uuid: u128, seat: &Seat, now: i64, max: i64) {
// The chart: 9 columns of time, 4 rows of (log) multiplier.
let (elapsed, top, crashed) = match self.phase {
Phase::Flying { started, crash, .. } => {
(now - started, multiple_at(now - started).min(crash), false)
}
Phase::Crashed { crash, .. } => (time_to(crash), crash, true),
_ => (0, 100, false),
};
let level = |h: u32| {
let f = (f64::from(h) / 100.0).ln() / (max.max(2) as f64).ln().min(5f64.ln() * 2.0);
(f * 4.0).clamp(0.0, 4.0)
};
let fill = if crashed {
"minecraft:red_stained_glass_pane"
} else {
"minecraft:lime_stained_glass_pane"
};
for col in 0..9usize {
let h = multiple_at(elapsed * (col as i64 + 1) / 9);
let l = level(h);
for row in 0..4usize {
let from_bottom = 3 - row;
cells[row * 9 + col] = if elapsed > 0 && l > from_bottom as f64 {
filler(fill)
} else {
filler("minecraft:black_stained_glass_pane")
};
}
}
if elapsed > 0 {
let row = 3 - (level(top).ceil() as usize).clamp(1, 4).saturating_sub(1);
let (item, color) = if crashed {
("minecraft:tnt", Color::Red)
} else {
("minecraft:firework_rocket", Color::Green)
};
let name = if crashed {
format!("Crashed at {}", times(top))
} else {
times(top)
};
cells[row * 9 + 8] = icon(item, name, color);
}
for c in &mut cells[36..45] {
*c = filler("minecraft:gray_stained_glass_pane");
}
let status = match self.phase {
Phase::Idle => "No round: the first bet opens one (10 s to join)".to_owned(),
Phase::Betting { ends } => {
format!("Takes off in {} s", ((ends - now).max(0) + 999) / 1000)
}
Phase::Flying { .. } => format!("Flying: {}", times(top)),
Phase::Crashed { crash, .. } => format!("Crashed at {}", times(crash)),
};
cells[38] = icon("minecraft:clock", status, Color::Yellow);
cells[39] = icon(
"minecraft:paper",
format!("{} bet(s) in this round", self.bets.len()),
Color::White,
);
let past: Vec<String> = self.history.iter().take(6).map(|h| times(*h)).collect();
cells[40] = icon(
"minecraft:map",
if past.is_empty() {
"Last crashes: none yet".to_owned()
} else {
format!("Last crashes: {}", past.join(", "))
},
Color::Gray,
);
cells[42] = icon(
"minecraft:repeater",
match seat.crash_auto {
0 => "Auto cash-out: off (click to set)".to_owned(),
a => format!("Auto cash-out at {} (click to change)", times(a)),
},
Color::Aqua,
);
let mine = self.bets.iter().find(|b| b.stake.uuid == uuid);
cells[53] = match (self.phase, mine) {
(Phase::Flying { .. }, Some(b)) => icon(
"minecraft:gold_block",
format!(
"CASH OUT at {}: {}",
times(top),
b.stake.amount * i64::from(top) / 100
),
Color::Gold,
),
(Phase::Idle | Phase::Betting { .. }, Some(b)) => icon(
"minecraft:lime_stained_glass",
format!("Your bet is in: {}", b.stake.amount),
Color::Green,
),
(Phase::Idle | Phase::Betting { .. }, None) => {
icon("minecraft:lime_concrete", "BET", Color::Green)
}
_ => icon(
"minecraft:gray_concrete",
"Wait for the next round",
Color::Gray,
),
};
}
}
#[cfg(test)]
mod tests {
use super::super::smp_casino::{Fx, Game, POT, View, tests::*};
use super::*;
fn stake(uuid: u128, amount: i64) -> Stake {
Stake {
uuid,
name: "x".into(),
game: Game::Crash,
amount,
}
}
#[test]
fn the_curve_and_its_inverse() {
assert_eq!(multiple_at(0), 100);
assert_eq!(multiple_at(8000), 200);
assert_eq!(multiple_at(16_000), 400);
for h in [100, 101, 150, 199, 200, 231, 1000, 10_000] {
let t = time_to(h);
assert!(
multiple_at(t) >= h && (t == 0 || multiple_at(t - 1) < h),
"{h}"
);
}
assert_eq!(times(231), "2.31x");
}
#[test]
fn crash_points_follow_96_over_x() {
let mut rng = Rng::from_seed([3; 32]);
let n = 200_000;
let points: Vec<u32> = (0..n).map(|_| draw(&mut rng, 4, 100)).collect();
let share = |x: u32| points.iter().filter(|p| **p >= x).count() as f64 / n as f64;
for x in [100, 150, 200, 500, 1000] {
let expected = if x == 100 {
1.0
} else {
0.96 * 100.0 / f64::from(x)
};
assert!(
(share(x) - expected).abs() < 0.01,
"{x}: {} vs {expected}",
share(x)
);
}
let instant = points.iter().filter(|p| **p == 100).count() as f64 / n as f64;
// Below 1.01x, shown as 1.00x: 1 - 96/101.
assert!((instant - 0.0495).abs() < 0.005, "{instant}");
assert!(points.iter().all(|p| (100..=10_000).contains(p)));
}
#[test]
fn a_round_cashes_out_by_hand_and_by_itself_then_crashes() {
let mut rng = Rng::from_seed([3; 32]);
let mut c = Crash::default();
c.join(stake(A, 100), 0, 0);
c.join(stake(B, 100), 150, 1000);
assert_eq!(c.phase, Phase::Betting { ends: BETTING_MS });
assert!(c.cash_out(A, 5000).is_none(), "not before the flight");
// A crash at 3.00x.
c.phase = Phase::Flying {
started: BETTING_MS,
crash: 300,
crash_at: BETTING_MS + time_to(300),
};
let s = c.step(BETTING_MS + time_to(150), &mut rng, 4, 100);
assert_eq!(s.cashed.len(), 1);
assert_eq!((s.cashed[0].0.stake.uuid, s.cashed[0].1), (B, 150));
let (bet, m) = c.cash_out(A, BETTING_MS + 8000).unwrap();
assert_eq!((bet.stake.uuid, m), (A, 200));
assert!(c.cash_out(A, BETTING_MS + 8100).is_none(), "once");
c.join(stake(B, 50), 0, 0);
let s = c.step(BETTING_MS + time_to(300), &mut rng, 4, 100);
let (crash, lost) = s.crashed.unwrap();
assert_eq!((crash, lost.len()), (300, 1));
assert!(
c.cash_out(B, BETTING_MS + time_to(300)).is_none(),
"too late"
);
c.step(BETTING_MS + time_to(300) + SHOWN_MS, &mut rng, 4, 100);
assert_eq!(c.phase, Phase::Idle);
}
#[test]
fn through_the_casino_every_bet_is_paid_once_and_leaving_cashes_out() {
let (mut e, mut c, dir) = setup("crash", 100_000);
let before = money(&e);
for (u, n) in [(A, "Alex"), (B, "Steve")] {
c.sit(u, n, View::Crash);
c.seats.get_mut(&u).unwrap().window = Some(7);
c.click(&mut e, u, 53, false, 0);
c.click(&mut e, u, 53, false, 20);
}
assert_eq!((e.held(A), e.held(B)), (10, 10), "double clicks bet once");
c.tick(&mut e, BETTING_MS);
let Phase::Flying {
crash, crash_at, ..
} = c.crash.phase
else {
panic!("{:?}", c.crash.phase)
};
// Steve leaves the server right away; Alex keeps flying.
c.gone(&mut e, &[A], BETTING_MS);
assert_eq!(e.held(B), 0);
assert_eq!(
e.balance(B),
10_000 - 10 + 10 * 100 / 100,
"cashed out at 1.00x"
);
// Closing the menu doesn't cash out.
assert!(c.closed(A, 7, 0));
let mut t = BETTING_MS;
while t <= crash_at + SHOWN_MS {
c.tick(&mut e, t);
t += 50;
}
c.tick(&mut e, crash_at + SHOWN_MS);
assert_eq!(e.held(A), 0);
assert_eq!(
e.balance(A),
9990,
"lost: no cash-out before {}",
times(crash)
);
assert_eq!(money(&e), before);
assert!(!c.animating());
let _ = e.pool(POT);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn the_flight_is_drawn_at_take_off_and_shown_5_times_a_second() {
let (mut e, mut c, dir) = setup("crash-frames", 100_000);
c.sit(A, "Alex", View::Crash);
c.seats.get_mut(&A).unwrap().window = Some(7);
c.seats.get_mut(&A).unwrap().crash_auto = 0;
c.click(&mut e, A, 53, false, 0);
c.tick(&mut e, BETTING_MS);
c.crash.phase = Phase::Flying {
started: BETTING_MS,
crash: 10_000,
crash_at: BETTING_MS + time_to(10_000),
};
let mut shows = 0;
for t in (BETTING_MS + 1..BETTING_MS + 2002).step_by(50) {
shows += c
.tick(&mut e, t)
.iter()
.filter(|f| matches!(f, Fx::Show(A)))
.count();
}
assert_eq!(shows, 10, "2 s of flight, 10 pictures");
// A cash-out click at 1.00x + 2 s.
let t = BETTING_MS + 2000;
c.click(&mut e, A, 53, false, t);
let m = i64::from(multiple_at(2000));
assert_eq!(e.balance(A), 10_000 - 10 + 10 * m / 100);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn playing_any_way_pays_back_about_96_percent() {
let mut rng = Rng::from_seed([3; 32]);
let n = 100_000;
for target in [110u32, 200, 500, 2000] {
let mut back = 0i64;
for _ in 0..n {
let crash = draw(&mut rng, 4, 100);
if target < crash {
back += i64::from(target);
}
}
let rtp = back as f64 / (n as f64 * 100.0);
assert!((0.90..1.02).contains(&rtp), "{target}: {rtp}");
}
}
}
@@ -0,0 +1,527 @@
// The casino's roulette: European, one zero, 37 pockets in the wheel's real
// order. One bet a spin (a number, red or black, odd or even, 1-18 or 19-36,
// a dozen, a column), paid at the true odds, so the house's edge is the zero:
// 1/37 = 2.70% on every bet. The pocket is drawn the moment the player
// spins; the menu's top rows then show the wheel turning under a pointer,
// fast first and slowing down to land on it (20 pictures over 4 s). Closing
// the menu or leaving doesn't stop it: the round ends and is paid all the
// same. Added to the game as `game::smp_casino_roulette`; also a module of
// this crate, for its tests.
use super::smp_casino::{Color, FRAME_MS, Icon, Rng, Seat, Stake, View, filler, icon};
/// The pockets in the order they sit on the wheel.
pub const WHEEL: [u8; 37] = [
0, 32, 15, 19, 4, 21, 2, 25, 17, 34, 6, 27, 13, 36, 11, 30, 8, 23, 10, 5, 24, 16, 33, 1, 20,
14, 31, 9, 22, 18, 29, 7, 28, 12, 35, 3, 26,
];
const RED: [u8; 18] = [
1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36,
];
/// Pictures of a spin, `FRAME_MS` apart.
pub const FRAMES: i64 = 20;
pub const HELP: [&str; 2] = [
"Roulette (European, one zero, 37 pockets): a number pays 35 to 1; a dozen or a column 2 to 1; red, black, odd, even, 1-18, 19-36 1 to 1.",
"Zero loses every bet but a bet on 0. House edge 2.70% on every bet (97.3% paid back on average).",
];
pub fn is_red(n: u8) -> bool {
RED.contains(&n)
}
/// "17 Black".
pub fn pocket_name(n: u8) -> String {
let colour = match n {
0 => "Green",
n if is_red(n) => "Red",
_ => "Black",
};
format!("{n} {colour}")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Bet {
Number(u8),
Red,
Black,
Odd,
Even,
Low,
High,
/// 1: 1-12, 2: 13-24, 3: 25-36.
Dozen(u8),
/// 1: 1, 4, 7... 34; 2: 2, 5... 35; 3: 3, 6... 36.
Column(u8),
}
impl Bet {
pub fn wins(self, n: u8) -> bool {
if n == 0 {
return self == Bet::Number(0);
}
match self {
Bet::Number(k) => k == n,
Bet::Red => is_red(n),
Bet::Black => !is_red(n),
Bet::Odd => !n.is_multiple_of(2),
Bet::Even => n.is_multiple_of(2),
Bet::Low => n <= 18,
Bet::High => n >= 19,
Bet::Dozen(d) => (n - 1) / 12 + 1 == d,
Bet::Column(c) => (n - 1) % 3 + 1 == c,
}
}
/// What a winning bet pays back for each coin staked, the stake included.
pub fn multiple(self) -> i64 {
match self {
Bet::Number(_) => 36,
Bet::Dozen(_) | Bet::Column(_) => 3,
_ => 2,
}
}
/// What the bet pays back on pocket `n`: 0 when it loses.
pub fn payout(self, n: u8, stake: i64) -> i64 {
if self.wins(n) {
stake * self.multiple()
} else {
0
}
}
pub fn label(self) -> String {
match self {
Bet::Number(n) => format!("{n}"),
Bet::Red => "Red".into(),
Bet::Black => "Black".into(),
Bet::Odd => "Odd".into(),
Bet::Even => "Even".into(),
Bet::Low => "1 to 18".into(),
Bet::High => "19 to 36".into(),
Bet::Dozen(d) => format!(
"{} 12",
["1st", "2nd", "3rd"][usize::from(d.clamp(1, 3) - 1)]
),
Bet::Column(c) => format!("Column {c}"),
}
}
fn odds(self) -> &'static str {
match self.multiple() {
36 => "35 to 1",
3 => "2 to 1",
_ => "1 to 1",
}
}
}
/// A player's table: the bet chosen, where the wheel stopped, the last
/// pocket and whether it won.
#[derive(Clone, Debug, Default)]
pub struct Table {
pub bet: Option<Bet>,
/// The wheel's index under the pointer.
pub at: usize,
pub last: Option<(u8, bool)>,
}
/// A spin: drawn when it starts, paid when its last picture is shown.
#[derive(Clone, Debug)]
pub struct Spin {
pub stake: Stake,
pub bet: Bet,
/// The wheel's index under the pointer at the start, and where it stops.
pub from: usize,
pub stop: usize,
/// Pockets the wheel passes: two turns and the way to `stop`.
pub total: i64,
pub started: i64,
}
impl Spin {
/// The wheel's index under the pointer at frame `f` (0..=FRAMES): an
/// ease-out, fast first, the last pockets one by one.
pub fn position(&self, f: i64) -> usize {
let f = f.clamp(0, FRAMES);
let done = self.total * (FRAMES * FRAMES - (FRAMES - f) * (FRAMES - f)) / (FRAMES * FRAMES);
(self.from + done as usize) % 37
}
fn frame(&self, now: i64) -> i64 {
((now - self.started) / FRAME_MS).clamp(0, FRAMES)
}
#[cfg(test)]
pub fn test(stake: Stake) -> Self {
Self {
stake,
bet: Bet::Red,
from: 0,
stop: 0,
total: 74,
started: 0,
}
}
}
/// The spins under way.
#[derive(Default)]
pub struct Roulette {
pub rounds: Vec<Spin>,
}
impl Roulette {
pub fn busy(&self) -> bool {
!self.rounds.is_empty()
}
pub fn playing(&self, uuid: u128) -> bool {
self.rounds.iter().any(|s| s.stake.uuid == uuid)
}
/// The bet is held: the pocket is drawn now, the wheel starts at `from`.
pub fn spin(&mut self, stake: Stake, bet: Bet, from: usize, now: i64, rng: &mut Rng) {
let stop = rng.below(37) as usize;
let from = from % 37;
self.rounds.push(Spin {
stake,
bet,
from,
stop,
total: 74 + ((stop + 37 - from) % 37) as i64,
started: now,
});
}
/// The picture `uuid`'s spin is at.
pub fn frame(&self, uuid: u128, now: i64) -> Option<i64> {
self.rounds
.iter()
.find(|s| s.stake.uuid == uuid)
.map(|s| s.frame(now))
}
/// The spins whose wheel has stopped, each with its pocket: taken out,
/// to be paid once.
pub fn ended(&mut self, now: i64) -> Vec<(Spin, u8)> {
let mut out = Vec::new();
let mut i = 0;
while i < self.rounds.len() {
if now - self.rounds[i].started >= FRAMES * FRAME_MS {
let s = self.rounds.swap_remove(i);
let n = WHEEL[s.stop];
out.push((s, n));
} else {
i += 1;
}
}
out
}
/// The table's cells (all but the bottom row).
pub fn layout(&self, cells: &mut [Option<Icon>], uuid: u128, seat: &Seat, now: i64) {
let t = &seat.roulette;
if seat.view == View::RouletteNumbers {
for n in 1..=36u8 {
cells[usize::from(n) - 1] = pocket(n, Some(Bet::Number(n)) == t.bet);
}
for c in &mut cells[36..45] {
*c = filler("minecraft:gray_stained_glass_pane");
}
cells[36] = icon("minecraft:arrow", "Back to the table", Color::White);
cells[40] = pocket(0, t.bet == Some(Bet::Number(0)));
return;
}
let spin = self.rounds.iter().find(|s| s.stake.uuid == uuid);
let at = spin.map_or(t.at, |s| s.position(s.frame(now)));
for c in &mut cells[..9] {
*c = filler("minecraft:black_stained_glass_pane");
}
let pointer = match (spin, t.last) {
(Some(_), _) => "The wheel turns...".to_owned(),
(None, Some((n, true))) => format!("{}: you won!", pocket_name(n)),
(None, Some((n, false))) => format!("{}: no luck", pocket_name(n)),
(None, None) => "The ball lands under this arrow".to_owned(),
};
cells[4] = icon("minecraft:hopper", pointer, Color::Yellow);
for k in 0..9 {
let n = WHEEL[(at + 37 + k - 4) % 37];
cells[9 + k] = pocket(n, false);
}
let bets: [(usize, Bet, &'static str); 15] = [
(18, Bet::Red, "minecraft:red_wool"),
(19, Bet::Black, "minecraft:black_wool"),
(20, Bet::Odd, "minecraft:white_wool"),
(21, Bet::Even, "minecraft:gray_wool"),
(22, Bet::Low, "minecraft:cyan_terracotta"),
(23, Bet::High, "minecraft:purple_terracotta"),
(24, Bet::Dozen(1), "minecraft:light_blue_concrete"),
(25, Bet::Dozen(2), "minecraft:light_blue_concrete"),
(26, Bet::Dozen(3), "minecraft:light_blue_concrete"),
(27, Bet::Column(1), "minecraft:yellow_concrete"),
(28, Bet::Column(2), "minecraft:yellow_concrete"),
(29, Bet::Column(3), "minecraft:yellow_concrete"),
(33, Bet::Number(0), "minecraft:lime_concrete"),
(30, Bet::Low, ""),
(31, Bet::Low, ""),
];
for (slot, bet, item) in bets {
if item.is_empty() {
continue;
}
let chosen = t.bet == Some(bet);
let name = format!(
"{}{} (pays {})",
if chosen { "» " } else { "" },
bet.label(),
bet.odds()
);
let mut i = icon(
item,
name,
if chosen { Color::Yellow } else { Color::White },
);
if let (Some(i), Bet::Dozen(k) | Bet::Column(k)) = (i.as_mut(), bet) {
i.count = k;
}
cells[slot] = i;
}
for slot in [30, 32, 34, 35] {
cells[slot] = filler("minecraft:gray_stained_glass_pane");
}
let number = match t.bet {
Some(Bet::Number(n)) if n > 0 => format!("» Number {n} (pays 35 to 1): pick another"),
_ => "A single number (pays 35 to 1)...".into(),
};
cells[31] = icon("minecraft:target", number, Color::White);
let shade = match (spin.is_some(), t.last) {
(false, Some((_, true))) => "minecraft:lime_stained_glass_pane",
(false, Some((_, false))) => "minecraft:red_stained_glass_pane",
_ => "minecraft:gray_stained_glass_pane",
};
for c in &mut cells[36..45] {
*c = filler(shade);
}
cells[40] = icon(
"minecraft:paper",
match t.bet {
Some(b) => format!("Your bet: {} (pays {})", b.label(), b.odds()),
None => "Pick a bet above, then spin".into(),
},
Color::White,
);
cells[53] = if spin.is_some() {
icon("minecraft:gray_concrete", "The wheel turns...", Color::Gray)
} else {
icon("minecraft:lime_concrete", "SPIN", Color::Green)
};
}
}
/// A pocket as a cell: its colour, its number as the count.
fn pocket(n: u8, chosen: bool) -> Option<Icon> {
let item = match n {
0 => "minecraft:lime_concrete",
n if is_red(n) => "minecraft:red_concrete",
_ => "minecraft:black_concrete",
};
let name = format!("{}{}", if chosen { "» " } else { "" }, pocket_name(n));
let mut i = icon(
item,
name,
if chosen { Color::Yellow } else { Color::White },
);
if let Some(i) = i.as_mut() {
i.count = n.max(1);
}
i
}
/// What a click on the table does.
#[derive(Debug, PartialEq)]
pub enum Click {
Nothing,
Show,
Spin,
}
/// A click on the table (not its bottom row).
pub fn click(seat: &mut Seat, slot: usize) -> Click {
let t = &mut seat.roulette;
if seat.view == View::RouletteNumbers {
let bet = match slot {
0..36 => Bet::Number(slot as u8 + 1),
40 => Bet::Number(0),
36 => {
seat.view = View::Roulette;
return Click::Show;
}
_ => return Click::Nothing,
};
t.bet = Some(bet);
seat.view = View::Roulette;
return Click::Show;
}
let bet = match slot {
18 => Bet::Red,
19 => Bet::Black,
20 => Bet::Odd,
21 => Bet::Even,
22 => Bet::Low,
23 => Bet::High,
24..=26 => Bet::Dozen(slot as u8 - 23),
27..=29 => Bet::Column(slot as u8 - 26),
33 => Bet::Number(0),
31 => {
seat.view = View::RouletteNumbers;
return Click::Show;
}
53 => return Click::Spin,
_ => return Click::Nothing,
};
t.bet = Some(bet);
Click::Show
}
#[cfg(test)]
mod tests {
use super::super::smp_casino::{Fx, Game, POT, tests::*};
use super::*;
#[test]
fn the_wheel_holds_each_number_once_and_the_colours_alternate() {
let mut seen = WHEEL.to_vec();
seen.sort_unstable();
assert_eq!(seen, (0..=36).collect::<Vec<u8>>());
for pair in WHEEL[1..].windows(2) {
assert_ne!(is_red(pair[0]), is_red(pair[1]), "{pair:?}");
}
}
#[test]
fn every_bet_pays_its_true_odds_and_the_edge_is_one_in_37() {
let mut bets = vec![
Bet::Red,
Bet::Black,
Bet::Odd,
Bet::Even,
Bet::Low,
Bet::High,
];
bets.extend((1..=3).flat_map(|k| [Bet::Dozen(k), Bet::Column(k)]));
bets.extend((0..=36).map(Bet::Number));
for bet in bets {
let winners = (0..=36u8).filter(|n| bet.wins(*n)).count() as i64;
assert_eq!(
winners * bet.multiple(),
36,
"{bet:?}: 36 of 37 coins paid back"
);
// Over the 37 pockets, a stake of 37 x 100 gets back 36 x 100.
let back: i64 = (0..=36).map(|n| bet.payout(n, 100)).sum();
assert_eq!(back, 3600, "{bet:?}");
}
assert!(!Bet::Red.wins(0) && !Bet::Even.wins(0) && !Bet::Low.wins(0));
assert!(Bet::Column(1).wins(34) && Bet::Column(3).wins(36) && Bet::Dozen(3).wins(25));
assert_eq!(Bet::Number(17).payout(17, 10), 360);
assert_eq!(Bet::Dozen(2).payout(13, 10), 30);
assert_eq!(Bet::Black.payout(17, 10), 20);
}
#[test]
fn the_wheel_slows_down_and_lands_on_the_drawn_pocket() {
let mut rng = Rng::from_seed([3; 32]);
for from in [0, 5, 36] {
for _ in 0..50 {
let mut r = Roulette::default();
let stake = Stake {
uuid: A,
name: "Alex".into(),
game: Game::Roulette,
amount: 10,
};
r.spin(stake, Bet::Red, from, 0, &mut rng);
let s = &r.rounds[0];
assert_eq!(s.position(0), from);
assert_eq!(s.position(FRAMES), s.stop);
let steps: Vec<i64> = (1..=FRAMES)
.map(|f| {
let done = |f: i64| {
s.total * (FRAMES * FRAMES - (FRAMES - f).pow(2)) / (FRAMES * FRAMES)
};
done(f) - done(f - 1)
})
.collect();
assert!(
steps.windows(2).all(|w| w[0] >= w[1] - 1),
"slows down: {steps:?}"
);
assert!(steps[0] >= 7 && *steps.last().unwrap() <= 1, "{steps:?}");
}
}
}
#[test]
fn a_spin_through_the_menu_pays_once_even_when_the_menu_closes() {
let (mut e, mut c, dir) = setup("roulette", 50_000);
let before = money(&e);
c.sit(A, "Alex", View::Roulette);
c.seats.get_mut(&A).unwrap().window = Some(3);
assert!(
c.click(&mut e, A, 53, false, 0)
.iter()
.any(|f| matches!(f, Fx::Bar { .. })),
"no bet yet"
);
c.click(&mut e, A, 49, false, 0);
assert_eq!(c.seats[&A].stake, 25);
c.click(&mut e, A, 18, false, 0);
assert_eq!(c.seats[&A].roulette.bet, Some(Bet::Red));
c.click(&mut e, A, 53, false, 0);
// A double click: the second spin is refused, nothing more held.
c.click(&mut e, A, 53, false, 50);
assert_eq!(e.held(A), 25);
// Frames: one picture per 200 ms, not per tick.
let shows = |fx: &[Fx]| fx.iter().filter(|f| matches!(f, Fx::Show(A))).count();
assert_eq!(
shows(&c.tick(&mut e, 10)),
0,
"the spin's click showed frame 0"
);
assert_eq!(shows(&c.tick(&mut e, 210)), 1);
assert_eq!(shows(&c.tick(&mut e, 260)), 0);
assert_eq!(shows(&c.tick(&mut e, 400)), 1);
// The menu closes mid-spin: the round still ends and is paid, once.
assert!(c.closed(A, 3, 0));
let n = WHEEL[c.roulette.rounds[0].stop];
c.tick(&mut e, 3999);
assert_eq!(e.held(A), 25, "not before the wheel stops");
c.tick(&mut e, 4000);
c.tick(&mut e, 4200);
assert_eq!(e.held(A), 0);
let expected = 10_000 - 25 + Bet::Red.payout(n, 25);
assert_eq!(e.balance(A), expected);
assert_eq!(money(&e), before);
assert_eq!(c.ledger.totals["roulette"].rounds, 1);
assert!(!c.animating());
let _ = e.pool(POT);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn many_spins_keep_about_the_house_edge() {
let (mut e, mut c, dir) = setup("edge", 1_000_000_000);
c.config.daily_loss_cap = i64::MAX / 4;
c.config.daily_win_cap = i64::MAX / 4;
c.config.cooldown_ms = 0;
let mut rng = Rng::from_seed([3; 32]);
let mut now = 0;
for i in 0..20_000 {
if e.balance(A) < 100 {
e.adjust("eco_give", A, 1_000_000, "test").unwrap();
}
let stake = c.bet(&mut e, A, "Alex", Game::Roulette, 10, now).unwrap();
// Even-money bets: little spread, so 20,000 spins show the edge.
let bet = if i % 2 == 0 { Bet::Red } else { Bet::Odd };
c.roulette.spin(stake, bet, 0, now, &mut rng);
now += FRAMES * FRAME_MS;
c.tick(&mut e, now);
}
let t = &c.ledger.totals["roulette"];
let rtp = t.paid as f64 / t.wagered as f64;
assert!((0.94..1.0).contains(&rtp), "{rtp}");
std::fs::remove_dir_all(dir).unwrap();
}
}
+298
View File
@@ -0,0 +1,298 @@
// The casino's slot machine: three reels of 32 stops with item symbols, one
// payline (the middle row). Each reel's stop is drawn when the player pulls
// the lever; the reels then scroll in the menu and stop one after another
// (at 1.6, 2.4 and 3.2 s), and the line is paid from the published paytable:
// three of a kind, or two berries anywhere on it. The reels hold each symbol
// a fixed number of times, so the odds are exact: 94.2% of what is staked is
// paid back on average (the test enumerates all 32,768 lines), and one pull
// in 4.5 pays. Closing the menu or leaving doesn't stop a pull: it ends and
// is paid all the same. Added to the game as `game::smp_casino_slots`; also
// a module of this crate, for its tests.
use super::smp_casino::{Color, FRAME_MS, Icon, Rng, Seat, Stake, filler, icon};
/// The symbols: item, name, stops on each reel, what three on the line pay
/// for each coin staked (the stake included).
pub const SYMBOLS: [(&str, &str, usize, i64); 6] = [
("minecraft:sweet_berries", "Berries", 9, 6),
("minecraft:melon_slice", "Melon", 8, 10),
("minecraft:golden_apple", "Golden Apple", 7, 15),
("minecraft:emerald", "Emerald", 5, 30),
("minecraft:diamond", "Diamond", 2, 100),
("minecraft:nether_star", "Nether Star", 1, 500),
];
/// Two berries on the line (not three) pay this for each coin staked.
pub const TWO_BERRIES: i64 = 2;
const BERRIES: u8 = 0;
pub const STOPS: usize = 32;
/// The reels, by stop: indices into `SYMBOLS`, no symbol twice in a row.
pub const REELS: [[u8; STOPS]; 3] = [
[
1, 0, 2, 3, 1, 4, 0, 1, 3, 1, 0, 5, 1, 2, 0, 1, 0, 2, 0, 2, 3, 2, 3, 2, 0, 1, 2, 1, 0, 4,
3, 0,
],
[
1, 5, 0, 1, 0, 1, 2, 3, 1, 0, 2, 3, 0, 3, 2, 4, 2, 1, 0, 1, 2, 0, 3, 2, 4, 1, 0, 2, 3, 0,
1, 0,
],
[
0, 1, 4, 3, 1, 0, 2, 0, 2, 3, 2, 0, 3, 0, 2, 1, 0, 4, 1, 2, 1, 2, 1, 3, 0, 1, 0, 2, 3, 0,
1, 5,
],
];
/// The frame each reel stops at; the pull is paid at the last.
pub const STOP_AT: [i64; 3] = [8, 12, 16];
pub const FRAMES: i64 = 16;
pub const HELP: [&str; 2] = [
"Slots (3 reels of 32 stops, one line): three Nether Stars pay 500x, Diamonds 100x, Emeralds 30x, Golden Apples 15x, Melons 10x, Berries 6x; two Berries 2x (the stake included).",
"Each reel holds 9 Berries, 8 Melons, 7 Golden Apples, 5 Emeralds, 2 Diamonds, 1 Nether Star: 94.2% paid back on average (house edge 5.8%), one pull in 4.5 pays.",
];
/// What a line pays for each coin staked: 0 when it loses.
pub fn multiple(line: [u8; 3]) -> i64 {
if line[0] == line[1] && line[1] == line[2] {
return SYMBOLS[usize::from(line[0])].3;
}
if line.iter().filter(|s| **s == BERRIES).count() == 2 {
return TWO_BERRIES;
}
0
}
pub fn line(stops: [usize; 3]) -> [u8; 3] {
[0, 1, 2].map(|r| REELS[r][stops[r] % STOPS])
}
/// "Diamond · Diamond · Melon".
pub fn line_name(stops: [usize; 3]) -> String {
line(stops).map(|s| SYMBOLS[usize::from(s)].1).join(" · ")
}
/// A player's machine: where its reels stand and whether the last pull won.
#[derive(Clone, Debug, Default)]
pub struct Machine {
pub stops: [usize; 3],
pub last: Option<bool>,
}
/// A pull: drawn when it starts, paid when the last reel stops.
#[derive(Clone, Debug)]
pub struct Pull {
pub stake: Stake,
pub stops: [usize; 3],
pub started: i64,
}
impl Pull {
fn frame(&self, now: i64) -> i64 {
((now - self.started) / FRAME_MS).clamp(0, FRAMES)
}
/// Reel `r`'s stop at frame `f`: scrolling, slower as it nears its stop.
pub fn position(&self, r: usize, f: i64) -> usize {
let left = (STOP_AT[r] - f).max(0);
let behind = (left * (left + 1) / 2) as usize;
(self.stops[r] + STOPS * 8 - behind % STOPS) % STOPS
}
}
#[derive(Default)]
pub struct Slots {
pub rounds: Vec<Pull>,
}
impl Slots {
pub fn busy(&self) -> bool {
!self.rounds.is_empty()
}
pub fn playing(&self, uuid: u128) -> bool {
self.rounds.iter().any(|p| p.stake.uuid == uuid)
}
pub fn pull(&mut self, stake: Stake, now: i64, rng: &mut Rng) {
let stops = [0; 3].map(|_| rng.below(STOPS as u64) as usize);
self.rounds.push(Pull {
stake,
stops,
started: now,
});
}
/// The picture `uuid`'s pull is at, and whether a reel stops on it.
pub fn frame(&self, uuid: u128, now: i64) -> Option<(i64, bool)> {
let p = self.rounds.iter().find(|p| p.stake.uuid == uuid)?;
let f = p.frame(now);
Some((f, STOP_AT.contains(&f)))
}
/// The pulls whose last reel stopped: taken out, to be paid once.
pub fn ended(&mut self, now: i64) -> Vec<Pull> {
let mut out = Vec::new();
let mut i = 0;
while i < self.rounds.len() {
if now - self.rounds[i].started >= FRAMES * FRAME_MS {
out.push(self.rounds.swap_remove(i));
} else {
i += 1;
}
}
out
}
/// The machine's cells (all but the bottom row).
pub fn layout(&self, cells: &mut [Option<Icon>], uuid: u128, seat: &Seat, now: i64) {
let m = &seat.slots;
let pull = self.rounds.iter().find(|p| p.stake.uuid == uuid);
for c in &mut cells[..45] {
*c = filler("minecraft:black_stained_glass_pane");
}
for c in &mut cells[..9] {
*c = filler("minecraft:yellow_stained_glass_pane");
}
let top = match (pull, m.last) {
(Some(_), _) => "The reels turn...".to_owned(),
(None, Some(true)) => format!("{}: you won!", line_name(m.stops)),
(None, Some(false)) => format!("{}: no luck", line_name(m.stops)),
(None, None) => "Three of a kind on the middle row wins".to_owned(),
};
cells[4] = icon("minecraft:bell", top, Color::Yellow);
let won = pull.is_none() && m.last == Some(true);
let marker = if won {
"minecraft:gold_block"
} else {
"minecraft:lime_stained_glass_pane"
};
cells[18] = icon(marker, "Payline →", Color::Green);
cells[26] = icon(marker, "← Payline", Color::Green);
for (r, col) in [2usize, 4, 6].into_iter().enumerate() {
let at = match pull {
Some(p) => p.position(r, p.frame(now)),
None => m.stops[r],
};
for (row, d) in [(1usize, STOPS - 1), (2, 0), (3, 1)] {
let s = SYMBOLS[usize::from(REELS[r][(at + d) % STOPS])];
let name = if row == 2 {
s.1.to_owned()
} else {
" ".to_owned()
};
cells[row * 9 + col] = icon(
s.0,
name,
if row == 2 {
Color::White
} else {
Color::DarkGray
},
);
}
}
// The paytable, on the fifth row.
for (i, (item, name, _, pays)) in SYMBOLS.iter().enumerate().rev() {
cells[36 + (5 - i)] = icon(item, format!("3 {name}: {pays}x"), Color::Gold);
}
cells[42] = icon(
"minecraft:sweet_berries",
format!("2 Berries: {TWO_BERRIES}x"),
Color::Gold,
);
cells[43] = icon("minecraft:book", "94.2% paid back on average", Color::Aqua);
cells[53] = if pull.is_some() {
icon("minecraft:gray_concrete", "The reels turn...", Color::Gray)
} else {
icon("minecraft:lever", "PULL", Color::Green)
};
}
}
#[cfg(test)]
mod tests {
use super::super::smp_casino::{Fx, Game, View, tests::*};
use super::*;
#[test]
fn each_reel_holds_the_published_stops() {
for reel in REELS {
for (i, s) in SYMBOLS.iter().enumerate() {
assert_eq!(
reel.iter().filter(|x| usize::from(**x) == i).count(),
s.2,
"{}",
s.1
);
}
for k in 0..STOPS {
assert_ne!(reel[k], reel[(k + 1) % STOPS], "no symbol twice in a row");
}
}
assert_eq!(SYMBOLS.iter().map(|s| s.2).sum::<usize>(), STOPS);
}
#[test]
fn the_paytable_pays_back_94_2_percent() {
let (mut back, mut hits) = (0i64, 0);
for a in 0..STOPS {
for b in 0..STOPS {
for c in 0..STOPS {
let m = multiple(line([a, b, c]));
back += m;
hits += i64::from(m > 0);
}
}
}
let lines = (STOPS * STOPS * STOPS) as f64;
let rtp = back as f64 / lines;
assert!((0.92..=0.95).contains(&rtp), "{rtp}");
assert_eq!(format!("{:.1}", rtp * 100.0), "94.2");
assert_eq!(format!("{:.1}", lines / hits as f64), "4.5");
assert_eq!(multiple([5, 5, 5]), 500);
assert_eq!(multiple([0, 3, 0]), 2);
assert_eq!(multiple([0, 0, 0]), 6);
assert_eq!(multiple([0, 3, 4]), 0);
}
#[test]
fn the_reels_scroll_and_stop_one_after_another_on_the_draw() {
let p = Pull {
stake: Stake {
uuid: A,
name: "Alex".into(),
game: Game::Slots,
amount: 10,
},
stops: [3, 17, 31],
started: 0,
};
for (r, stop) in STOP_AT.into_iter().enumerate() {
assert_ne!(p.position(r, stop - 1), p.stops[r], "still turning");
for f in stop..=FRAMES {
assert_eq!(p.position(r, f), p.stops[r]);
}
}
}
#[test]
fn a_pull_is_paid_once_and_frames_stay_at_5_a_second() {
let (mut e, mut c, dir) = setup("slots", 100_000);
let before = money(&e);
c.sit(A, "Alex", View::Slots);
c.seats.get_mut(&A).unwrap().window = Some(5);
c.click(&mut e, A, 53, false, 0);
c.click(&mut e, A, 53, false, 30);
assert_eq!(e.held(A), 10, "the double click pulled once");
let mut shows = 0;
for t in (0..FRAMES * FRAME_MS).step_by(50) {
shows += c
.tick(&mut e, t)
.iter()
.filter(|f| matches!(f, Fx::Show(A)))
.count();
}
assert_eq!(shows as i64, FRAMES - 1, "one picture per frame");
let stops = c.slots.rounds[0].stops;
c.tick(&mut e, FRAMES * FRAME_MS);
assert_eq!(e.held(A), 0);
assert_eq!(e.balance(A), 10_000 - 10 + 10 * multiple(line(stops)));
assert_eq!(money(&e), before);
assert!(!c.animating());
std::fs::remove_dir_all(dir).unwrap();
}
}
+10 -1
View File
@@ -271,8 +271,17 @@ 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.
fn held(&self, uuid: u128) -> usize {
pub fn held(&self, uuid: u128) -> usize {
self.owners
.values()
.flat_map(|c| c.values())
+530 -140
View File
@@ -42,6 +42,49 @@ 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;
/// `economy.json`.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Accounts {
@@ -52,8 +95,60 @@ pub struct Accounts {
/// it twice. Given back when the economy opens (a restart voids games).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub held: BTreeMap<String, i64>,
/// Other parts' state that holds money or items (the auction house's
/// listings, bids and mail), by part: written in the same file as the
/// 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.
#[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`.
#[serde(default)]
pub flows: Flows,
}
/// Money the server made (`minted`) or took out of the game (`burned`),
/// today and the day before, by kind (the audit kind's first word).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Flows {
pub day: i64,
pub minted: BTreeMap<String, i64>,
pub burned: BTreeMap<String, i64>,
pub minted_yesterday: BTreeMap<String, i64>,
pub burned_yesterday: BTreeMap<String, i64>,
}
impl Flows {
/// Starts `day` (moving the last one to yesterday's when it was the day
/// before).
pub fn roll(&mut self, day: i64) {
if self.day == day {
return;
}
let next = self.day + 1 == day;
self.minted_yesterday = std::mem::take(&mut self.minted);
self.burned_yesterday = std::mem::take(&mut self.burned);
if !next {
self.minted_yesterday.clear();
self.burned_yesterday.clear();
}
self.day = day;
}
}
/// The state's pool: taxes, fees and house edges in; grants out.
pub const TREASURY: &str = "treasury";
/// Part of the state: covers what a liquidated position can't.
pub const INSURANCE: &str = "insurance";
/// A player as a command sees them.
#[derive(Clone, Debug, PartialEq)]
pub struct Player {
@@ -134,6 +229,22 @@ impl Economy {
.collect()
}
/// Money the server made and destroyed, by kind (rolled to `now`'s day).
pub fn flows(&mut self) -> &Flows {
self.accounts.flows.roll(self.now.div_euclid(DAY_MS));
&self.accounts.flows
}
/// All the money held for games.
pub fn held_total(&self) -> i64 {
self.accounts.held.values().sum()
}
/// Every pool, by name.
pub fn pools(&self) -> &BTreeMap<String, i64> {
&self.accounts.pools
}
/// Money `uuid` has held for a game.
pub fn held(&self, uuid: u128) -> i64 {
self.accounts
@@ -197,11 +308,17 @@ impl Economy {
if stakes.iter().any(|(u, a)| self.held(*u) < *a) || !(0..=total).contains(&prize) {
return Err("The stakes are not held.".into());
}
let fee = total - prize;
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);
@@ -212,6 +329,10 @@ 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(())
}
@@ -225,6 +346,273 @@ impl Economy {
}
}
/// Money in the pool `name` (0 for one never used).
pub fn pool(&self, name: &str) -> i64 {
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);
if *p == 0 {
self.accounts.pools.remove(name);
}
}
/// 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.
pub fn to_pool(
&mut self,
kind: &str,
uuid: u128,
pool: &str,
amount: i64,
reason: &str,
) -> Result<(), String> {
self.into_pool(kind, uuid, pool, amount, reason, "")
}
/// [`Self::to_pool`] by an operator (`by`: their UUID), logged so.
pub fn into_pool(
&mut self,
kind: &str,
uuid: u128,
pool: &str,
amount: i64,
reason: &str,
by: &str,
) -> Result<(), String> {
if amount <= 0 || amount > MAX_AMOUNT {
return Err("The amount must be a positive whole number.".into());
}
let key = uuid_string(uuid);
let Some(a) = self.accounts.accounts.get(&key) else {
return Err("No such account.".into());
};
if a.balance < amount {
return Err(format!("Only {} there.", self.money(a.balance)));
}
let mut entry = self.entry(kind, Some(uuid), None, amount, by);
entry["to_pool"] = pool.into();
entry["pool_before"] = self.pool(pool).into();
if !reason.is_empty() {
entry["reason"] = reason.into();
}
self.audit.append(&entry)?;
self.accounts
.accounts
.get_mut(&key)
.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),
/// logged as `kind`: never more than the pool holds.
#[allow(dead_code)] // the casino, the auction house and the futures
pub fn from_pool(
&mut self,
kind: &str,
pool: &str,
uuid: u128,
amount: i64,
reason: &str,
) -> Result<(), String> {
self.out_of_pool(kind, pool, uuid, amount, reason, "")
}
/// [`Self::from_pool`] by an operator (`by`: their UUID), logged so.
pub fn out_of_pool(
&mut self,
kind: &str,
pool: &str,
uuid: u128,
amount: i64,
reason: &str,
by: &str,
) -> Result<(), String> {
if amount <= 0 || amount > MAX_AMOUNT {
return Err("The amount must be a positive whole number.".into());
}
let key = uuid_string(uuid);
let Some(a) = self.accounts.accounts.get(&key) else {
return Err("No such account.".into());
};
if a.balance.saturating_add(amount) > MAX_AMOUNT * 1000 {
return Err("That account can't hold more.".into());
}
let held = self.pool(pool);
if held < amount {
return Err(format!("The {pool} only holds {}.", self.money(held)));
}
let mut entry = self.entry(kind, None, Some(uuid), amount, by);
entry["from_pool"] = pool.into();
entry["pool_before"] = held.into();
if !reason.is_empty() {
entry["reason"] = reason.into();
}
self.audit.append(&entry)?;
self.accounts
.accounts
.get_mut(&key)
.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
pub fn pool_move(
&mut self,
kind: &str,
from: &str,
to: &str,
amount: i64,
reason: &str,
) -> Result<(), String> {
if amount <= 0 || amount > MAX_AMOUNT * 1000 || from == to {
return Err("The amount must be a positive whole number.".into());
}
let held = self.pool(from);
if held < amount {
return Err(format!("The {from} only holds {}.", self.money(held)));
}
let mut entry = serde_json::json!({
"time": self.now, "kind": kind, "amount": amount,
"from_pool": from, "from_pool_before": held,
"to_pool": to, "to_pool_before": self.pool(to),
});
if !reason.is_empty() {
entry["reason"] = reason.into();
}
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(())
}
/// 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
pub fn settle_pool(
&mut self,
kind: &str,
uuid: u128,
stake: i64,
payout: i64,
pool: &str,
) -> Result<i64, String> {
if stake < 0 || payout < 0 || self.held(uuid) < stake {
return Err("The stake is not held.".into());
}
let before = self.pool(pool);
let paid = payout.min(before.saturating_add(stake));
let mut entry = self.entry(kind, None, Some(uuid), paid, "");
entry["stake"] = stake.into();
entry["pool"] = pool.into();
entry["pool_before"] = before.into();
entry["asked"] = payout.into();
self.audit.append(&entry)?;
let key = uuid_string(uuid);
self.take_held(&key, stake);
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)
}
/// A part's state kept with the balances (`Accounts::ext`).
pub fn ext(&self, part: &str) -> Option<&serde_json::Value> {
self.accounts.ext.get(part)
}
/// Replaces a part's state, written with the balances at the next save.
pub fn set_ext(&mut self, part: &str, value: serde_json::Value) {
self.accounts.ext.insert(part.to_owned(), value);
self.dirty = true;
}
/// Writes `economy.json` when it changed (end of a tick, world closing).
pub fn save(&mut self) -> Result<(), String> {
self.audit.sync();
@@ -403,6 +791,15 @@ impl Economy {
.get_mut(&key)
.expect("checked")
.balance = after;
let flows = &mut self.accounts.flows;
flows.roll(self.now.div_euclid(DAY_MS));
let family = kind.split(' ').next().unwrap_or(kind).to_owned();
let map = if delta >= 0 {
&mut flows.minted
} else {
&mut flows.burned
};
*map.entry(family).or_default() += delta.abs();
self.touch();
Ok(after)
}
@@ -427,12 +824,12 @@ impl Economy {
}
/// Runs `/balance`, `/bal`, `/pay`, `/baltop` or the operators' `/eco`
/// (and its other name `/money`); None for another command. `online`:
/// the players on, for `/eco give * <amount>`.
/// (and its other name `/money`); None for another command. `_online`:
/// the players on (no command uses it now that `/eco give *` is gone).
pub fn command(
&mut self,
me: &Player,
online: &[(u128, String)],
_online: &[(u128, String)],
line: &str,
) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
@@ -441,52 +838,11 @@ impl Economy {
"balance" | "bal" => self.balance_command(me, args),
"pay" => self.pay(me, args),
"baltop" => self.baltop(args),
"eco" | "money" => match args {
["give", "*", amount] => self.give_everyone(me, online, amount),
_ => self.eco(me, args),
},
"eco" | "money" => self.eco(me, args),
_ => return None,
})
}
/// `/eco give * <amount>`: to every player on, each change logged.
fn give_everyone(&mut self, me: &Player, online: &[(u128, String)], amount: &str) -> Vec<Out> {
if me.level < 3 {
return vec![Out::Error("Only operators of level 3 may use /eco.".into())];
}
let Some(amount) = parse_amount(amount) else {
return vec![Out::Error(
"The amount must be a positive whole number.".into(),
)];
};
let by = uuid_string(me.uuid);
let mut out = Vec::new();
let mut given = 0;
for (uuid, name) in online {
self.seen(*uuid, name);
match self.adjust("eco_give", *uuid, amount, &by) {
Ok(_) => {
given += 1;
if *uuid != me.uuid {
out.push(Out::Tell(
*uuid,
format!("An operator gave you {}.", self.money(amount)),
));
}
}
Err(e) => out.push(Out::Error(format!("{name}: {e}"))),
}
}
out.insert(
0,
Out::Reply(format!(
"Gave {} to {given} player(s) on.",
self.money(amount)
)),
);
out
}
fn balance_command(&mut self, me: &Player, args: &[&str]) -> Vec<Out> {
match args {
[] => vec![Out::Reply(format!(
@@ -549,50 +905,41 @@ 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.
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())];
}
let [verb, who, amount] = args else {
return vec![Out::Error(
"Usage: /eco give|take|set <player> <amount>, /eco give * <amount>".into(),
)];
};
let amount = match amount.parse::<i64>() {
Ok(a) if (0..=MAX_AMOUNT).contains(&a) => a,
_ => {
return vec![Out::Error(
"The amount must be a whole number from 0.".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 delta = match *verb {
"give" => amount,
"take" => -amount,
"set" => amount - account.balance,
_ => {
return vec![Out::Error(
"Usage: /eco give|take|set <player> <amount>, /eco give * <amount>".into(),
)];
}
};
let by = uuid_string(me.uuid);
match self.adjust(&format!("eco_{verb}"), uuid, delta, &by) {
Ok(after) => {
let mut out = vec![Out::Reply(format!("{name} now has {}.", self.money(after)))];
if *verb == "give" && uuid != me.uuid && amount > 0 {
out.push(Out::Tell(
uuid,
format!("An operator gave you {}.", self.money(amount)),
));
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)],
}
out
}
Err(e) => vec![Out::Error(e)],
_ => vec![Out::Error(USAGE.into())],
}
}
}
@@ -720,77 +1067,120 @@ mod tests {
}
#[test]
fn money_is_eco_and_give_star_pays_everyone_on() {
let (mut e, dir) = economy("money");
let op = player(ALEX, "Alex", 3);
fn nobody_makes_money_and_eco_take_goes_to_the_treasury() {
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(&op, &[], "money give Steve 7").unwrap()[0],
Out::Reply("Steve now has 107 coins.".into())
);
let online = [
(ALEX, "Alex".to_owned()),
(STEVE, "Steve".to_owned()),
(99, "Newcomer".to_owned()),
];
let out = e.command(&op, &online, "eco give * 1,000").unwrap();
assert_eq!(
out[0],
Out::Reply("Gave 1,000 coins to 3 player(s) on.".into()),
"{out:?}"
);
assert!(out.contains(&Out::Tell(99, "An operator gave you 1,000 coins.".into())));
assert!(
!out.iter()
.any(|o| matches!(o, Out::Tell(u, _) if *u == ALEX))
);
assert_eq!(
(e.balance(ALEX), e.balance(STEVE), e.balance(99)),
(1100, 1107, 1100)
);
let gives = log_lines(&dir)
.iter()
.filter(|l| l["kind"] == "eco_give")
.count();
assert_eq!(gives, 4);
assert_eq!(
e.command(&player(STEVE, "Steve", 0), &online, "money give * 5")
e.command(&player(ALEX, "Alex", 2), &[], "eco take Steve 5")
.unwrap(),
[Out::Error("Only operators of level 3 may use /eco.".into())]
);
assert!(matches!(
&e.command(&op, &online, "eco give * -5").unwrap()[0],
Out::Error(_)
));
assert_eq!(
e.command(&op, &[], "eco take Steve 200").unwrap(),
[Out::Error("Only 100 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()
)]
);
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!(
(m.player.as_str(), m.reason.as_str(), m.by.as_str()),
("Steve", "duped diamonds", "Alex")
);
let log = log_lines(&dir);
let last = log.last().unwrap();
assert_eq!(
(last["kind"].as_str(), last["to_pool"].as_str()),
(Some("eco_take"), Some("treasury"))
);
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);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn eco_is_for_level_3_and_never_goes_below_zero() {
let (mut e, dir) = economy("eco");
let alex = player(ALEX, "Alex", 2);
assert_eq!(
e.command(&alex, &[], "eco give Steve 5").unwrap(),
[Out::Error("Only operators of level 3 may use /eco.".into())]
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();
assert!(
e.to_pool("vat", ALEX, TREASURY, 71, "").is_err(),
"more than Alex has"
);
let op = player(ALEX, "Alex", 3);
assert_eq!(
e.command(&op, &[], "eco give Steve 5").unwrap(),
[
Out::Reply("Steve now has 105 coins.".into()),
Out::Tell(STEVE, "An operator gave you 5 coins.".into())
]
assert!(
e.from_pool("grant", TREASURY, STEVE, 31, "event").is_err(),
"more than the Treasury holds"
);
e.from_pool("grant", TREASURY, STEVE, 10, "event prize")
.unwrap();
e.pool_move("insurance_fund", TREASURY, INSURANCE, 5, "")
.unwrap();
assert_eq!((e.pool(TREASURY), e.pool(INSURANCE)), (15, 5));
assert_eq!((e.balance(ALEX), e.balance(STEVE)), (70, 110));
let t = e.treasury();
assert_eq!(
e.command(&op, &[], "eco take Steve 200").unwrap(),
[Out::Error("Only 105 coins there.".into())]
(t.income["vat"], t.spent["grant"], t.spent["insurance_fund"]),
(30, 10, 5)
);
assert_eq!(t.income_today["vat"], 30);
// 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);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn settle_pool_never_pays_more_than_the_pool_and_award_fees_go_to_the_treasury() {
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.
assert_eq!(
e.command(&op, &[], "eco set Steve 1234567").unwrap(),
[Out::Reply("Steve now has 1,234,567 coins.".into())]
e.settle_pool("roulette", ALEX, 50, 1000, "casino").unwrap(),
50
);
assert_eq!((e.balance(ALEX), e.pool("casino")), (100, 0));
e.hold("bet", ALEX, 40).unwrap();
assert_eq!(e.settle_pool("roulette", ALEX, 40, 0, "casino").unwrap(), 0);
assert_eq!((e.balance(ALEX), e.pool("casino")), (60, 40));
assert!(
e.settle_pool("roulette", ALEX, 10, 0, "casino").is_err(),
"nothing held"
);
// A coinflip with a 10% fee: the fee is the Treasury's.
e.hold("coinflip_stake", ALEX, 50).unwrap();
e.hold("coinflip_stake", STEVE, 50).unwrap();
e.award("coinflip_win", &[(ALEX, 50), (STEVE, 50)], STEVE, 90)
.unwrap();
assert_eq!(
(e.balance(ALEX), e.balance(STEVE), e.pool(TREASURY)),
(10, 140, 10)
);
assert_eq!(e.treasury().income["gambling_fee"], 10);
// Nothing made: 200 at the start.
assert_eq!(
e.balance(ALEX) + e.balance(STEVE) + e.pool(TREASURY) + e.pool("casino"),
200
);
let log = log_lines(&dir);
assert_eq!(log.last().unwrap()["kind"], "eco_set");
assert_eq!(log.last().unwrap()["by"], uuid_string(ALEX));
fs::remove_dir_all(dir).unwrap();
}
File diff suppressed because it is too large. Load diff
+631
View File
@@ -0,0 +1,631 @@
// `/invsee <player>` and `/endersee <player>`: an operator of level 3 sees
// another player's inventory (main, hotbar, armour, off hand) or ender chest
// in a chest menu, on or off the server, and moves stacks in and out: a click
// on one of theirs takes it into the operator's inventory, a click on one of
// the operator's own puts it into theirs. One stack moves at a time, and only
// if it is still exactly what the menu showed (the game checks it and moves
// it whole, or nothing): no copy, no loss, whatever the player does
// meanwhile (moving items, leaving, joining, dying). Each move is in the
// audit log; closing the menu writes a one-line summary for the staff. The
// menu is read again once a second while it is open; nothing runs when no
// menu is open. No game types: the stacks are a type parameter, the game
// lends its inventories as a `World`. Added to the game as
// `game::smp_invsee`; also a module of this crate, for its tests.
use super::smp_economy::{Out, Player};
use std::collections::HashMap;
pub const COMMANDS: [&str; 2] = ["invsee", "endersee"];
/// The level that may look.
pub const LEVEL: u8 = 3;
pub const ROWS: u8 = 6;
pub const SIZE: usize = 54;
/// The bottom row's buttons.
const INFO: usize = 49;
const CLOSE: usize = 53;
/// Which inventory: the player's 41 slots (0..36 main, 36..40 armour feet to
/// head, 40 off hand), or the ender chest's 27.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Player,
Ender,
}
/// A stack's item and count, for the log.
pub trait Stack: Clone + PartialEq {
fn id(&self) -> String;
fn count(&self) -> u32;
}
/// The game's inventories (the glue's, over the native helpers).
pub trait World<S> {
/// A player's inventory and whether it is live (they are on): Err while
/// they log in, are dead, or never played here.
fn read(&self, uuid: u128, kind: Kind) -> Result<(Vec<Option<S>>, bool), String>;
/// One stack from `from`'s slot, still exactly `expected`, into `to`'s
/// inventory where it fits whole, or nothing moves.
fn move_stack(
&mut self,
from: (u128, Kind, usize),
expected: &S,
to: (u128, Kind),
) -> Result<(), String>;
}
/// One operator's open menu.
#[derive(Clone, Debug)]
pub struct View<S> {
pub target: u128,
pub name: String,
pub kind: Kind,
pub window: Option<i32>,
/// What the menu shows, by inventory slot.
pub shown: Vec<Option<S>>,
pub online: bool,
/// Why the inventory can't be read now (logging in, dead...).
pub busy: Option<String>,
/// What moved: (taken from the player, item, count).
pub moved: Vec<(bool, String, u32)>,
}
/// A cell of the menu.
#[derive(Clone, Debug, PartialEq)]
pub enum Cell<S> {
Empty,
Stack(S),
Filler,
/// The player's name, on or off, and why it can't be read now.
Info {
name: String,
online: bool,
busy: Option<String>,
},
/// Above an armour or off-hand slot.
Label(&'static str),
Close,
}
/// A move the glue logs before it asks the game for it.
#[derive(Clone, Debug, PartialEq)]
pub struct Move {
pub op: u128,
pub target: u128,
pub kind: Kind,
/// Taken from the player (true) or put into their inventory.
pub take: bool,
pub item: String,
pub count: u32,
}
/// The menu slot of each inventory slot. Player: the main inventory in the
/// first three rows, the hotbar in the fourth (as the player sees it), the
/// armour head to feet then the off hand in the fifth.
pub fn menu_slot(kind: Kind, slot: usize) -> Option<usize> {
match (kind, slot) {
(Kind::Player, 9..36) => Some(slot - 9),
(Kind::Player, 0..9) => Some(27 + slot),
(Kind::Player, 36..40) => Some(36 + (39 - slot)),
(Kind::Player, 40) => Some(41),
(Kind::Ender, 0..27) => Some(slot),
_ => None,
}
}
/// The inventory slot a menu slot shows.
pub fn inventory_slot(kind: Kind, menu: usize) -> Option<usize> {
let size = match kind {
Kind::Player => 41,
Kind::Ender => 27,
};
(0..size).find(|&s| menu_slot(kind, s) == Some(menu))
}
pub struct Invsee<S> {
pub views: HashMap<u128, View<S>>,
}
impl<S> Default for Invsee<S> {
fn default() -> Self {
Self {
views: HashMap::new(),
}
}
}
/// What a command asks to open: the player (UUID, real name) and which inventory.
pub type Open = Option<(u128, String, Kind)>;
impl<S: Stack> Invsee<S> {
/// Runs `/invsee` or `/endersee`: the target named, to open at the next
/// tick (the game's inventories aren't reachable inside a command).
/// `find`: a player who has played here by name (UUID, real name).
pub fn command(
&mut self,
me: &Player,
find: &dyn Fn(&str) -> Option<(u128, String)>,
line: &str,
) -> Option<(Vec<Out>, Open)> {
let words: Vec<&str> = line.split_whitespace().collect();
let (&name, args) = words.split_first()?;
let kind = match name {
"invsee" => Kind::Player,
"endersee" => Kind::Ender,
_ => return None,
};
if me.level < LEVEL {
return Some((
vec![Out::Error(format!("Only operators may use /{name}."))],
None,
));
}
let [who] = args else {
return Some((vec![Out::Error(format!("Usage: /{name} <player>"))], None));
};
match find(who) {
Some((uuid, _)) if uuid == me.uuid => {
Some((vec![Out::Error("That is your own inventory.".into())], None))
}
Some((uuid, real)) => Some((Vec::new(), Some((uuid, real, kind)))),
None => Some((
vec![Out::Error(format!(
"No player named {who} has played here."
))],
None,
)),
}
}
/// The view for `op` on `target`, read now (the glue opens the menu).
pub fn open(
&mut self,
op: u128,
target: u128,
name: &str,
kind: Kind,
world: &dyn World<S>,
) -> Result<(), String> {
let (shown, online) = world.read(target, kind)?;
self.views.insert(
op,
View {
target,
name: name.to_owned(),
kind,
window: None,
shown,
online,
busy: None,
moved: Vec::new(),
},
);
Ok(())
}
/// The menu's 54 cells for `op`.
pub fn layout(&self, op: u128) -> Vec<Cell<S>> {
let Some(v) = self.views.get(&op) else {
return Vec::new();
};
let mut cells = vec![Cell::Empty; SIZE];
let used = match v.kind {
Kind::Player => 42,
Kind::Ender => 27,
};
for c in cells.iter_mut().take(45).skip(used) {
*c = Cell::Filler;
}
if v.kind == Kind::Player {
cells[40] = Cell::Label("Off hand →");
}
for (slot, s) in v.shown.iter().enumerate() {
if let (Some(m), Some(s)) = (menu_slot(v.kind, slot), s) {
cells[m] = Cell::Stack(s.clone());
}
}
for c in cells.iter_mut().skip(45) {
*c = Cell::Filler;
}
cells[INFO] = Cell::Info {
name: v.name.clone(),
online: v.online,
busy: v.busy.clone(),
};
cells[CLOSE] = Cell::Close;
cells
}
/// Reads the target's inventory again: whether the menu changed.
pub fn refresh(&mut self, op: u128, world: &dyn World<S>) -> bool {
let Some(v) = self.views.get_mut(&op) else {
return false;
};
let (shown, online, busy) = match world.read(v.target, v.kind) {
Ok((s, on)) => (s, on, None),
Err(e) => (v.shown.clone(), v.online, Some(e)),
};
let changed = shown != v.shown || online != v.online || busy != v.busy;
(v.shown, v.online, v.busy) = (shown, online, busy);
changed
}
/// A click on the menu's slot `menu`: the stack shown there into the
/// operator's inventory. Ok(None): nothing to do; Ok(Some(close)) when
/// the close button. `log` records the move first (the audit log); a
/// move it refuses doesn't happen.
pub fn click_menu(
&mut self,
op: u128,
menu: usize,
world: &mut dyn World<S>,
log: &mut dyn FnMut(&Move) -> Result<(), String>,
) -> Result<Option<bool>, String> {
let Some(v) = self.views.get(&op) else {
return Ok(None);
};
if menu == CLOSE {
return Ok(Some(true));
}
let Some(slot) = inventory_slot(v.kind, menu) else {
return Ok(None);
};
let Some(stack) = v.shown.get(slot).cloned().flatten() else {
return Ok(None);
};
let m = Move {
op,
target: v.target,
kind: v.kind,
take: true,
item: stack.id(),
count: stack.count(),
};
let r = self.apply(op, &m, world, log, |w| {
w.move_stack((m.target, m.kind, slot), &stack, (op, Kind::Player))
});
self.refresh(op, world);
r.map(|_| Some(false))
}
/// A click on the operator's own stack `stack` in inventory slot `slot`
/// (0..36): into the player's inventory.
pub fn click_own(
&mut self,
op: u128,
slot: usize,
stack: &S,
world: &mut dyn World<S>,
log: &mut dyn FnMut(&Move) -> Result<(), String>,
) -> Result<(), String> {
let Some(v) = self.views.get(&op) else {
return Ok(());
};
let m = Move {
op,
target: v.target,
kind: v.kind,
take: false,
item: stack.id(),
count: stack.count(),
};
let r = self.apply(op, &m, world, log, |w| {
w.move_stack((op, Kind::Player, slot), stack, (m.target, m.kind))
});
self.refresh(op, world);
r
}
fn apply(
&mut self,
op: u128,
m: &Move,
world: &mut dyn World<S>,
log: &mut dyn FnMut(&Move) -> Result<(), String>,
go: impl FnOnce(&mut dyn World<S>) -> Result<(), String>,
) -> Result<(), String> {
log(m)?;
go(world)?;
if let Some(v) = self.views.get_mut(&op) {
v.moved.push((m.take, m.item.clone(), m.count));
}
Ok(())
}
/// The operator's menu closed (or they left): the staff's one-line
/// summary when something moved.
pub fn close(&mut self, op: u128, op_name: &str) -> Option<String> {
let v = self.views.remove(&op)?;
if v.moved.is_empty() {
return None;
}
let list = |take: bool| {
let mut sums: Vec<(String, u32)> = Vec::new();
for (t, item, n) in &v.moved {
if *t == take {
let item = item.strip_prefix("minecraft:").unwrap_or(item).to_owned();
match sums.iter_mut().find(|(i, _)| *i == item) {
Some((_, c)) => *c += n,
None => sums.push((item, *n)),
}
}
}
sums.iter()
.map(|(i, n)| format!("{n} {i}"))
.collect::<Vec<_>>()
.join(", ")
};
let what = match v.kind {
Kind::Player => "inventory",
Kind::Ender => "ender chest",
};
let (took, put) = (list(true), list(false));
let mut parts = Vec::new();
if !took.is_empty() {
parts.push(format!("took {took}"));
}
if !put.is_empty() {
parts.push(format!("put {put}"));
}
Some(format!(
"{op_name} edited {}'s {what}{}: {}",
v.name,
if v.online { "" } else { " (offline)" },
parts.join("; ")
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone, Debug, PartialEq)]
struct S(&'static str, u32);
impl Stack for S {
fn id(&self) -> String {
format!("minecraft:{}", self.0)
}
fn count(&self) -> u32 {
self.1
}
}
#[derive(Clone, Copy, PartialEq)]
enum State {
On,
Off,
LoggingIn,
Dead,
}
/// Two players: the operator (1, on) and Steve (2), whose inventories
/// are live or a file by `state`; moves as the game's helper makes them.
struct Fake {
inv: HashMap<(u128, bool), Vec<Option<S>>>,
state: State,
}
impl Fake {
fn new() -> Self {
let mut steve = vec![None; 41];
steve[0] = Some(S("diamond", 5));
steve[39] = Some(S("iron_helmet", 1));
let mut ender = vec![None; 27];
ender[3] = Some(S("emerald", 9));
let mut inv = HashMap::new();
inv.insert((1, false), vec![None; 41]);
inv.insert((2, false), steve);
inv.insert((2, true), ender);
Self {
inv,
state: State::On,
}
}
fn total(&self) -> u32 {
self.inv.values().flatten().flatten().map(|s| s.1).sum()
}
}
impl World<S> for Fake {
fn read(&self, uuid: u128, kind: Kind) -> Result<(Vec<Option<S>>, bool), String> {
if uuid == 2 {
match self.state {
State::LoggingIn => return Err("the player is logging in".into()),
State::Dead => return Err("the player is dead".into()),
_ => {}
}
}
let online = uuid == 1 || self.state == State::On;
Ok((self.inv[&(uuid, kind == Kind::Ender)].clone(), online))
}
fn move_stack(
&mut self,
from: (u128, Kind, usize),
expected: &S,
to: (u128, Kind),
) -> Result<(), String> {
self.read(from.0, from.1)?;
self.read(to.0, to.1)?;
let src = self.inv.get_mut(&(from.0, from.1 == Kind::Ender)).unwrap();
if src[from.2].as_ref() != Some(expected) {
return Err("that stack changed".into());
}
let s = src[from.2].take().unwrap();
let dst = self.inv.get_mut(&(to.0, to.1 == Kind::Ender)).unwrap();
let room = if to.1 == Kind::Player { 36 } else { 27 };
match dst.iter_mut().take(room).find(|d| d.is_none()) {
Some(d) => *d = Some(s),
None => {
self.inv.get_mut(&(from.0, from.1 == Kind::Ender)).unwrap()[from.2] = Some(s);
return Err("no room".into());
}
}
Ok(())
}
}
fn op() -> Player {
Player {
uuid: 1,
name: "Boss".into(),
level: 4,
}
}
fn opened(kind: Kind) -> (Invsee<S>, Fake, Vec<Move>) {
let mut i = Invsee::default();
let w = Fake::new();
i.open(1, 2, "Steve", kind, &w).unwrap();
(i, w, Vec::new())
}
#[test]
fn only_operators_and_a_known_other_player() {
let mut i: Invsee<S> = Invsee::default();
let find = |n: &str| {
(n == "Steve")
.then(|| (2, "Steve".to_owned()))
.or_else(|| (n == "Boss").then(|| (1, "Boss".into())))
};
let pleb = Player { level: 0, ..op() };
assert_eq!(
i.command(&pleb, &find, "invsee Steve").unwrap().0,
[Out::Error("Only operators may use /invsee.".into())]
);
assert_eq!(
i.command(&op(), &find, "invsee Boss").unwrap().0,
[Out::Error("That is your own inventory.".into())]
);
assert_eq!(
i.command(&op(), &find, "endersee Nobody").unwrap().0,
[Out::Error("No player named Nobody has played here.".into())]
);
assert_eq!(
i.command(&op(), &find, "endersee Steve").unwrap().1,
Some((2, "Steve".into(), Kind::Ender))
);
assert!(i.command(&op(), &find, "balance").is_none());
}
#[test]
fn the_layout_shows_main_hotbar_armour_and_off_hand() {
let (i, _, _) = opened(Kind::Player);
let cells = i.layout(1);
assert_eq!(
cells[27],
Cell::Stack(S("diamond", 5)),
"the hotbar's first slot"
);
assert_eq!(
cells[36],
Cell::Stack(S("iron_helmet", 1)),
"the head first"
);
assert_eq!(cells[44], Cell::Filler);
assert!(matches!(&cells[INFO], Cell::Info { name, online: true, .. } if name == "Steve"));
for s in 0..41 {
assert_eq!(
inventory_slot(Kind::Player, menu_slot(Kind::Player, s).unwrap()),
Some(s)
);
}
}
#[test]
fn taking_and_putting_move_one_stack_and_log_it_first() {
let (mut i, mut w, mut log) = opened(Kind::Player);
let before = w.total();
let mut rec = |m: &Move| {
log.push(m.clone());
Ok(())
};
assert_eq!(i.click_menu(1, 27, &mut w, &mut rec), Ok(Some(false)));
assert_eq!(w.inv[&(1, false)][0], Some(S("diamond", 5)));
assert_eq!(i.layout(1)[27], Cell::Empty, "read again after the move");
i.click_own(1, 0, &S("diamond", 5), &mut w, &mut rec)
.unwrap();
assert_eq!(w.inv[&(2, false)][0], Some(S("diamond", 5)));
assert_eq!(w.total(), before);
assert_eq!(log.len(), 2);
assert!(
log[0].take && !log[1].take && log[0].item == "minecraft:diamond" && log[0].count == 5
);
assert_eq!(
i.close(1, "Boss").as_deref(),
Some("Boss edited Steve's inventory: took 5 diamond; put 5 diamond")
);
assert!(i.views.is_empty());
}
#[test]
fn a_stack_the_player_moved_meanwhile_is_not_copied() {
let (mut i, mut w, _) = opened(Kind::Player);
// Steve moves his diamonds after the menu was read.
let d = w.inv.get_mut(&(2, false)).unwrap()[0].take();
w.inv.get_mut(&(2, false)).unwrap()[5] = d;
let before = w.total();
assert_eq!(
i.click_menu(1, 27, &mut w, &mut |_| Ok(())),
Err("that stack changed".into())
);
assert_eq!(w.total(), before);
assert!(w.inv[&(1, false)].iter().all(Option::is_none));
// The menu shows where they are now.
assert_eq!(i.layout(1)[32], Cell::Stack(S("diamond", 5)));
}
#[test]
fn leaving_joining_and_dying_while_the_menu_is_open() {
let (mut i, mut w, _) = opened(Kind::Ender);
let before = w.total();
// He leaves: his file, the same items, offline in the menu.
w.state = State::Off;
assert!(i.refresh(1, &w));
assert!(matches!(
&i.layout(1)[INFO],
Cell::Info { online: false, .. }
));
assert_eq!(i.click_menu(1, 3, &mut w, &mut |_| Ok(())), Ok(Some(false)));
assert_eq!(w.inv[&(1, false)][0], Some(S("emerald", 9)));
// He is logging in: nothing moves, the menu says why.
w.state = State::LoggingIn;
assert_eq!(
i.click_own(1, 0, &S("emerald", 9), &mut w, &mut |_| Ok(())),
Err("the player is logging in".into())
);
assert!(
matches!(&i.layout(1)[INFO], Cell::Info { busy: Some(b), .. } if b.contains("logging in"))
);
// Dead: the same.
w.state = State::Dead;
assert!(
i.click_own(1, 0, &S("emerald", 9), &mut w, &mut |_| Ok(()))
.is_err()
);
// Back on: the move goes through live.
w.state = State::On;
i.click_own(1, 0, &S("emerald", 9), &mut w, &mut |_| Ok(()))
.unwrap();
assert_eq!(w.total(), before);
assert_eq!(
i.close(1, "Boss").as_deref(),
Some("Boss edited Steve's ender chest: took 9 emerald; put 9 emerald")
);
}
#[test]
fn a_move_the_log_refuses_does_not_happen_and_closing_with_nothing_moved_says_nothing() {
let (mut i, mut w, _) = opened(Kind::Player);
let before = w.inv.clone();
assert_eq!(
i.click_menu(1, 27, &mut w, &mut |_| Err("audit.log: disk full".into())),
Err("audit.log: disk full".into())
);
assert_eq!(w.inv, before);
assert_eq!(
i.click_menu(1, CLOSE, &mut w, &mut |_| Ok(())),
Ok(Some(true))
);
assert_eq!(i.close(1, "Boss"), None);
}
}
+571
View File
@@ -0,0 +1,571 @@
// 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"
);
}
}
File diff suppressed because it is too large. Load diff
+260
View File
@@ -0,0 +1,260 @@
// A price chart drawn as a map's picture: 128 x 128 colours (`MapColor` id
// times 4 plus the shade), the curve over a dark background with a grid, the
// item's name, the span, the price now, the change and the low and high, in
// a 3 x 5 pixel font. Green when the price rose over the span, red when it
// fell. The glue sends it as map data (`native_send_map`) for a filled map
// it gives the player, and again each minute while they carry it. No game
// types. Added to the game as `game::smp_market_chart`; also a module of this
// crate, for its tests.
pub const SIZE: usize = 128;
/// `MapColor` ids, with the brightest shade (2).
const fn c(id: u8, shade: u8) -> u8 {
id * 4 + shade
}
pub const BACKGROUND: u8 = c(29, 0); // COLOR_BLACK, dark
const GRID: u8 = c(21, 3); // COLOR_GRAY, darkest
const AXIS: u8 = c(22, 0); // COLOR_LIGHT_GRAY
const TEXT: u8 = c(8, 2); // SNOW
const DIM: u8 = c(22, 2); // COLOR_LIGHT_GRAY
pub const UP: u8 = c(19, 2); // COLOR_LIGHT_GREEN
pub const DOWN: u8 = c(28, 2); // COLOR_RED
const FILL_UP: u8 = c(27, 3); // COLOR_GREEN, darkest
const FILL_DOWN: u8 = c(28, 3);
/// 3 x 5 glyphs, rows top to bottom, 3 bits each (bit 2 = left).
fn glyph(ch: char) -> [u8; 5] {
match ch.to_ascii_uppercase() {
'0' => [7, 5, 5, 5, 7],
'1' => [2, 6, 2, 2, 7],
'2' => [7, 1, 7, 4, 7],
'3' => [7, 1, 3, 1, 7],
'4' => [5, 5, 7, 1, 1],
'5' => [7, 4, 7, 1, 7],
'6' => [7, 4, 7, 5, 7],
'7' => [7, 1, 1, 2, 2],
'8' => [7, 5, 7, 5, 7],
'9' => [7, 5, 7, 1, 7],
'A' => [2, 5, 7, 5, 5],
'B' => [6, 5, 6, 5, 6],
'C' => [3, 4, 4, 4, 3],
'D' => [6, 5, 5, 5, 6],
'E' => [7, 4, 6, 4, 7],
'F' => [7, 4, 6, 4, 4],
'G' => [3, 4, 5, 5, 3],
'H' => [5, 5, 7, 5, 5],
'I' => [7, 2, 2, 2, 7],
'J' => [1, 1, 1, 5, 2],
'K' => [5, 5, 6, 5, 5],
'L' => [4, 4, 4, 4, 7],
'M' => [5, 7, 7, 5, 5],
'N' => [6, 5, 5, 5, 5],
'O' => [2, 5, 5, 5, 2],
'P' => [6, 5, 6, 4, 4],
'Q' => [2, 5, 5, 6, 3],
'R' => [6, 5, 6, 5, 5],
'S' => [3, 4, 2, 1, 6],
'T' => [7, 2, 2, 2, 2],
'U' => [5, 5, 5, 5, 7],
'V' => [5, 5, 5, 5, 2],
'W' => [5, 5, 7, 7, 5],
'X' => [5, 5, 2, 5, 5],
'Y' => [5, 5, 2, 2, 2],
'Z' => [7, 1, 2, 4, 7],
'.' => [0, 0, 0, 0, 2],
',' => [0, 0, 0, 2, 4],
':' => [0, 2, 0, 2, 0],
'%' => [5, 1, 2, 4, 5],
'+' => [0, 2, 7, 2, 0],
'-' => [0, 0, 7, 0, 0],
'/' => [1, 1, 2, 4, 4],
'^' => [2, 7, 7, 0, 0],
'v' => [0, 0, 7, 7, 2],
_ => [0; 5],
}
}
/// A picture being drawn.
pub struct Canvas(pub Vec<u8>);
impl Canvas {
fn new() -> Self {
Self(vec![BACKGROUND; SIZE * SIZE])
}
fn set(&mut self, x: i32, y: i32, color: u8) {
if (0..SIZE as i32).contains(&x) && (0..SIZE as i32).contains(&y) {
self.0[y as usize * SIZE + x as usize] = color;
}
}
/// Text from `x`, `y` (top left), 4 pixels a character.
pub fn text(&mut self, x: i32, y: i32, s: &str, color: u8) {
for (i, ch) in s.chars().enumerate() {
// `▲` `▼` as the font's arrows.
let ch = match ch {
'▲' => '^',
'▼' => 'v',
c => c,
};
for (row, bits) in glyph(ch).iter().enumerate() {
for col in 0..3 {
if bits & (4 >> col) != 0 {
self.set(x + i as i32 * 4 + col, y + row as i32, color);
}
}
}
}
}
fn line(&mut self, (x0, y0): (i32, i32), (x1, y1): (i32, i32), color: u8) {
let (dx, dy) = ((x1 - x0).abs(), -(y1 - y0).abs());
let (sx, sy) = (if x0 < x1 { 1 } else { -1 }, if y0 < y1 { 1 } else { -1 });
let (mut x, mut y, mut err) = (x0, y0, dx + dy);
loop {
self.set(x, y, color);
if x == x1 && y == y1 {
break;
}
let e2 = 2 * err;
if e2 >= dy {
err += dy;
x += sx;
}
if e2 <= dx {
err += dx;
y += sy;
}
}
}
}
/// A short price: `412`, `41.2`, `1.2K`, `3.4M`.
pub fn short(v: f64) -> String {
if v >= 1e6 {
format!("{:.1}M", v / 1e6)
} else if v >= 1e4 {
format!("{:.1}K", v / 1e3)
} else if v >= 100.0 {
format!("{v:.0}")
} else {
format!("{v:.1}")
}
}
/// The chart of `prices` (oldest first) for `title` over `span` ("24H",
/// "7D"): the picture's colours.
pub fn render(title: &str, span: &str, prices: &[f64]) -> Vec<u8> {
let mut c = Canvas::new();
let title: String = title.chars().take(24).collect();
c.text(2, 2, &title, TEXT);
let (plot_x, plot_y, plot_w, plot_h) = (2i32, 20i32, 124i32, 92i32);
if prices.len() < 2 {
c.text(2, 60, "NO TRADES YET", DIM);
c.text(2, 120, span, DIM);
return c.0;
}
let first = prices[0];
let last = *prices.last().expect("two");
let (mut lo, mut hi) = prices
.iter()
.fold((f64::MAX, f64::MIN), |(l, h), &p| (l.min(p), h.max(p)));
if hi - lo < hi.abs() * 0.01 {
// A flat line: a band of 1% around it.
let m = (hi + lo) / 2.0;
lo = m * 0.995;
hi = m * 1.005;
}
let change = if first > 0.0 {
(last / first - 1.0) * 100.0
} else {
0.0
};
let (line, fill) = if change >= 0.0 {
(UP, FILL_UP)
} else {
(DOWN, FILL_DOWN)
};
let arrow = if change >= 0.0 { '▲' } else { '▼' };
c.text(2, 10, &short(last), TEXT);
let ch = format!("{arrow}{:.1}%", change.abs());
c.text(126 - ch.chars().count() as i32 * 4, 10, &ch, line);
// The grid: four rows, the span's quarters.
for i in 0..=4 {
let y = plot_y + plot_h * i / 4;
for x in (plot_x..plot_x + plot_w).step_by(2) {
c.set(x, y, GRID);
}
let x = plot_x + plot_w * i / 4;
for y in (plot_y..plot_y + plot_h).step_by(2) {
c.set(x, y, GRID);
}
}
let to_y = |p: f64| plot_y + plot_h - ((p - lo) / (hi - lo) * f64::from(plot_h)).round() as i32;
let n = prices.len();
let to_x =
|i: usize| plot_x + (i as f64 / (n - 1) as f64 * f64::from(plot_w - 1)).round() as i32;
// The area under the curve, then the curve.
let mut prev = (to_x(0), to_y(prices[0]));
for (i, &p) in prices.iter().enumerate().skip(1) {
let pt = (to_x(i), to_y(p));
for x in prev.0..=pt.0 {
let t = if pt.0 == prev.0 {
1.0
} else {
f64::from(x - prev.0) / f64::from(pt.0 - prev.0)
};
let y = (f64::from(prev.1) + t * f64::from(pt.1 - prev.1)).round() as i32;
for yy in (y + 2..=plot_y + plot_h).step_by(2) {
c.set(x, yy, fill);
}
}
prev = pt;
}
let mut prev = (to_x(0), to_y(prices[0]));
for (i, &p) in prices.iter().enumerate().skip(1) {
let pt = (to_x(i), to_y(p));
c.line(prev, pt, line);
c.line((prev.0, prev.1 - 1), (pt.0, pt.1 - 1), line);
prev = pt;
}
c.line(
(plot_x, plot_y + plot_h),
(plot_x + plot_w - 1, plot_y + plot_h),
AXIS,
);
c.text(2, 116, &format!("L {}", short(lo)), DIM);
let high = format!("H {}", short(hi));
c.text(64, 116, &high, DIM);
c.text(126 - span.chars().count() as i32 * 4, 122, span, DIM);
c.0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_rising_chart_is_green_and_a_falling_one_red() {
let up: Vec<f64> = (0..100).map(|i| 100.0 + f64::from(i)).collect();
let pic = render("Diamond", "24H", &up);
assert_eq!(pic.len(), SIZE * SIZE);
assert!(pic.contains(&UP) && !pic.contains(&DOWN));
let down: Vec<f64> = up.iter().rev().copied().collect();
let pic = render("Diamond", "24H", &down);
assert!(pic.contains(&DOWN) && !pic.contains(&UP));
// The curve's ends: low on the left of a rising one.
let pic = render("X", "24H", &up);
let col = |x: usize| (20..113).find(|&y| pic[y * SIZE + x] == UP);
assert!(col(3).unwrap() > col(124).unwrap());
}
#[test]
fn no_history_and_flat_prices_draw_without_dividing_by_zero() {
let pic = render("Wheat", "7D", &[]);
assert!(pic.contains(&TEXT) || pic.contains(&DIM));
let pic = render("Wheat", "7D", &[2.0; 50]);
assert!(pic.contains(&UP));
assert_eq!(short(412.4), "412");
assert_eq!(short(41.24), "41.2");
assert_eq!(short(12_400.0), "12.4K");
}
}
+325
View File
@@ -0,0 +1,325 @@
// `/market`: the moving market as a chest menu. The first page shows the
// categories (the shop's) and the day's movers; a category's page its items,
// each with its buy and sell price, its change over 1 h and 24 h (green ▲,
// red ▼) and its volume; an item's page buys or sells 1, 16 or 64, sells all
// the player carries, and gives the price chart of the last 24 h or 7 days
// as a map (smp_market_chart). The menus follow the prices at each minute.
// Buying and selling go through the market's rules (smp_market). This file
// lays the pages out and says what a click means. No game types. Added to
// the game as `game::smp_market_menu`; also a module of this crate, for its
// tests.
use super::smp_market::Config as MarketConfig;
use super::smp_shop::Config as ShopConfig;
use std::collections::HashMap;
pub const ROWS: u8 = 6;
pub const SIZE: usize = 54;
const PER_PAGE: usize = 45;
const BACK: usize = 45;
const NEWS: usize = 46;
const PREV: usize = 47;
const BALANCE: usize = 48;
const NEXT: usize = 51;
const CLOSE: usize = 53;
const CATEGORY_SLOTS: [usize; 21] = [
10, 11, 12, 13, 14, 15, 16, 19, 20, 21, 22, 23, 24, 25, 28, 29, 30, 31, 32, 33, 34,
];
/// An item's page.
const ICON: usize = 13;
const BUYS: [(usize, u32); 3] = [(19, 1), (20, 16), (21, 64)];
const SELLS: [(usize, u32); 3] = [(23, 1), (24, 16), (25, 64)];
const SELL_ALL: usize = 31;
const CHART_DAY: usize = 39;
const CHART_WEEK: usize = 41;
/// Which chart.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Chart {
Day,
Week,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Page {
Main,
/// A category (index, or the categories' count for "Other"), its page.
Category(usize, usize),
/// An item, and the category page to go back to.
Item(String, usize, usize),
}
#[derive(Clone, Debug, PartialEq)]
pub enum Cell {
Empty,
Filler,
/// Its icon, name, how many items.
Category(String, String, usize),
/// An item in a list (its prices and moves in the lore).
Item(String),
/// The item of the page, with every number.
Detail(String),
Buy(String, u32),
Sell(String, u32),
SellAll(String),
Chart(String, Chart),
News,
Back,
Prev,
Next,
Balance,
Close,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Click {
Nothing,
/// The page changed: show it.
Show,
Buy(String, u32),
Sell(String, u32),
SellAll(String),
Chart(String, Chart),
Close,
}
/// The categories: the shop's, then "Other" for the market's items in none.
pub fn categories(shop: &ShopConfig, market: &MarketConfig) -> Vec<(String, String, Vec<String>)> {
let mut out: Vec<(String, String, Vec<String>)> = shop
.categories
.iter()
.map(|c| {
let items = c
.items
.iter()
.filter(|i| market.items.contains_key(*i))
.cloned()
.collect();
(c.icon.clone(), c.name.clone(), items)
})
.filter(|c: &(String, String, Vec<String>)| !c.2.is_empty())
.collect();
let other: Vec<String> = market
.items
.keys()
.filter(|i| !shop.categories.iter().any(|c| c.items.contains(i)))
.cloned()
.collect();
if !other.is_empty() {
out.push(("minecraft:chest".into(), "Other".into(), other));
}
out
}
/// The open `/market` menus: window and page, by player.
#[derive(Default)]
pub struct Menus {
pub views: HashMap<u128, (Option<i32>, Page)>,
}
impl Menus {
pub fn layout(&self, shop: &ShopConfig, market: &MarketConfig, uuid: u128) -> Vec<Cell> {
let Some((_, page)) = self.views.get(&uuid) else {
return Vec::new();
};
let cats = categories(shop, market);
let mut cells = vec![Cell::Empty; SIZE];
for c in cells.iter_mut().skip(PER_PAGE) {
*c = Cell::Filler;
}
cells[BALANCE] = Cell::Balance;
cells[CLOSE] = Cell::Close;
match page {
Page::Main => {
for (slot, (icon, name, items)) in CATEGORY_SLOTS.iter().zip(&cats) {
cells[*slot] = Cell::Category(icon.clone(), name.clone(), items.len());
}
cells[NEWS] = Cell::News;
}
Page::Category(c, p) => {
let items = cats.get(*c).map(|c| c.2.as_slice()).unwrap_or_default();
for (i, id) in items.iter().skip(p * PER_PAGE).take(PER_PAGE).enumerate() {
cells[i] = Cell::Item(id.clone());
}
cells[BACK] = Cell::Back;
if *p > 0 {
cells[PREV] = Cell::Prev;
}
if (p + 1) * PER_PAGE < items.len() {
cells[NEXT] = Cell::Next;
}
}
Page::Item(id, ..) => {
for c in cells.iter_mut().take(PER_PAGE) {
*c = Cell::Filler;
}
cells[ICON] = Cell::Detail(id.clone());
let p = market.items.get(id).copied().unwrap_or_default();
for (slot, n) in BUYS {
cells[slot] = if p.buy > 0 {
Cell::Buy(id.clone(), n)
} else {
Cell::Filler
};
}
for (slot, n) in SELLS {
cells[slot] = if p.sell > 0 {
Cell::Sell(id.clone(), n)
} else {
Cell::Filler
};
}
if p.sell > 0 {
cells[SELL_ALL] = Cell::SellAll(id.clone());
}
cells[CHART_DAY] = Cell::Chart(id.clone(), Chart::Day);
cells[CHART_WEEK] = Cell::Chart(id.clone(), Chart::Week);
cells[BACK] = Cell::Back;
}
}
cells
}
/// A click on the menu's `slot`: the page moves here; a trade is the
/// glue's to make.
pub fn click(
&mut self,
shop: &ShopConfig,
market: &MarketConfig,
uuid: u128,
slot: usize,
) -> Click {
let cells = self.layout(shop, market, uuid);
let Some((_, page)) = self.views.get_mut(&uuid) else {
return Click::Nothing;
};
let cats = categories(shop, market);
match (cells.get(slot), page.clone()) {
(Some(Cell::Close), _) => Click::Close,
(Some(Cell::Category(..)), Page::Main) => {
match CATEGORY_SLOTS.iter().position(|s| *s == slot) {
Some(i) if i < cats.len() => {
*page = Page::Category(i, 0);
Click::Show
}
_ => Click::Nothing,
}
}
(Some(Cell::Item(id)), Page::Category(c, p)) => {
*page = Page::Item(id.clone(), c, p);
Click::Show
}
(Some(Cell::Back), Page::Category(..)) => {
*page = Page::Main;
Click::Show
}
(Some(Cell::Back), Page::Item(_, c, p)) => {
*page = Page::Category(c, p);
Click::Show
}
(Some(Cell::Prev), Page::Category(c, p)) => {
*page = Page::Category(c, p.saturating_sub(1));
Click::Show
}
(Some(Cell::Next), Page::Category(c, p)) => {
*page = Page::Category(c, p + 1);
Click::Show
}
(Some(Cell::Buy(id, n)), _) => Click::Buy(id.clone(), *n),
(Some(Cell::Sell(id, n)), _) => Click::Sell(id.clone(), *n),
(Some(Cell::SellAll(id)), _) => Click::SellAll(id.clone()),
(Some(Cell::Chart(id, c)), _) => Click::Chart(id.clone(), *c),
_ => Click::Nothing,
}
}
/// `/market <item>`: straight to its page (back goes to its category).
pub fn page_of(shop: &ShopConfig, market: &MarketConfig, id: &str) -> Option<Page> {
if !market.items.contains_key(id) {
return None;
}
let cats = categories(shop, market);
let c = cats
.iter()
.position(|c| c.2.iter().any(|i| i == id))
.unwrap_or(0);
let p = cats
.get(c)
.and_then(|c| c.2.iter().position(|i| i == id))
.unwrap_or(0)
/ PER_PAGE;
Some(Page::Item(id.to_owned(), c, p))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn configs() -> (ShopConfig, MarketConfig) {
(ShopConfig::default(), MarketConfig::default())
}
#[test]
fn categories_then_items_then_an_items_page_and_back() {
let (shop, market) = configs();
let mut m = Menus::default();
m.views.insert(1, (Some(3), Page::Main));
let cells = m.layout(&shop, &market, 1);
assert!(matches!(&cells[10], Cell::Category(_, name, _) if name == "Farming"));
assert_eq!(cells[NEWS], Cell::News);
// Mining is the second category.
assert_eq!(m.click(&shop, &market, 1, 11), Click::Show);
let cells = m.layout(&shop, &market, 1);
let at = cells
.iter()
.position(|c| *c == Cell::Item("minecraft:diamond".into()))
.unwrap();
assert_eq!(m.click(&shop, &market, 1, at), Click::Show);
let cells = m.layout(&shop, &market, 1);
assert_eq!(cells[ICON], Cell::Detail("minecraft:diamond".into()));
assert_eq!(
m.click(&shop, &market, 1, 21),
Click::Buy("minecraft:diamond".into(), 64)
);
assert_eq!(
m.click(&shop, &market, 1, 23),
Click::Sell("minecraft:diamond".into(), 1)
);
assert_eq!(
m.click(&shop, &market, 1, SELL_ALL),
Click::SellAll("minecraft:diamond".into())
);
assert_eq!(
m.click(&shop, &market, 1, CHART_WEEK),
Click::Chart("minecraft:diamond".into(), Chart::Week)
);
assert_eq!(m.click(&shop, &market, 1, BACK), Click::Show);
assert_eq!(m.views[&1].1, Page::Category(1, 0));
assert_eq!(m.click(&shop, &market, 1, BACK), Click::Show);
assert_eq!(m.views[&1].1, Page::Main);
assert_eq!(m.click(&shop, &market, 1, CLOSE), Click::Close);
assert_eq!(m.click(&shop, &market, 1, 0), Click::Nothing);
}
#[test]
fn a_side_the_market_doesnt_trade_has_no_buttons() {
let (shop, market) = configs();
let mut m = Menus::default();
m.views.insert(
1,
(
Some(3),
Menus::page_of(&shop, &market, "minecraft:bread").unwrap(),
),
);
let cells = m.layout(&shop, &market, 1);
assert_eq!(cells[19], Cell::Buy("minecraft:bread".into(), 1));
assert_eq!(cells[23], Cell::Filler, "the market doesn't buy bread");
assert_eq!(cells[SELL_ALL], Cell::Filler);
assert_eq!(
Menus::page_of(&shop, &market, "minecraft:nether_star"),
None
);
}
}
+932
View File
@@ -0,0 +1,932 @@
// The market's moving prices (smp_market's `dynamic`): each item has one
// base price (config.json's `buy`, the same as its `sell` when the market
// trades it both ways), a factor that scales it, and a history. A purchase
// pushes the factor up and a sale down, at once, by an elasticity per item
// (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
// 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
// dumping at a flat edge, would earn). At `min_factor`..`max_factor` the
// price stops and a trade goes at that bound, the least the market ever pays
// or the most it ever asks. Between trades the factor drifts back toward
// 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
// to the game as `game::smp_market_prices`; also a module of this crate, for
// its tests.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const MINUTE_MS: i64 = 60_000;
pub const HOUR_MS: i64 = 3_600_000;
/// Points kept: a day of minutes, 30 days of hours.
pub const MINUTES: usize = 1440;
pub const HOURS: usize = 720;
/// `config.json`'s `market.dynamic`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Dynamic {
pub enabled: bool,
pub min_factor: f64,
pub max_factor: f64,
/// How long the factor takes to come halfway back to 1.
pub half_life_hours: f64,
/// Unused since 0.29 (trades move the price at once, along one curve):
/// kept so older configs read the same.
pub max_trade_impact_percent: f64,
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,
/// Items that move the price 1%, by item (else from its price: dear
/// items move faster).
pub depth: BTreeMap<String, f64>,
/// A day's supply change that makes news, and the least volume for it.
pub news_percent: f64,
pub news_min_volume: u64,
}
impl Default for Dynamic {
fn default() -> Self {
Self {
enabled: true,
min_factor: 0.6,
max_factor: 1.6,
half_life_hours: 6.0,
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,
news_min_volume: 64,
}
}
}
impl Dynamic {
/// Its numbers made sane: factors around 1, caps positive.
pub fn validate(&mut self) {
let ok = |v: f64, lo: f64, hi: f64, d: f64| if v.is_finite() { v.clamp(lo, hi) } else { d };
let d = Dynamic::default();
self.min_factor = ok(self.min_factor, 0.05, 1.0, d.min_factor);
self.max_factor = ok(self.max_factor, 1.0, 20.0, d.max_factor);
self.half_life_hours = ok(self.half_life_hours, 0.1, 24.0 * 30.0, d.half_life_hours);
self.max_trade_impact_percent = ok(
self.max_trade_impact_percent,
0.0,
50.0,
d.max_trade_impact_percent,
);
self.max_player_impact_percent = ok(
self.max_player_impact_percent,
0.0,
50.0,
d.max_player_impact_percent,
);
self.max_minute_move_percent = ok(
self.max_minute_move_percent,
0.0,
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
/// traded (both sides) and the items players sold.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(from = "(i64, f64, u32, u32)", into = "(i64, f64, u32, u32)")]
pub struct Point {
pub t: i64,
pub price: f64,
pub volume: u32,
pub sold: u32,
}
impl From<(i64, f64, u32, u32)> for Point {
fn from((t, price, volume, sold): (i64, f64, u32, u32)) -> Self {
Self {
t,
price,
volume,
sold,
}
}
}
impl From<Point> for (i64, f64, u32, u32) {
fn from(p: Point) -> Self {
(p.t, (p.price * 100.0).round() / 100.0, p.volume, p.sold)
}
}
/// Which history.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Span {
Minute,
Hour,
}
/// The price of one now (`mid`), and what one costs to buy and one brings
/// when sold, in whole coins (the price moves a little as a trade goes, and
/// coins round against the trader).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Quote {
pub mid: f64,
pub buy: i64,
pub sell: i64,
}
/// One item's moving price.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Track {
pub factor: f64,
pub minutes: Vec<Point>,
pub hours: Vec<Point>,
/// The log of the factor when this minute began (the minute's band is
/// around it).
pub open: f64,
/// This minute's volume and sales.
pub volume: u32,
pub sold: u32,
/// The hour being summed.
pub hour_volume: u32,
pub hour_sold: u32,
}
/// `market_prices.json`.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Book {
pub items: BTreeMap<String, Track>,
/// The start of the minute the flows are for.
pub minute: i64,
/// The UTC day the last summary was for.
pub summary_day: i64,
/// 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).
pub bases: BTreeMap<String, f64>,
}
/// What `value` coins of a purchase cost in whole coins (rounded up; a
/// hundredth of a coin forgiven, so a whole price stays whole).
pub fn to_pay(value: f64) -> i64 {
(value - 0.01).ceil().max(0.0) as i64
}
/// What `value` coins of a sale bring in whole coins (rounded down).
pub fn to_get(value: f64) -> i64 {
(value + 0.01).floor().max(0.0) as i64
}
/// One price for an item the market both buys and sells at `buy` and `sell`
/// (before 0.29 they differed): their geometric mean, whole, at least 1.
pub fn one_price(buy: i64, sell: i64) -> i64 {
match (buy > 0, sell > 0) {
(true, true) => ((buy as f64 * sell as f64).sqrt().round() as i64).max(1),
(true, false) => buy,
_ => sell.max(0),
}
}
/// The base mid price of an item priced `buy` and `sell`.
pub fn base_mid(buy: i64, sell: i64) -> f64 {
match (buy > 0, sell > 0) {
(true, true) => (buy + sell) as f64 / 2.0,
(true, false) => buy as f64,
_ => sell as f64,
}
}
impl Book {
fn track(&mut self, id: &str) -> &mut Track {
self.items.entry(id.to_owned()).or_insert_with(|| Track {
factor: 1.0,
..Track::default()
})
}
/// The factor now (1 for an item never traded).
pub fn factor(&self, id: &str) -> f64 {
self.items
.get(id)
.map_or(1.0, |t| if t.factor > 0.0 { t.factor } else { 1.0 })
}
/// The price now of an item based at `mid` (one unit moving its log
/// factor `delta`): one, and what one costs and brings.
pub fn quote(&self, config: &Dynamic, id: &str, mid: f64, delta: f64) -> Quote {
Quote {
mid: mid * self.factor(id),
buy: to_pay(self.one(config, id, mid, delta, true)),
sell: to_get(self.one(config, id, mid, delta, false)),
}
}
/// One unit's value now along the curve (within the bounds; the
/// minute's band aside, so a side the band has closed still shows what
/// its next one goes for).
fn one(&self, config: &Dynamic, id: &str, mid: f64, delta: f64, buy: bool) -> f64 {
let s0 = self.factor(id).ln();
let s1 = if buy {
(s0 + delta).min(config.max_factor.ln().max(s0))
} else {
(s0 - delta).max(config.min_factor.ln().min(s0))
};
if delta <= 0.0 || s1 == s0 {
return mid * s0.exp();
}
mid / delta * (s1.exp() - s0.exp()).abs()
+ (1.0 - (s1 - s0).abs() / delta).max(0.0) * mid * s1.exp()
}
/// Items that move `id` (based at `mid`) by 1%.
pub fn depth(config: &Dynamic, id: &str, mid: f64) -> f64 {
config
.depth
.get(id)
.copied()
.unwrap_or_else(|| (400.0 / mid.max(0.01)).clamp(2.0, 400.0))
}
/// How far one of `id` (based at `mid`) moves the log of its factor.
pub fn unit_move(config: &Dynamic, id: &str, mid: f64) -> f64 {
0.01 / Self::depth(config, id, mid)
}
/// What `n` of `id` (based at `mid`, each moving the log factor by
/// `delta`) are worth bought (`buy`) or sold now: the area under the
/// price as the trade moves it, exactly, so the same items sold back
/// retrace it. At a bound the price stops and the rest goes at it; past
/// this minute's band the rest isn't traded. The value, the log factor
/// after, and how many of the `n` go.
pub fn fill(
&self,
config: &Dynamic,
id: &str,
mid: f64,
delta: f64,
n: u32,
buy: bool,
) -> (f64, f64, u32) {
let s0 = self.factor(id).ln();
let open = self.items.get(id).map_or(0.0, |t| t.open);
let band = config.max_minute_move_percent / 100.0;
let sign = if buy { 1.0 } else { -1.0 };
// How far this minute still lets it go, and where the bound is.
let edge = sign * (open + sign * band - s0);
let bound = if buy {
config.max_factor.ln() - s0
} else {
s0 - config.min_factor.ln()
};
if delta <= 0.0 {
return (f64::from(n) * mid * s0.exp(), s0, n);
}
let mut done = n;
if edge < bound {
// The band comes first: as many as fit in it.
let fit = (edge.max(0.0) / delta + 1e-9).floor();
if fit < f64::from(n) {
done = fit as u32;
}
}
let reach = (f64::from(done) * delta).min(bound.max(0.0));
let s1 = s0 + sign * reach;
let curve = mid / delta * (s1.exp() - s0.exp()).abs();
let flat = (f64::from(done) - reach / delta).max(0.0) * mid * s1.exp();
(curve + flat, s1, done)
}
/// A trade of `n` of `id` (based at `mid`, `delta` a unit) now: its
/// value (`fill`), the factor moved at once for `id` and every item in
/// `family` (the items it is made of or into), the volume counted.
#[allow(
clippy::too_many_arguments,
reason = "one trade's facts, read in one place"
)]
pub fn trade(
&mut self,
config: &Dynamic,
id: &str,
family: &[String],
mid: f64,
delta: f64,
n: u32,
buy: bool,
) -> f64 {
let (value, s1, n) = self.fill(config, id, mid, delta, n, buy);
let factor = s1.exp();
self.track(id).factor = factor;
for other in family {
self.track(other).factor = factor;
}
let t = self.track(id);
t.volume = t.volume.saturating_add(n);
t.hour_volume = t.hour_volume.saturating_add(n);
if !buy {
t.sold = t.sold.saturating_add(n);
t.hour_sold = t.hour_sold.saturating_add(n);
}
value
}
/// Items made of one another share one factor: each family's takes the
/// first member's that has one (after a config adds a family).
/// 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
/// cliff. The items it scaled, by how much.
pub fn settle(
&mut self,
old: &BTreeMap<String, f64>,
bases: &BTreeMap<String, f64>,
) -> BTreeMap<String, f64> {
let mut scaled = BTreeMap::new();
if self.bases.is_empty() {
for (id, t) in self.items.iter_mut() {
let (Some(&was), Some(&now)) = (old.get(id), bases.get(id)) else {
continue;
};
if was > 0.0 && now > 0.0 && (now / was - 1.0).abs() > 1e-9 {
let k = now / was;
for p in t.minutes.iter_mut().chain(t.hours.iter_mut()) {
p.price *= k;
}
scaled.insert(id.clone(), k);
}
}
}
for t in self.items.values_mut() {
t.open = if t.factor > 0.0 { t.factor.ln() } else { 0.0 };
}
self.bases = bases.clone();
scaled
}
pub fn link(&mut self, families: &[Vec<String>]) {
for f in families {
let Some((factor, open)) = f
.iter()
.find_map(|id| self.items.get(id).map(|t| (t.factor, t.open)))
else {
continue;
};
for id in f {
let t = self.track(id);
t.factor = factor;
t.open = open;
}
}
}
/// The minute timer: every minute since the last one, each item's
/// factor drawn back toward 1 and kept in bounds, the minute's band set,
/// a point written (and an hour's at each hour). `mids`: the items
/// traded, with their base mid. Whether a minute passed.
pub fn tick(&mut self, config: &Dynamic, mids: &BTreeMap<String, f64>, now: i64) -> bool {
let minute = now.div_euclid(MINUTE_MS) * MINUTE_MS;
if self.minute == 0 {
self.minute = minute;
return false;
}
if minute <= self.minute {
return false;
}
// After a long stop, one step for the whole gap (the reversion
// covers it), its point at the last minute.
let gap = ((minute - self.minute) / MINUTE_MS).max(1) as f64;
let decay = 0.5f64.powf(gap / (config.half_life_hours * 60.0));
let (lo, hi) = (config.min_factor.ln(), config.max_factor.ln());
let closed = self.minute;
for (id, mid) in mids {
let t = self.track(id);
let log = (t.factor.max(1e-9).ln() * decay).clamp(lo, hi);
t.factor = log.exp();
t.open = log;
let p = Point {
t: closed,
price: mid * t.factor,
volume: std::mem::take(&mut t.volume),
sold: std::mem::take(&mut t.sold),
};
t.minutes.push(p);
if t.minutes.len() > MINUTES {
let extra = t.minutes.len() - MINUTES;
t.minutes.drain(..extra);
}
if minute.div_euclid(HOUR_MS) != closed.div_euclid(HOUR_MS) {
t.hours.push(Point {
t: closed.div_euclid(HOUR_MS) * HOUR_MS,
price: p.price,
volume: std::mem::take(&mut t.hour_volume),
sold: std::mem::take(&mut t.hour_sold),
});
if t.hours.len() > HOURS {
let extra = t.hours.len() - HOURS;
t.hours.drain(..extra);
}
}
}
// Items no longer traded go.
self.items.retain(|id, _| mids.contains_key(id));
self.minute = minute;
// Purchases past the round-trip window go.
let since = now - config.round_trip_hours * HOUR_MS;
for v in self.bought.values_mut() {
v.retain(|(_, t, _)| *t > since);
}
self.bought.retain(|_, v| !v.is_empty());
true
}
/// `uuid` bought `n` of `id` at `now`.
pub fn bought(&mut self, uuid: &str, id: &str, n: u32, now: i64) {
self.bought
.entry(uuid.to_owned())
.or_default()
.push((id.to_owned(), now, n));
}
/// How many of `id` `uuid` bought within the round-trip window.
pub fn recently_bought(&self, config: &Dynamic, uuid: &str, id: &str, now: i64) -> u32 {
let since = now - config.round_trip_hours * HOUR_MS;
self.bought.get(uuid).map_or(0, |v| {
v.iter()
.filter(|(i, t, _)| i == id && *t > since)
.map(|e| e.2)
.sum()
})
}
pub fn history(&self, id: &str, span: Span) -> &[Point] {
match self.items.get(id) {
Some(t) => match span {
Span::Minute => &t.minutes,
Span::Hour => &t.hours,
},
None => &[],
}
}
#[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 {
let Some(t) = self.items.get(id) else {
return 0;
};
let since = now - i64::from(hours) * HOUR_MS;
let live = u64::from(t.volume);
if hours <= 24 {
return live
+ t.minutes
.iter()
.rev()
.take_while(|p| p.t >= since)
.map(|p| u64::from(p.volume))
.sum::<u64>();
}
live + u64::from(t.hour_volume)
+ t.hours
.iter()
.rev()
.take_while(|p| p.t >= since)
.map(|p| u64::from(p.volume))
.sum::<u64>()
}
/// Items players sold of `id` between `from` and `to` (hours, then
/// minutes not yet in an hour).
fn sold_between(&self, id: &str, from: i64, to: i64) -> u64 {
let Some(t) = self.items.get(id) else {
return 0;
};
let hours: u64 = t
.hours
.iter()
.filter(|p| p.t >= from && p.t < to)
.map(|p| u64::from(p.sold))
.sum();
let last_hour = t.hours.last().map_or(i64::MIN, |p| p.t + HOUR_MS);
let minutes: u64 = t
.minutes
.iter()
.filter(|p| p.t >= from.max(last_hour) && p.t < to)
.map(|p| u64::from(p.sold))
.sum();
hours + minutes
}
/// The change of `id`'s mid over the last `minutes`, in percent (None
/// without a point that old).
pub fn change(&self, id: &str, now: i64, minutes: u32, mid_now: f64) -> Option<f64> {
let t = self.items.get(id)?;
let at = now - i64::from(minutes) * MINUTE_MS;
let then = if minutes <= MINUTES as u32 {
t.minutes.iter().find(|p| p.t >= at)?
} else {
t.hours.iter().find(|p| p.t >= at)?
};
(then.price > 0.0).then(|| (mid_now / then.price - 1.0) * 100.0)
}
/// The day's movers and news, for the chat and Discord: the three
/// biggest risers and fallers over 24 h (at least 1%), then supply
/// changes of at least `news_percent` (today's sales against the day
/// before's, both busy enough). `names`: each item's display name and
/// mid now.
pub fn summary(
&self,
config: &Dynamic,
items: &[(String, String, f64)],
now: i64,
) -> Vec<String> {
let mut moves: Vec<(f64, &str)> = items
.iter()
.filter_map(|(id, name, mid)| {
Some((self.change(id, now, 24 * 60, *mid)?, name.as_str()))
})
.filter(|(c, _)| c.abs() >= 1.0)
.collect();
moves.sort_by(|a, b| b.0.total_cmp(&a.0));
let mut lines = Vec::new();
let fmt = |v: &[(f64, &str)]| {
v.iter()
.map(|(c, n)| format!("{n} {}", arrow(*c)))
.collect::<Vec<_>>()
.join(", ")
};
let up: Vec<_> = moves
.iter()
.filter(|m| m.0 > 0.0)
.take(3)
.copied()
.collect();
let down: Vec<_> = moves
.iter()
.rev()
.filter(|m| m.0 < 0.0)
.take(3)
.copied()
.collect();
if !up.is_empty() {
lines.push(format!("Market today: up {}", fmt(&up)));
}
if !down.is_empty() {
lines.push(format!("Market today: down {}", fmt(&down)));
}
let day = 24 * HOUR_MS;
for (id, name, _) in items {
let today = self.sold_between(id, now - day, now + 1);
let before = self.sold_between(id, now - 2 * day, now - day);
if today.max(before) < config.news_min_volume || before == 0 {
continue;
}
let c = (today as f64 / before as f64 - 1.0) * 100.0;
if c.abs() >= config.news_percent {
let how = if c > 0.0 { "up" } else { "down" };
lines.push(format!(
"News: {name} supply {how} {:.0}% today ({today} sold, {before} the day before).",
c.abs()
));
}
}
lines
}
}
/// `▲ 2.4%` or `▼ 1.0%`.
pub fn arrow(change: f64) -> String {
if change >= 0.0 {
format!("▲ {change:.1}%")
} else {
format!("▼ {:.1}%", -change)
}
}
#[cfg(test)]
mod tests {
use super::*;
const T0: i64 = 1_800_000_000_000;
fn mids() -> BTreeMap<String, f64> {
[("minecraft:diamond", 250.0), ("minecraft:wheat", 2.0)]
.iter()
.map(|(k, v)| (k.to_string(), *v))
.collect()
}
fn book() -> Book {
let mut b = Book::default();
b.tick(&Dynamic::default(), &mids(), T0);
b
}
const D: &str = "minecraft:diamond";
/// One diamond's move (based at 250: 0.5%).
fn delta() -> f64 {
Book::unit_move(&Dynamic::default(), D, 250.0)
}
/// Buys or sells `n` diamonds (as many as the minute allows): the coins
/// paid (negative) or received, and how many.
fn deal(b: &mut Book, n: u32, buy: bool) -> (i64, u32) {
let done = b.fill(&Dynamic::default(), D, 250.0, delta(), n, buy).2;
let v = b.trade(&Dynamic::default(), D, &[], 250.0, delta(), n, buy);
(if buy { -to_pay(v) } else { to_get(v) }, done)
}
#[test]
fn purchases_raise_and_sales_lower_the_price_at_once_along_one_curve() {
let c = Dynamic::default();
let mut b = book();
let q = b.quote(&c, D, 250.0, delta());
// One price; one diamond moves it 0.5%, so one costs and brings the
// price give or take a quarter of a percent.
assert_eq!((q.mid, q.buy, q.sell), (250.0, 251, 249));
assert_eq!(deal(&mut b, 1, false), (249, 1));
assert!((b.factor(D) - (-0.005f64).exp()).abs() < 1e-12, "at once");
assert_eq!(deal(&mut b, 1, true), (-250, 1));
assert!((b.factor(D) - 1.0).abs() < 1e-12, "and back");
// Wheat is deep: 64 move it far less than one diamond.
let w = Book::unit_move(&c, "minecraft:wheat", 2.0);
b.trade(&c, "minecraft:wheat", &[], 2.0, w, 64, false);
assert!(1.0 - b.factor("minecraft:wheat") < 0.004);
// Its family moves with it.
let fam = vec!["minecraft:hay_block".to_string()];
b.trade(&c, "minecraft:wheat", &fam, 2.0, w, 64, true);
assert_eq!(b.factor("minecraft:hay_block"), b.factor("minecraft:wheat"));
}
#[test]
fn a_round_trip_earns_nothing_but_the_tax_for_1_64_and_past_the_band() {
let c = Dynamic::default();
// 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] {
for first_buy in [true, false] {
let mut b = book();
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.
if n <= 8 {
assert!(a + z >= -2, "{n} {first_buy}: {a} {z}");
}
}
}
// Two players: one pumps, the other sells into it: together they
// lose, whatever the split.
let mut b = book();
let paid = -deal(&mut b, 9, true).0;
let got = deal(&mut b, 9, false).0;
assert!(got <= paid, "{got} {paid}");
}
#[test]
fn whatever_the_trades_and_the_drift_items_back_where_they_were_cost_money() {
let mut b = book();
let mut seed = 7u64;
let mut rand = |m: u64| {
seed = seed
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(seed >> 33) % m
};
let (mut coins, mut held, mut now) = (0i64, 0i64, T0);
for step in 0..5_000 {
let n = 1 + rand(200) as u32;
let buy = held < i64::from(n) || (held < 2_000 && rand(2) == 0);
let (c, n) = deal(&mut b, n, buy);
coins += c;
held += if buy { i64::from(n) } else { -i64::from(n) };
if step % 7 == 0 {
now += MINUTE_MS * (1 + rand(30) as i64);
b.tick(&Dynamic::default(), &mids(), now);
}
}
// Back to no items: sold at the end, a minute at a time.
while held > 0 {
let (c, n) = deal(&mut b, held as u32, false);
coins += c;
held -= i64::from(n);
now += MINUTE_MS;
b.tick(&Dynamic::default(), &mids(), now);
}
assert!(coins <= 0, "{coins}");
}
#[test]
fn a_minute_moves_the_price_at_most_five_percent_and_the_rest_waits() {
let c = Dynamic::default();
let mut b = book();
// 0.5% a diamond: 10 fit in the minute's 5%, the other 630 wait.
let (v, s1, done) = b.fill(&c, D, 250.0, delta(), 640, false);
assert_eq!(done, 10);
assert!((s1 + 0.05).abs() < 1e-9, "{s1}");
let curve = 250.0 / delta() * (1.0 - (-0.05f64).exp());
assert!((v - curve).abs() < 1e-6, "{v}");
b.trade(&c, D, &[], 250.0, delta(), 640, false);
assert_eq!(
b.fill(&c, D, 250.0, delta(), 5, false).2,
0,
"no more this minute"
);
assert!(
b.fill(&c, D, 250.0, delta(), 5, true).2 == 5,
"buying back is fine"
);
// The next minute's band starts where this one ended (drift aside).
b.tick(&c, &mids(), T0 + MINUTE_MS);
b.trade(&c, D, &[], 250.0, delta(), 640, false);
assert!(b.factor(D) < (-0.099f64).exp());
}
#[test]
fn the_price_drifts_back_and_stays_in_bounds() {
let c = Dynamic::default();
let mut b = book();
for m in 1..=600 {
b.trade(&c, D, &[], 250.0, delta(), 640, true);
// At the bound the rest goes at it: the most it ever asks.
b.tick(&c, &mids(), T0 + m * MINUTE_MS);
assert!(b.factor("minecraft:diamond") <= c.max_factor + 1e-9);
}
// At the bound (less a minute's drift).
let top = b.factor("minecraft:diamond");
assert!((top - c.max_factor).abs() < 0.01, "{top}");
// Six hours of quiet: halfway back (in the log).
b.tick(&c, &mids(), T0 + 600 * MINUTE_MS + 6 * HOUR_MS);
let f = b.factor("minecraft:diamond");
assert!((f.ln() - top.ln() / 2.0).abs() < 1e-6, "{f}");
}
#[test]
fn twap_mark_price_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);
assert!(h.windows(2).all(|w| w[1].t == w[0].t + MINUTE_MS));
// A point written and read back keeps its numbers.
let json = serde_json::to_string(&b).unwrap();
let back: Book = serde_json::from_str(&json).unwrap();
assert_eq!(back.history("minecraft:diamond", Span::Minute).len(), 12);
assert!((back.factor("minecraft:diamond") - b.factor("minecraft:diamond")).abs() < 1e-12);
}
#[test]
fn hours_are_summed_and_the_day_makes_news() {
let c = Dynamic::default();
let mut b = book();
let mut now = T0;
// Yesterday 100 wheat an hour; today 200.
for h in 0..48 {
let n = if h < 24 { 100 } else { 200 };
for m in 0..60 {
if m == 0 {
let w = Book::unit_move(&c, "minecraft:wheat", 2.0);
b.trade(&c, "minecraft:wheat", &[], 2.0, w, n, false);
}
now += MINUTE_MS;
b.tick(&c, &mids(), now);
}
}
assert!(b.history("minecraft:wheat", Span::Hour).len() >= 47);
let lines = b.summary(
&c,
&[(
"minecraft:wheat".into(),
"Wheat".into(),
2.0 * b.factor("minecraft:wheat"),
)],
now,
);
assert!(
lines
.iter()
.any(|l| l.starts_with("News: Wheat supply up 100% today")),
"{lines:?}"
);
assert!(
lines
.iter()
.any(|l| l.starts_with("Market today: down Wheat ▼")),
"{lines:?}"
);
}
#[test]
fn the_round_trip_window_and_the_tax() {
let c = Dynamic::default();
let mut b = book();
b.bought("u", "minecraft:diamond", 5, T0);
assert_eq!(
b.recently_bought(&c, "u", "minecraft:diamond", T0 + HOUR_MS),
5
);
b.tick(&c, &mids(), T0 + 25 * HOUR_MS);
assert_eq!(
b.recently_bought(&c, "u", "minecraft:diamond", T0 + 25 * HOUR_MS),
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);
assert_eq!(one_price(2, 1), 1);
assert_eq!((one_price(5, 0), one_price(0, 1)), (5, 1));
assert_eq!((to_pay(250.004), to_get(249.996)), (250, 250));
assert_eq!((to_pay(1.94), to_get(1.94)), (2, 1));
}
}
+875
View File
@@ -0,0 +1,875 @@
// Nicknames: `/nick <name>` shows another name for you in chat and the Tab
// list, always as `~name` so it never passes for a real name (hovering it
// gives your real one); `/nick off` your name again; operators of level 3
// `/nick <player> <name|off>`; anyone `/realname <nick>`. With `ops_only`
// (the default since SMP 0.22) only operators of level 3 pick nicknames,
// for themselves or for a player; a player may still take off the one an
// operator gave them, and the nicknames players picked themselves are taken
// off when the server starts. A nickname is 3 to
// 16 letters, digits or `_`, and once read without case, `_`, digits and
// look-alikes (0 as o, 1 and i as l...) it may not be or contain a name the
// server knows (anyone who played here, an operator, the Team in
// `protected`, another player's nickname) nor a word kept for the server
// (`reserved`: server, admin, notch...); it must pass the server's chat
// filter; one change every 10 minutes, and only after `min_playtime_minutes`
// of play. Nicknames that break these rules are taken off when the server
// starts. `/pay`, `/tpa`, `/trade`... take a nickname as well as a name. The
// real name stays everywhere else (the log, the anticheat, the Discord
// bridge). Kept in `nicks.json`. A change is decided
// here and made by the glue at the next second, outside the player's
// command, where the chat filter and the Tab list are in reach. No game
// types. Added to the game as `game::smp_nick`; also a module of this crate,
// for its tests.
use super::smp_economy::{Out, Player};
use super::smp_store::{parse_uuid, read_json, uuid_string, write_json};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, path::PathBuf};
pub const COMMANDS: [&str; 2] = ["nick", "realname"];
/// `config.json`'s `nicks`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub enabled: bool,
/// Only operators of level 3 set nicknames (their own, or a player's).
pub ops_only: bool,
/// Between two changes of one's own nickname.
pub cooldown_seconds: i64,
/// The play a player needs here before their first nickname.
pub min_playtime_minutes: i64,
/// Names no nickname may look like, besides the players' and the
/// operators': the Team's.
pub protected: Vec<String>,
/// Words no nickname may look like (contain, from 4 letters on; be, or
/// have as a `_` part, when shorter).
pub reserved: Vec<String>,
}
impl Default for Config {
fn default() -> Self {
let list = |l: &[&str]| l.iter().map(|s| (*s).to_owned()).collect();
Self {
enabled: true,
ops_only: true,
cooldown_seconds: 600,
min_playtime_minutes: 60,
protected: list(&[
"rdbt",
"rdbtclient",
"luzW",
"bunchy7s",
"lu4ovic",
"ludovic",
"dezer-x",
"dezerx",
"levzzz",
]),
reserved: list(&[
"server",
"console",
"admin",
"mod",
"moderator",
"owner",
"staff",
"op",
"horizon",
"kira",
"system",
"error",
"notch",
"jeb",
"dinnerbone",
"mojang",
"microsoft",
"herobrine",
]),
}
}
}
/// A name as it reads: lowercase, look-alikes folded (`0` o, `1` `i` `|` l,
/// `3` e, `4` `@` a, `5` `$` s, `7` t, `8` b, `9` g), anything else that
/// isn't a letter (`_`, `-`, other digits) dropped.
pub fn skeleton(name: &str) -> String {
name.chars()
.filter_map(|c| match c.to_ascii_lowercase() {
'0' => Some('o'),
'1' | 'i' | '|' => Some('l'),
'3' => Some('e'),
'4' | '@' => Some('a'),
'5' | '$' => Some('s'),
'7' => Some('t'),
'8' => Some('b'),
'9' => Some('g'),
c if c.is_ascii_lowercase() => Some(c),
_ => None,
})
.collect()
}
/// Whether a nickname reading as `nick` (skeleton) copies `name`'s: the
/// same, or containing it when it has 4 letters or more.
fn looks_like(nick: &str, name: &str) -> bool {
!name.is_empty() && (nick == name || (name.len() >= 4 && nick.contains(name)))
}
/// One player's nickname, when they last changed it (ms), and whether an
/// operator gave it to them (it stays with `ops_only`).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Nick {
pub nick: Option<String>,
pub changed: i64,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub given: bool,
}
/// The level that sets nicknames with `ops_only`, and others' at any time.
pub const OP_LEVEL: u8 = 3;
/// `nicks.json`, by UUID.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct File {
pub nicks: BTreeMap<String, Nick>,
}
/// A change to make: whose nickname, the new one (None: off), who asked
/// (told the outcome), and whether it starts the cooldown.
#[derive(Clone, Debug, PartialEq)]
pub struct Change {
pub uuid: u128,
pub nick: Option<String>,
pub by: u128,
pub own: bool,
}
/// The names a nickname may not copy: real names of anyone who has played
/// here and of the operators.
pub trait Names {
/// Every real name the server knows, with its player's UUID when known
/// (an operator who never played has none).
fn known(&self) -> Vec<(Option<u128>, String)>;
/// A player who has played here by name (any case): UUID and name.
fn find(&self, name: &str) -> Option<(u128, String)>;
/// A player's real name.
fn name_of(&self, uuid: u128) -> Option<String>;
/// A player's operator level (`ops.json`; 0 when not an operator).
fn level(&self, uuid: u128) -> u8;
}
pub struct Nicks {
pub config: Config,
file: File,
path: PathBuf,
dirty: bool,
/// Changes waiting for the next second.
pub pending: Vec<Change>,
}
/// 3 to 16 of `A-Z a-z 0-9 _`.
pub fn valid(nick: &str) -> bool {
(3..=16).contains(&nick.len()) && nick.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
}
impl Nicks {
/// The nicknames kept in `dir` (`world/horizon-smp/`); an error when
/// `nicks.json` can't be read (nicknames are off then).
pub fn open(dir: &std::path::Path, config: Config) -> Result<Self, String> {
let path = dir.join("nicks.json");
Ok(Self {
config,
file: read_json(&path)?,
path,
dirty: false,
pending: Vec::new(),
})
}
pub fn save(&mut self) -> Result<(), String> {
if self.dirty {
write_json(&self.path, &self.file)?;
self.dirty = false;
}
Ok(())
}
/// Every nickname set, by UUID.
pub fn all(&self) -> Vec<(u128, String)> {
self.file
.nicks
.iter()
.filter_map(|(k, n)| Some((parse_uuid(k)?, n.nick.clone()?)))
.collect()
}
pub fn nick_of(&self, uuid: u128) -> Option<&str> {
self.file.nicks.get(&uuid_string(uuid))?.nick.as_deref()
}
/// Whose nickname `nick` is (any case, with or without its `~`).
pub fn owner(&self, nick: &str) -> Option<u128> {
let nick = nick.strip_prefix('~').unwrap_or(nick);
self.all()
.into_iter()
.find(|(_, n)| n.eq_ignore_ascii_case(nick))
.map(|(u, _)| u)
}
/// Why `nick` can't be the nickname of `uuid` (real name `real`), or
/// None: its form, then the server's words, then every name the server
/// knows and the other nicknames. One's own name never counts.
pub fn refused(&self, uuid: u128, real: &str, nick: &str, names: &dyn Names) -> Option<String> {
if !valid(nick) {
return Some("A nickname is 3 to 16 letters, digits or _.".into());
}
let s = skeleton(nick);
if s.len() < 3 {
return Some("A nickname needs at least 3 letters.".into());
}
let parts: Vec<String> = nick.split('_').map(skeleton).collect();
for word in &self.config.reserved {
let w = skeleton(word);
if looks_like(&s, &w) || parts.contains(&w) {
return Some(format!(
"{nick} reads as \"{word}\": that word is kept for the server."
));
}
}
let own = skeleton(real);
let others = names
.known()
.into_iter()
.filter(|(u, _)| *u != Some(uuid))
.map(|(_, n)| n)
.chain(self.config.protected.iter().cloned())
.chain(
self.all()
.into_iter()
.filter(|(u, _)| *u != uuid)
.map(|(_, n)| n),
);
for name in others {
let k = skeleton(&name);
if k != own && looks_like(&s, &k) {
return Some(format!("{nick} looks like the name of another player."));
}
}
None
}
/// Takes off the nicknames that break today's rules (from an older
/// version): each one's UUID, nickname and reason, for the log. With
/// `ops_only`, those a player who isn't an operator picked themselves.
pub fn purge(&mut self, names: &dyn Names) -> Vec<(u128, String, String)> {
let mut gone = Vec::new();
for (uuid, nick) in self.all() {
let real = names.name_of(uuid).unwrap_or_default();
let given = self
.file
.nicks
.get(&uuid_string(uuid))
.is_some_and(|n| n.given);
let not_op = (self.config.ops_only && names.level(uuid) < OP_LEVEL && !given)
.then(|| "nicknames are for operators only".to_owned());
if let Some(why) = not_op.or_else(|| self.refused(uuid, &real, &nick, names)) {
if let Some(n) = self.file.nicks.get_mut(&uuid_string(uuid)) {
n.nick = None;
}
self.dirty = true;
gone.push((uuid, nick, why));
}
}
gone
}
/// `line` with a nickname in a player argument of `/pay`, `/tpa`,
/// `/trade`... replaced by its player's name; None when nothing to
/// change. `is_name`: whether a word is a real name (it wins);
/// `name_of`: a player's real name.
pub fn resolve(
&self,
line: &str,
is_name: &dyn Fn(&str) -> bool,
name_of: &dyn Fn(u128) -> Option<String>,
) -> Option<String> {
let mut words: Vec<&str> = line.split_whitespace().collect();
let at = match words.as_slice() {
["trade", "accept" | "deny", _] => 2,
[
"pay" | "tpa" | "trade" | "playtime" | "tpaccept" | "tpdeny",
_,
..,
] => 1,
["eco" | "money", "give" | "take" | "set", _, _] => 2,
_ => return None,
};
let word = words[at];
if word == "*"
|| (at == 1 && ["accept", "deny", "cancel", "money"].contains(&word))
|| is_name(word)
{
return None;
}
let real = name_of(self.owner(word)?)?;
words[at] = &real;
Some(words.join(" "))
}
/// Runs `/nick` or `/realname`; None for another command.
pub fn command(
&mut self,
me: &Player,
names: &dyn Names,
played_seconds: i64,
now: i64,
line: &str,
) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
let (&name, args) = words.split_first()?;
if !COMMANDS.contains(&name) {
return None;
}
if !self.config.enabled {
return Some(vec![Out::Error("Nicknames are off on this server.".into())]);
}
Some(match (name, args) {
("nick", []) => {
let picks = !self.config.ops_only || me.level >= OP_LEVEL;
vec![Out::Reply(match (self.nick_of(me.uuid), picks) {
(Some(n), true) => {
format!(
"You go by ~{n}. /nick off takes it off; /nick <name> picks another."
)
}
(Some(n), false) => format!("You go by ~{n}. /nick off takes it off."),
(None, true) => {
"/nick <name> picks a nickname (shown as ~name); /nick off takes it off."
.into()
}
(None, false) => "Only operators set nicknames on this server.".into(),
})]
}
("realname", [nick]) => match self
.owner(nick)
.and_then(|u| Some((u, self.nick_of(u)?.to_owned())))
{
Some((u, n)) => {
let real = names.name_of(u).unwrap_or_else(|| uuid_string(u));
vec![Out::Reply(format!("~{n} is {real}."))]
}
None => vec![Out::Error(format!("Nobody goes by {nick}."))],
},
("realname", _) => vec![Out::Error("Usage: /realname <nickname>".into())],
("nick", [new])
if self.config.ops_only
&& me.level < OP_LEVEL
&& !new.eq_ignore_ascii_case("off") =>
{
vec![Out::Error(
"Only operators set nicknames on this server.".into(),
)]
}
("nick", [new]) => {
let need = self.config.min_playtime_minutes;
if me.level < OP_LEVEL
&& played_seconds < need * 60
&& !new.eq_ignore_ascii_case("off")
{
vec![Out::Error(format!(
"Nicknames come after {need} minutes of play here; you have played {} so far.",
played_seconds / 60
))]
} else {
self.ask(me, me.uuid, &me.name, new, names, now, true)
}
}
("nick", [who, new]) if me.level >= OP_LEVEL => match names.find(who) {
Some((uuid, real)) => self.ask(me, uuid, &real, new, names, now, false),
None => vec![Out::Error(format!(
"No player named {who} has played here."
))],
},
("nick", [_, _]) => vec![Out::Error(
"Only operators of level 3 may set another player's nickname.".into(),
)],
_ => vec![Out::Error("Usage: /nick <name>, /nick off".into())],
})
}
/// A change for `uuid` (real name `real`), checked; queued when right.
#[allow(clippy::too_many_arguments)]
fn ask(
&mut self,
me: &Player,
uuid: u128,
real: &str,
new: &str,
names: &dyn Names,
now: i64,
own: bool,
) -> Vec<Out> {
let current = self.nick_of(uuid);
let new = new.strip_prefix('~').unwrap_or(new);
let off = new.eq_ignore_ascii_case("off") || new == real;
if own && let Some(n) = self.file.nicks.get(&uuid_string(uuid)) {
let wait = n.changed + self.config.cooldown_seconds * 1000 - now;
if wait > 0 && me.level < OP_LEVEL {
return vec![Out::Error(format!(
"You changed your nickname recently: wait {} more minute(s).",
(wait + 59_999) / 60_000
))];
}
}
if off {
if current.is_none() {
return vec![Out::Error("No nickname to take off.".into())];
}
} else {
if let Some(why) = self.refused(uuid, real, new, names) {
return vec![Out::Error(why)];
}
if current.is_some_and(|c| c == new) {
return vec![Out::Error(format!(
"That is already the nickname of {real}."
))];
}
}
self.pending.retain(|c| c.uuid != uuid);
self.pending.push(Change {
uuid,
nick: (!off).then(|| new.to_owned()),
by: me.uuid,
own,
});
Vec::new()
}
/// A pending change the chat filter let through: kept. Lines for the
/// one who asked and the player.
pub fn apply(&mut self, change: &Change, real: &str, now: i64) -> Vec<Out> {
let key = uuid_string(change.uuid);
let entry = self.file.nicks.entry(key.clone()).or_default();
entry.nick = change.nick.clone();
// An operator's gift to another player; one's own pick isn't.
entry.given = change.nick.is_some() && !change.own;
if change.own {
entry.changed = now;
}
if entry.nick.is_none() && entry.changed == 0 {
self.file.nicks.remove(&key);
}
self.dirty = true;
let line = match &change.nick {
Some(n) => format!("{real} now goes by ~{n}. /nick off takes it off."),
None => format!("{real} goes by their name again."),
};
let mut out = vec![Out::Tell(change.by, line.clone())];
if change.by != change.uuid {
out.push(Out::Tell(change.uuid, line));
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
const ALEX: u128 = 1;
const STEVE: u128 = 2;
const HOUR: i64 = 3600;
struct Known(Vec<(u128, &'static str)>);
impl Names for Known {
fn known(&self) -> Vec<(Option<u128>, String)> {
self.0
.iter()
.map(|(u, n)| (Some(*u), (*n).to_owned()))
.chain([(None, "SomeOp".to_owned())])
.collect()
}
fn find(&self, name: &str) -> Option<(u128, String)> {
self.0
.iter()
.find(|(_, n)| n.eq_ignore_ascii_case(name))
.map(|(u, n)| (*u, (*n).to_owned()))
}
fn name_of(&self, uuid: u128) -> Option<String> {
self.0
.iter()
.find(|(u, _)| *u == uuid)
.map(|(_, n)| (*n).to_owned())
}
fn level(&self, uuid: u128) -> u8 {
if uuid == OP { 4 } else { 0 }
}
}
/// An operator in `ops.json` (for `Known::level`).
const OP: u128 = 9;
/// Nicknames for everyone (the rules before `ops_only`).
fn open_to_all() -> Config {
Config {
ops_only: false,
..Config::default()
}
}
fn setup(name: &str) -> (Nicks, std::path::PathBuf) {
setup_with(name, open_to_all())
}
fn setup_with(name: &str, config: Config) -> (Nicks, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("smp-nick-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
(Nicks::open(&dir, config).unwrap(), dir)
}
fn player(uuid: u128, name: &str, level: u8) -> Player {
Player {
uuid,
name: name.into(),
level,
}
}
fn known() -> Known {
Known(vec![(ALEX, "Alex"), (STEVE, "Steve"), (3, "Bob")])
}
/// What `/nick <nick>` answers Alex (played an hour): "" when queued.
fn try_nick(n: &mut Nicks, nick: &str) -> String {
n.pending.clear();
match n
.command(
&player(ALEX, "Alex", 0),
&known(),
HOUR,
0,
&format!("nick {nick}"),
)
.unwrap()
.as_slice()
{
[] => String::new(),
[Out::Error(e)] => e.clone(),
other => panic!("{other:?}"),
}
}
#[test]
fn names_read_without_case_underscores_digits_and_look_alikes() {
assert_eq!(skeleton("N0tch"), "notch");
assert_eq!(skeleton("D1nnerb0ne_"), "dlnnerbone");
assert_eq!(skeleton("dinnerbone"), "dlnnerbone");
assert_eq!(skeleton("lu4ovic"), skeleton("LU4OVIC"));
assert_eq!(skeleton("$3rv3r"), "server");
assert_eq!(skeleton("rdbt_2"), "rdbt");
}
#[test]
fn the_form_and_the_letters() {
let (mut n, dir) = setup("form");
for bad in ["ab", "way_too_long_nickname", "bad-name", "no.dots"] {
assert_eq!(
try_nick(&mut n, bad),
"A nickname is 3 to 16 letters, digits or _.",
"{bad}"
);
}
assert_eq!(
try_nick(&mut n, "1_2_6"),
"A nickname needs at least 3 letters."
);
assert_eq!(try_nick(&mut n, "Sunny"), "");
fs::remove_dir_all(dir).ok();
}
#[test]
fn words_kept_for_the_server_are_refused_in_any_spelling() {
let (mut n, dir) = setup("reserved");
for bad in [
"Server",
"SERVER_",
"s3rv3r",
"Notch",
"N0tch",
"Jeb_",
"jeb",
"Dinnerbone",
"D1nnerb0ne",
"ERROR",
"Err0r",
"TheAdmin",
"Mod",
"Mod_Steve",
"Big_Op",
"Herobrine2",
"Mojang",
"xXConsoleXx",
"Staff",
"Kira",
] {
assert!(
try_nick(&mut n, bad).contains("is kept for the server"),
"{bad}"
);
}
// Short words count only alone or as a `_` part.
for good in ["Opal", "Modest", "Jebbo", "Hopper"] {
assert_eq!(try_nick(&mut n, good), "", "{good}");
}
fs::remove_dir_all(dir).ok();
}
#[test]
fn names_of_players_operators_the_team_and_other_nicknames_are_refused() {
let (mut n, dir) = setup("names");
for bad in [
"steve",
"St3ve",
"Steve_",
"TheSteve",
"SomeOp",
"S0me0p",
"rdbt",
"RDBT_",
"rdbtclient",
"luzW",
"Lu4ovic",
"ludovic1",
"Bunchy7s",
"levzzz",
"dezerx",
] {
assert_eq!(
try_nick(&mut n, bad),
format!("{bad} looks like the name of another player."),
"{bad}"
);
}
// A name of 3 letters only when it is the whole nickname.
assert!(try_nick(&mut n, "bob").contains("another player"));
assert_eq!(try_nick(&mut n, "Bobcat"), "");
// One's own name is fine.
assert_eq!(try_nick(&mut n, "Alex_the_Great"), "");
// Another player's nickname, in any spelling.
let rocky = Change {
uuid: STEVE,
nick: Some("Rocky".into()),
by: STEVE,
own: true,
};
n.apply(&rocky, "Steve", 0);
for bad in ["rocky", "R0cky", "Rocky_2", "RockyRoad"] {
assert!(try_nick(&mut n, bad).contains("another player"), "{bad}");
}
fs::remove_dir_all(dir).ok();
}
#[test]
fn a_nickname_shows_with_a_tilde_and_waits_for_playtime_and_the_cooldown() {
let (mut n, dir) = setup("set");
let k = known();
let alex = player(ALEX, "Alex", 0);
assert_eq!(
n.command(&alex, &k, 59 * 60, 0, "nick Sunny").unwrap(),
[Out::Error(
"Nicknames come after 60 minutes of play here; you have played 59 so far.".into()
)]
);
// `/nick` alone says how it works; a `~` typed is not part of it.
assert!(
matches!(&n.command(&alex, &k, 0, 0, "nick").unwrap()[0], Out::Reply(r) if r.contains("/nick off"))
);
assert_eq!(n.command(&alex, &k, HOUR, 0, "nick ~Sunny").unwrap(), []);
let c = n.pending.remove(0);
assert_eq!(c.nick.as_deref(), Some("Sunny"));
assert_eq!(
n.apply(&c, "Alex", 1000),
[Out::Tell(
ALEX,
"Alex now goes by ~Sunny. /nick off takes it off.".into()
)]
);
assert!(
matches!(&n.command(&alex, &k, HOUR, 2000, "nick").unwrap()[0], Out::Reply(r) if r.starts_with("You go by ~Sunny."))
);
assert_eq!(
n.command(&alex, &k, HOUR, 60_000, "nick off").unwrap(),
[Out::Error(
"You changed your nickname recently: wait 10 more minute(s).".into()
)]
);
assert_eq!(n.command(&alex, &k, HOUR, 601_000, "nick off").unwrap(), []);
n.save().unwrap();
let mut again = Nicks::open(&dir, open_to_all()).unwrap();
assert_eq!(
again.nick_of(ALEX),
Some("Sunny"),
"kept until the change is applied"
);
let steve = player(STEVE, "Steve", 0);
for asked in ["realname Sunny", "realname ~sunny"] {
assert_eq!(
again.command(&steve, &k, 0, 0, asked).unwrap(),
[Out::Reply("~Sunny is Alex.".into())]
);
}
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn nicknames_that_break_the_rules_are_taken_off_on_load() {
let (mut n, dir) = setup("purge");
for (uuid, nick) in [(ALEX, "Notch"), (STEVE, "Rocky"), (3, "rdbt"), (4, "ERROR")] {
n.file.nicks.insert(
uuid_string(uuid),
Nick {
nick: Some(nick.into()),
changed: 5,
given: false,
},
);
}
let gone = n.purge(&known());
let nicks: Vec<&str> = gone.iter().map(|g| g.1.as_str()).collect();
assert_eq!(nicks, ["Notch", "rdbt", "ERROR"]);
assert!(gone[0].2.contains("kept for the server"));
assert_eq!(n.all(), [(STEVE, "Rocky".to_owned())]);
// The cooldown stays: no new nickname at once.
assert_eq!(n.file.nicks[&uuid_string(ALEX)].changed, 5);
fs::remove_dir_all(dir).ok();
}
#[test]
fn commands_that_name_a_player_take_a_nickname() {
let (mut n, dir) = setup("resolve");
n.apply(
&Change {
uuid: STEVE,
nick: Some("Rocky".into()),
by: STEVE,
own: true,
},
"Steve",
0,
);
let is_name = |w: &str| ["alex", "steve"].contains(&w.to_ascii_lowercase().as_str());
let name_of = |u: u128| known().name_of(u);
let r = |line: &str| n.resolve(line, &is_name, &name_of);
assert_eq!(r("pay Rocky 50").as_deref(), Some("pay Steve 50"));
assert_eq!(r("pay ~rocky 50").as_deref(), Some("pay Steve 50"));
assert_eq!(r("tpa Rocky").as_deref(), Some("tpa Steve"));
assert_eq!(r("trade Rocky").as_deref(), Some("trade Steve"));
assert_eq!(
r("trade accept Rocky").as_deref(),
Some("trade accept Steve")
);
assert_eq!(r("eco give Rocky 5").as_deref(), Some("eco give Steve 5"));
assert_eq!(r("pay Steve 50"), None, "a real name stays");
assert_eq!(r("trade accept"), None);
assert_eq!(r("eco give * 5"), None);
assert_eq!(r("pay Nobody 5"), None);
assert_eq!(r("balance"), None);
fs::remove_dir_all(dir).ok();
}
#[test]
fn operators_set_others_nicknames_without_a_cooldown_or_playtime() {
let (mut n, dir) = setup("op");
let k = known();
let op = player(ALEX, "Alex", 3);
assert_eq!(
n.command(&player(STEVE, "Steve", 0), &k, HOUR, 0, "nick Alex Bobby")
.unwrap(),
[Out::Error(
"Only operators of level 3 may set another player's nickname.".into()
)]
);
assert_eq!(n.command(&op, &k, 0, 0, "nick Steve Rocky").unwrap(), []);
let c = n.pending.remove(0);
assert_eq!(
n.apply(&c, "Steve", 5),
[
Out::Tell(
ALEX,
"Steve now goes by ~Rocky. /nick off takes it off.".into()
),
Out::Tell(
STEVE,
"Steve now goes by ~Rocky. /nick off takes it off.".into()
)
]
);
assert_eq!(n.command(&op, &k, 0, 10, "nick Steve off").unwrap(), []);
assert_eq!(
n.command(&op, &k, 0, 10, "nick Nobody x").unwrap(),
[Out::Error("No player named Nobody has played here.".into())]
);
assert_eq!(n.command(&op, &k, 0, 10, "balance"), None);
fs::remove_dir_all(dir).ok();
}
#[test]
fn only_operators_set_nicknames_and_players_own_ones_go_on_load() {
let (mut n, dir) = setup_with("ops", Config::default());
let k = known();
let alex = player(ALEX, "Alex", 0);
let op = player(OP, "Boss", 4);
assert_eq!(
n.command(&alex, &k, 10 * HOUR, 0, "nick Sunny").unwrap(),
[Out::Error(
"Only operators set nicknames on this server.".into()
)]
);
assert!(
matches!(&n.command(&alex, &k, 0, 0, "nick").unwrap()[0], Out::Reply(r) if r == "Only operators set nicknames on this server.")
);
// An operator's own, and one an operator gives a player.
assert_eq!(n.command(&op, &k, 0, 0, "nick Chief").unwrap(), []);
assert_eq!(n.command(&op, &k, 0, 0, "nick Steve Rocky").unwrap(), []);
for c in std::mem::take(&mut n.pending) {
n.apply(&c, "x", 1);
}
// A nickname picked before the rule (not given), which goes.
n.file.nicks.insert(
uuid_string(ALEX),
Nick {
nick: Some("Sunny".into()),
changed: 5,
given: false,
},
);
// The player may take off the one given to them.
let steve = player(STEVE, "Steve", 0);
assert!(
matches!(&n.command(&steve, &k, 0, 9, "nick").unwrap()[0], Out::Reply(r) if r == "You go by ~Rocky. /nick off takes it off.")
);
n.save().unwrap();
let mut again = Nicks::open(&dir, Config::default()).unwrap();
let gone = again.purge(&k);
assert_eq!(gone.len(), 1);
assert_eq!((gone[0].0, gone[0].1.as_str()), (ALEX, "Sunny"));
assert_eq!(gone[0].2, "nicknames are for operators only");
let mut left = again.all();
left.sort();
assert_eq!(
left,
[(STEVE, "Rocky".to_owned()), (OP, "Chief".to_owned())]
);
assert_eq!(
again
.command(&steve, &k, 0, 10_000_000, "nick off")
.unwrap(),
[]
);
fs::remove_dir_all(dir).ok();
}
}
+113 -4
View File
@@ -30,6 +30,33 @@ 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 {
@@ -44,6 +71,7 @@ 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(),
}
}
}
@@ -93,6 +121,7 @@ enum To {
pub struct Pending {
pub uuid: u128,
to: To,
fee: i64,
from: Place,
health: f32,
due: i64,
@@ -114,6 +143,11 @@ 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,
pub what: String,
/// The cooldown and `/back` before it: `call_off` puts them back.
undo: (Option<i64>, Option<(Place, bool)>),
}
/// The places' commands.
@@ -237,8 +271,44 @@ 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
/// cooldown, and `/back` goes where it went before.
pub fn call_off(&mut self, t: &Teleport) {
match t.undo.0 {
Some(at) => self.last.insert(t.uuid, at),
None => self.last.remove(&t.uuid),
};
match &t.undo.1 {
Some(b) => self.back.insert(t.uuid, b.clone()),
None => self.back.remove(&t.uuid),
};
}
/// 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 {
@@ -252,13 +322,19 @@ 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: don't move.",
"Teleporting in {} s{cost}: don't move.",
self.config.warmup_seconds
))]
} else {
@@ -549,13 +625,18 @@ impl Places {
};
match to {
Some(to) => {
self.last.insert(p.uuid, now);
self.back.insert(p.uuid, (p.from.clone(), false));
let undo = (
self.last.insert(p.uuid, now),
self.back.insert(p.uuid, (p.from.clone(), false)),
);
go.push(Teleport {
uuid: p.uuid,
name: me.name.clone(),
to,
done: format!("Teleported to {what}."),
fee: p.fee,
what,
undo,
});
}
None => out.push(Out::Tell(
@@ -727,7 +808,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: don't move.".into())
Out::Tell(A, "Teleporting in 3 s (fee 10 coins): don't move.".into())
);
// Steve walked on meanwhile: Alex lands where Steve is then.
let later = [body(A, 0.0), body(B, 120.0)];
@@ -757,6 +838,34 @@ mod tests {
assert!(matches!(&out[0], Out::Error(_)));
}
#[test]
fn a_called_off_teleport_keeps_back_and_no_cooldown() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
let mut dead = body(A, 40.0);
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()
)]
);
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.
p.call_off(&go[0]);
p.command(&player(A, 0), &alive, 4_000, "back").unwrap();
let (_, go) = p.tick(&alive, 7_000);
assert_eq!(go[0].to, place(40.0));
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn spawn_and_warps_are_set_by_operators() {
let d = dir();
+58 -2
View File
@@ -25,6 +25,9 @@ pub struct Config {
pub welcome: Vec<String>,
/// Paid once a day (UTC) to each player who is on; 0: none.
pub daily_reward: i64,
/// Minutes of play (moving, not AFK) a day before the reward: an alt
/// that only logs in gets nothing.
pub reward_after_minutes: i64,
/// Minutes without a move, a turn or a command before a player is AFK;
/// 0: only `/afk`.
pub afk_minutes: i64,
@@ -45,6 +48,7 @@ impl Default for Config {
"Set a home with /sethome, claim your land with /claim, see the richest with /baltop.".into(),
],
daily_reward: 50,
reward_after_minutes: 10,
afk_minutes: 5,
afk_announce: false,
spam_commands: 8,
@@ -91,6 +95,9 @@ pub struct Presence {
afk: HashSet<u128>,
/// Each player's last commands (ms), for the limit.
recent: HashMap<u128, VecDeque<i64>>,
/// Seconds each player was active today (UTC day), toward the reward;
/// not kept over a restart.
played_today: HashMap<u128, (String, i64)>,
}
impl Presence {
@@ -115,6 +122,7 @@ impl Presence {
on: HashMap::new(),
afk: HashSet::new(),
recent: HashMap::new(),
played_today: HashMap::new(),
})
}
@@ -195,11 +203,27 @@ impl Presence {
out.push(self.away(p.uuid, &p.name, &mut acts));
}
}
// Each player's first second on today, or the day turning while on.
// Once a player has been active long enough today (moved or ran a
// command in the last minute, each second counts).
if self.config.daily_reward > 0 {
let need = self.config.reward_after_minutes.max(0) * 60;
for p in players {
let key = smp_store::uuid_string(p.uuid);
if self.data.rewarded.get(&key).map(String::as_str) != Some(today) {
if self.data.rewarded.get(&key).map(String::as_str) == Some(today) {
continue;
}
let active = self.on.get(&p.uuid).is_some_and(|(_, t)| now - t < 60_000);
let day = self
.played_today
.entry(p.uuid)
.or_insert_with(|| (today.to_owned(), 0));
if day.0 != today {
*day = (today.to_owned(), 0);
}
if active {
day.1 += 1;
}
if day.1 >= need {
self.data.rewarded.insert(key, today.to_owned());
self.dirty = true;
acts.push(Act::Reward(p.uuid));
@@ -333,6 +357,7 @@ mod tests {
#[test]
fn the_daily_reward_comes_once_a_day() {
let (mut p, _) = open(vec![]);
p.config.reward_after_minutes = 0;
let (_, acts) = p.tick(&[seen(A, 0.0)], "2026-10-31", 0, &[]);
assert_eq!(acts, vec![Act::Reward(A)]);
p.tick(&[], "2026-10-31", 1_000, &[]);
@@ -343,6 +368,37 @@ mod tests {
assert_eq!(acts, vec![Act::Reward(A)]);
}
#[test]
fn the_daily_reward_needs_ten_minutes_of_play_so_logging_in_alts_get_nothing() {
let (mut p, _) = open(vec![A, B]);
let mut x = 0.0;
let mut rewarded = Vec::new();
for s in 0..700 {
x += 1.0;
// Alex walks; Steve (an alt) stands still after logging in.
let (_, acts) = p.tick(&[seen(A, x), seen(B, 0.0)], "2026-10-31", s * 1000, &[]);
rewarded.extend(acts.into_iter().filter_map(|a| match a {
Act::Reward(u) => Some((u, s)),
_ => None,
}));
}
// Alex: rewarded on his 600th second of play. Steve's first minute
// counts, then nothing.
assert_eq!(rewarded, vec![(A, 599)]);
// Leaving and coming back the same day: the count goes on.
let (mut q, _) = open(vec![A]);
for s in 0..300 {
q.tick(&[seen(A, s as f64)], "2026-10-31", s * 1000, &[]);
}
q.tick(&[], "2026-10-31", 400_000, &[]);
let mut got = false;
for s in 0..302 {
let (_, acts) = q.tick(&[seen(A, s as f64)], "2026-10-31", 500_000 + s * 1000, &[]);
got |= acts.contains(&Act::Reward(A));
}
assert!(got);
}
#[test]
fn a_still_player_goes_afk_and_comes_back_by_moving_or_a_command() {
let (mut p, _) = open(vec![A]);
+50 -1
View File
@@ -324,9 +324,10 @@ mod tests {
fn the_default_prices_have_no_loop() {
let config = Config::default();
assert_eq!(loops(&config.items, &chains()), []);
// One price an item (spread 0), or one side closed.
for (id, p) in &config.items {
assert!(
p.buy == 0 || p.sell < p.buy,
p.buy == 0 || p.sell == 0 || p.sell == p.buy,
"{id} sells for {} and buys for {}",
p.sell,
p.buy
@@ -446,6 +447,54 @@ mod tests {
assert!(recipes.len() > 1000, "{} recipes", recipes.len());
let config = Config::default();
assert_eq!(loops(&config.items, &recipes), []);
// Nor once prices have moved as far as they may (0.6 to 1.6 times
// their base): an item sold at the top while what makes it is
// bought at the bottom, unless both share one price (a family).
let families = super::super::smp_market::families(&config.items);
let (lo, hi) = (config.dynamic.min_factor, config.dynamic.max_factor);
let worst = |items: &BTreeMap<String, Price>, families: &[Vec<String>]| {
let mut worst = Vec::new();
for (id, p) in items.iter().filter(|(_, p)| p.sell > 0) {
let kin = |other: &String| {
other == id || families.iter().any(|f| f.contains(id) && f.contains(other))
};
let moved: BTreeMap<String, Price> = items
.iter()
.map(|(other, q)| {
let f = if kin(other) { hi } else { lo };
// In hundredths, so rounding hides nothing.
let scale = |v: i64| (v as f64 * f * 100.0).round() as i64;
(
other.clone(),
Price {
buy: scale(q.buy),
sell: scale(q.sell),
},
)
})
.collect();
if let Some(c) = costs(&moved, &recipes).get(id)
&& p.sell as f64 * hi * 100.0 > c + 1e-6
{
worst.push((id.clone(), p.sell as f64 * hi, *c / 100.0));
}
}
worst
};
assert_eq!(
worst(&config.items, &families),
[],
"loops once prices move"
);
// A gold block sold for its 9 ingots: no loop while they share one
// price; one once the ingots drift down and the block up.
let mut blocks = config.items.clone();
blocks.insert("minecraft:gold_block".into(), Price { buy: 0, sell: 180 });
assert_eq!(
worst(&blocks, &super::super::smp_market::families(&blocks)),
[]
);
assert_eq!(worst(&blocks, &[])[0].0, "minecraft:gold_block");
// The checker does find a loop through the real recipes.
let mut cheap = config.items.clone();
cheap.insert("minecraft:iron_block".into(), Price { buy: 0, sell: 300 });
+498
View File
@@ -0,0 +1,498 @@
// The economy in the open: `/eco stats` (the money supply and where it
// sits, what the server made and destroyed today, the Treasury's day, how
// evenly money is spread), a report to everyone at 00:00 UTC on the day
// before, and `web/economy.json` for the site's /hub (written once a minute,
// off the tick thread, by the glue). One snapshot a day is kept (60 days) in
// `economy.json` (`ext.report`) for the curves. Reading only: this part
// moves no money. No game types. Added to the game as `game::smp_report`;
// also a module of this crate, for its tests.
use super::smp_economy::{DAY_MS, Economy, INSURANCE, Out, TREASURY};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashMap};
/// `config.json`'s `report`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// The 00:00 UTC report in everyone's chat.
pub daily_report: bool,
/// `web/economy.json` once a minute.
pub web_json: bool,
pub history_days: usize,
}
impl Default for Config {
fn default() -> Self {
Self {
daily_report: true,
web_json: true,
history_days: 60,
}
}
}
/// One day, as the report keeps it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Day {
/// Days since 1970 (UTC).
pub day: i64,
/// All the money there is, at the end of the day.
pub supply: i64,
/// Players' money: wallets, savings, money held (bids, stakes, margins).
pub players: i64,
pub treasury: i64,
pub insurance: i64,
/// Made and destroyed by the server that day.
pub minted: i64,
pub burned: i64,
/// The Treasury's income and spending that day.
pub taxes: i64,
pub spent: i64,
/// Over players with money: Gini (0 to 1, in thousandths) and the
/// richest ten's share (percent).
pub gini_milli: i64,
pub top10_percent: i64,
}
/// `ext.report`.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct State {
pub days: Vec<Day>,
/// The last day reported.
pub day: i64,
}
pub struct Report {
pub config: Config,
pub state: State,
dirty: bool,
minute: i64,
}
/// Pools that are the state's, not players' money.
const STATE_POOLS: [&str; 3] = [TREASURY, INSURANCE, "casino"];
/// Gini (thousandths) and the top ten's share (percent) of `wealth`.
pub fn spread(mut wealth: Vec<i64>) -> (i64, i64) {
wealth.retain(|w| *w > 0);
wealth.sort_unstable();
let n = wealth.len() as i128;
let total: i128 = wealth.iter().map(|w| i128::from(*w)).sum();
if total == 0 {
return (0, 0);
}
let weighted: i128 = wealth
.iter()
.enumerate()
.map(|(i, w)| (2 * i as i128 - n + 1) * i128::from(*w))
.sum();
let top: i128 = wealth.iter().rev().take(10).map(|w| i128::from(*w)).sum();
(
(weighted * 1000 / (n * total)) as i64,
(top * 100 / total) as i64,
)
}
fn sum(m: &BTreeMap<String, i64>) -> i64 {
m.values().sum()
}
/// `market sale 60,000 coins, daily reward 20,000 coins` (largest first).
fn list(e: &Economy, m: &BTreeMap<String, i64>, n: usize) -> String {
let mut v: Vec<(&String, &i64)> = m.iter().filter(|(_, a)| **a > 0).collect();
v.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
let parts: Vec<String> = v
.iter()
.take(n)
.map(|(k, a)| format!("{} {}", label(k), e.money(**a)))
.collect();
if parts.is_empty() {
"nothing".into()
} else {
parts.join(", ")
}
}
fn label(kind: &str) -> String {
match kind {
"sell" => "market sales".into(),
"buy" => "market purchases".into(),
k => k.replace('_', " "),
}
}
impl Report {
pub fn open(economy: &Economy, config: Config) -> Self {
let mut state = economy
.ext("report")
.and_then(|v| serde_json::from_value::<State>(v.clone()).ok())
.unwrap_or_default();
if state.day == 0 {
state.day = economy.now.div_euclid(DAY_MS);
}
Self {
config,
state,
dirty: false,
minute: 0,
}
}
pub fn store(&mut self, economy: &mut Economy) {
if self.dirty {
economy.set_ext(
"report",
serde_json::to_value(&self.state).unwrap_or_default(),
);
self.dirty = false;
}
}
/// The day's numbers now. `savings`: what each player has in the bank.
pub fn snapshot(economy: &mut Economy, savings: &HashMap<u128, i64>, day: i64) -> Day {
let accounts = economy.accounts();
let wallets: i64 = accounts.iter().map(|a| a.2).sum();
let pools: i64 = economy.pools().values().sum();
let state: i64 = STATE_POOLS.iter().map(|p| economy.pool(p)).sum();
let supply = wallets + economy.held_total() + pools;
let mut wealth: HashMap<u128, i64> = accounts.iter().map(|a| (a.0, a.2)).collect();
for (u, s) in savings {
*wealth.entry(*u).or_default() += s;
}
let (gini_milli, top10_percent) = spread(wealth.into_values().collect());
let today = economy.now.div_euclid(DAY_MS);
let (minted, burned, taxes, spent) = {
let t = economy.treasury();
let (inc, out) = if t.day == day {
(sum(&t.income_today), sum(&t.spent_today))
} else if t.day == day + 1 {
(sum(&t.income_yesterday), sum(&t.spent_yesterday))
} else {
(0, 0)
};
let f = economy.flows();
let (m, b) = if day == today {
(sum(&f.minted), sum(&f.burned))
} else if day + 1 == today {
(sum(&f.minted_yesterday), sum(&f.burned_yesterday))
} else {
(0, 0)
};
(m, b, inc, out)
};
Day {
day,
supply,
players: supply - state,
treasury: economy.pool(TREASURY),
insurance: economy.pool(INSURANCE),
minted,
burned,
taxes,
spent,
gini_milli,
top10_percent,
}
}
/// Runs `/eco stats` (or `/money stats`); None for another command.
pub fn command(
&self,
economy: &mut Economy,
savings: &HashMap<u128, i64>,
line: &str,
) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
if !matches!(words.as_slice(), ["eco" | "money", "stats"]) {
return None;
}
let today = economy.now.div_euclid(DAY_MS);
let d = Self::snapshot(economy, savings, today);
let since = self.state.days.last().map(|y| d.supply - y.supply);
let pools: Vec<String> = economy
.pools()
.iter()
.filter(|(k, v)| **v > 0 && !STATE_POOLS.contains(&k.as_str()))
.map(|(k, v)| format!("{} {}", k.replace('_', " "), economy.money(*v)))
.collect();
let flows = economy.flows().clone();
let t = economy.treasury().clone();
let (inc, out) = if t.day == today {
(t.income_today, t.spent_today)
} else {
Default::default()
};
let mut lines = vec![
Out::Reply(format!(
"Money supply: {}{}. Players hold {} (of it in pools: {}), the Treasury {}, the insurance fund {}.",
economy.money(d.supply),
since.map_or(String::new(), |s| format!(
" ({}{} since midnight)",
if s >= 0 { "+" } else { "-" },
economy.money(s.abs())
)),
economy.money(d.players),
if pools.is_empty() {
"none".into()
} else {
pools.join(", ")
},
economy.money(d.treasury),
economy.money(d.insurance)
)),
Out::Reply(format!(
"Today the server made {} ({}) and destroyed {} ({}).",
economy.money(sum(&flows.minted)),
list(economy, &flows.minted, 3),
economy.money(sum(&flows.burned)),
list(economy, &flows.burned, 3)
)),
Out::Reply(format!(
"Taxes and fees today: {} ({}). The Treasury paid out {}.",
economy.money(sum(&inc)),
list(economy, &inc, 4),
economy.money(sum(&out))
)),
Out::Reply(format!(
"Spread: Gini {}.{:03} (0 equal, 1 one player has all), the richest ten hold {}%.",
d.gini_milli / 1000,
d.gini_milli % 1000,
d.top10_percent
)),
];
lines.push(Out::Reply(
"The Treasury: /treasury. The taxes: /eco taxes.".into(),
));
Some(lines)
}
/// Whether `tick` has work at `now` (a new minute or day): the glue
/// gathers the savings only then.
pub fn due(&self, now: i64) -> bool {
now.div_euclid(60_000) != self.minute || now.div_euclid(DAY_MS) > self.state.day
}
/// Once a second from the glue. At the day's turn: the day before is
/// kept and, if on, told to everyone. Returns the lines, and whether
/// `web/economy.json` is due (once a minute).
pub fn tick(
&mut self,
economy: &mut Economy,
savings: &HashMap<u128, i64>,
now: i64,
) -> (Vec<Out>, bool) {
let minute = now.div_euclid(60_000);
let web = self.config.web_json && minute != self.minute;
self.minute = minute;
let today = now.div_euclid(DAY_MS);
if today <= self.state.day {
return (Vec::new(), web);
}
let yesterday = today - 1;
self.state.day = today;
self.dirty = true;
// The supply now is yesterday's end, give or take a second.
let d = Self::snapshot(economy, savings, yesterday);
let before = self
.state
.days
.last()
.filter(|p| p.day + 1 == yesterday)
.map(|p| p.supply);
self.state.days.push(d.clone());
let keep = self.config.history_days.max(1);
if self.state.days.len() > keep {
let extra = self.state.days.len() - keep;
self.state.days.drain(..extra);
}
let mut out = Vec::new();
if self.config.daily_report {
out.push(Out::All(self.daily_line(economy, &d, before)));
}
(out, web)
}
fn daily_line(&self, economy: &Economy, d: &Day, before: Option<i64>) -> String {
format!(
"Economy yesterday: money supply {}{}; the server made {} and destroyed {}; taxes and fees {} to the Treasury (now {}), which paid out {}. /eco stats",
economy.money(d.supply),
before.map_or(String::new(), |b| {
let c = d.supply - b;
format!(
" ({}{})",
if c >= 0 { "+" } else { "-" },
economy.money(c.abs())
)
}),
economy.money(d.minted),
economy.money(d.burned),
economy.money(d.taxes),
economy.money(d.treasury),
economy.money(d.spent)
)
}
/// `web/economy.json`: aggregates, the Treasury's every movement (public
/// in game too), the tax code, the days kept.
pub fn web(
&self,
economy: &mut Economy,
savings: &HashMap<u128, i64>,
taxes: Value,
now: i64,
) -> Value {
let today = now.div_euclid(DAY_MS);
let d = Self::snapshot(economy, savings, today);
let flows = economy.flows().clone();
let t = economy.treasury().clone();
let fresh = t.day == today;
let pools: BTreeMap<String, i64> = economy.pools().clone();
let daily = self.state.days.last().map(|y| {
self.daily_line(
economy,
y,
self.state
.days
.iter()
.rev()
.nth(1)
.filter(|p| p.day + 1 == y.day)
.map(|p| p.supply),
)
});
json!({
"schema": 1,
"generated": now,
"currency": economy.config.currency,
"today": d,
"pools": pools,
"minted_today": flows.minted,
"burned_today": flows.burned,
"treasury": {
"balance": economy.pool(TREASURY),
"insurance": economy.pool(INSURANCE),
"income": t.income,
"spent": t.spent,
"income_today": if fresh { t.income_today } else { BTreeMap::new() },
"spent_today": if fresh { t.spent_today } else { BTreeMap::new() },
"log": t.log,
},
"taxes": taxes,
"days": self.state.days,
"daily_report": daily,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::smp_economy::Config as EcoConfig;
const A: u128 = 1;
const B: u128 = 2;
const T0: i64 = 1_793_577_600_000; // a midnight, UTC
fn setup(name: &str) -> (Economy, Report) {
let dir = std::env::temp_dir().join(format!("smp-report-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut e = Economy::open(&dir, EcoConfig::default()).unwrap();
e.now = T0 + 1000;
e.seen(A, "Alex");
e.seen(B, "Bo");
let r = Report::open(&e, Config::default());
(e, r)
}
#[test]
fn spread_measures_inequality() {
assert_eq!(spread(vec![10, 10, 10, 10]), (0, 100));
let (g, _) = spread(vec![0, 0, 0, 100]);
assert_eq!(g, 0, "only players with money count");
let (g, top) = spread((1..=20).map(|i| if i == 20 { 1_000 } else { 1 }).collect());
assert!(g > 900, "{g}");
assert_eq!(top, 99);
}
#[test]
fn stats_show_what_was_made_destroyed_and_taxed_today() {
let (mut e, r) = setup("stats");
e.adjust("sell 20 minecraft:wheat", A, 600, "").unwrap();
e.adjust("daily_reward", B, 50, "").unwrap();
e.adjust("buy 5 minecraft:torch", A, -100, "").unwrap();
e.to_pool("vat", A, TREASURY, 25, "").unwrap();
let out = r
.command(&mut e, &HashMap::from([(B, 0)]), "eco stats")
.unwrap();
let text: Vec<String> = out
.iter()
.map(|o| match o {
Out::Reply(s) => s.clone(),
o => panic!("{o:?}"),
})
.collect();
assert!(
text[0].starts_with("Money supply: 550 coins. Players hold 525 coins"),
"{}",
text[0]
);
assert_eq!(
text[1],
"Today the server made 650 coins (market sales 600 coins, daily reward 50 coins) and destroyed 100 coins (market purchases 100 coins)."
);
assert!(
text[2].starts_with("Taxes and fees today: 25 coins (vat 25 coins)"),
"{}",
text[2]
);
assert!(r.command(&mut e, &HashMap::new(), "eco give A 5").is_none());
}
#[test]
fn midnight_reports_the_day_before_once_and_keeps_it_through_a_restart() {
let (mut e, mut r) = setup("daily");
e.adjust("sell 1 minecraft:diamond", A, 100, "").unwrap();
e.to_pool("vat", A, TREASURY, 5, "").unwrap();
let (out, web) = r.tick(&mut e, &HashMap::new(), T0 + 2000);
assert!(out.is_empty() && web, "same day; the JSON's first minute");
assert!(
!r.tick(&mut e, &HashMap::new(), T0 + 3000).1,
"once a minute"
);
e.now = T0 + DAY_MS + 500;
let now = e.now;
let (out, _) = r.tick(&mut e, &HashMap::new(), now);
assert_eq!(
out,
[Out::All(
"Economy yesterday: money supply 100 coins; the server made 100 coins and destroyed 0 coins; taxes and fees 5 coins to the Treasury (now 5 coins), which paid out 0 coins. /eco stats"
.into()
)]
);
assert!(
r.tick(&mut e, &HashMap::new(), now + 1000).0.is_empty(),
"once"
);
let json = r.web(&mut e, &HashMap::new(), json!({}), now);
assert_eq!(json["schema"], 1);
assert_eq!(json["days"][0]["minted"], 100);
assert_eq!(json["treasury"]["balance"], 5);
assert!(
json["daily_report"]
.as_str()
.unwrap()
.starts_with("Economy yesterday")
);
r.store(&mut e);
e.save().unwrap();
let dir = std::env::temp_dir().join(format!("smp-report-daily-{}", std::process::id()));
let e2 = Economy::open(&dir, EcoConfig::default()).unwrap();
let r2 = Report::open(&e2, Config::default());
assert_eq!(r2.state.days.len(), 1);
assert_eq!(r2.state.day, (T0 + DAY_MS).div_euclid(DAY_MS));
}
}
+1 -1
View File
@@ -405,7 +405,7 @@ mod tests {
let cells = shop.layout(&config, &market, ALEX);
assert_eq!(
cells[0],
Cell::Item("minecraft:coal".into(), Price { buy: 8, sell: 2 })
Cell::Item("minecraft:coal".into(), Price { buy: 4, sell: 4 })
);
assert_eq!(cells[BACK], Cell::Back);
assert_eq!(
+8
View File
@@ -51,6 +51,14 @@ pub fn write_json<T: serde::Serialize>(path: &Path, value: &T) -> Result<(), Str
write_atomic(path, &bytes).map_err(|e| format!("{}: {e}", path.display()))
}
/// `value` as compact JSON in `path`, atomically (large files: the
/// market's prices and the web pages).
pub fn write_json_compact<T: serde::Serialize>(path: &Path, value: &T) -> Result<(), String> {
let mut bytes = serde_json::to_vec(value).map_err(|e| e.to_string())?;
bytes.push(b'\n');
write_atomic(path, &bytes).map_err(|e| format!("{}: {e}", path.display()))
}
/// The audit log: `audit.log`, one JSON object a line.
pub struct Audit {
path: PathBuf,
+676
View File
@@ -0,0 +1,676 @@
// 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();
}
}
+1
View File
@@ -8,5 +8,6 @@ fn smp_server_tick(
crate::game::native_lifecycle::ServerTickPhase::ServerTickEnd { .. }
) {
crate::game::smp_glue::tick(game);
crate::game::smp_invsee_glue::tick(game);
}
}
+319 -51
View File
@@ -2,8 +2,11 @@
Steve, `horizon-ctl --offscreen`) join it. Checked from the clients' own chat and from the files the server wrote:
- 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, to oneself, unknown name);
- /baltop; /eco is refused below level 3 and works for an operator (made one from the server's console);
- /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;
- /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
items gone from or in his inventory as his client sees it; a creative player can't sell;
@@ -16,15 +19,30 @@ 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;
- /money and /eco give * for an operator, then the /shop menu: a category, one item bought by a click and a stack by
- a Treasury payment for an operator, 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.
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
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;
- the web pages (web/market.json, web/economy.json) sent gzipped with the token to a stand-in for Kira;
- /invsee: an operator takes a stack from an offline player's saved inventory, logged before the move and summed up in
staff.log;
- nicknames, for operators only: a player can't pick one; an operator's refused ones (a player's name, too short, a
bad character), the one given in the other's chat and Tab list, /realname, someone else's taken, the player taking
it off.
The casino has its own proof (proof_casino.py): `SMP_PROOF=casino` runs it instead of this one.
Everything runs in fresh temporary folders (the server's, each client's home, launcher and XDG folders), no audio.
"""
import gzip
import http.server
import json
import math
import os
import re
import shutil
import socket
import subprocess
@@ -40,6 +58,29 @@ PROPERTIES = {
}
class Receiver:
"""Stands in for Kira's POST /smp/ingest/<name>: keeps what the server sends (its path, bearer, unzipped body)."""
def __init__(self):
got = self.got = []
class Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
if self.headers.get("Content-Encoding") == "gzip":
body = gzip.decompress(body)
got.append((self.path, self.headers.get("Authorization"), json.loads(body)))
self.send_response(200)
self.end_headers()
def log_message(self, *a):
pass
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.url = f"http://127.0.0.1:{self.server.server_address[1]}/smp/ingest"
threading.Thread(target=self.server.serve_forever, daemon=True).start()
def free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
@@ -412,7 +453,8 @@ def world_guard(server, alex, steve):
def board(alex, steve, out):
"""The sidebar of the richest players and the Tab list; /sidebar off hides it, on shows it again."""
"""The scoreboard sidebar (the player's own block, the richest, the address) and the Tab list; /sidebar off
hides it, on shows it again."""
r = {}
side = lambda c: c.run("status").get("sidebar")
for _ in range(20):
@@ -420,9 +462,10 @@ def board(alex, steve, out):
if r["sidebar"]:
break
time.sleep(0.5)
assert r["sidebar"]["objective"] == "horizon:native_sidebar" and r["sidebar"]["title"] == "Richest players", r
# Alex and Steve, a gap, then Steve's own line.
assert len(r["sidebar"]["scores"]) == 4, r
assert r["sidebar"]["objective"] == "horizon:native_sidebar" and r["sidebar"]["title"] == "Horizon SMP", r
# Steve's name, balance, playtime and the players on; a gap, "Richest", Alex and Steve (the two accounts); a gap,
# the address.
assert len(r["sidebar"]["scores"]) == 10, r
# Holders starting with '#' would be hidden from the sidebar (only the title would show).
assert not any(o.startswith("#") for o in r["sidebar"]["scores"]), r
steve.run("key", "tab", "down")
@@ -502,17 +545,74 @@ def presence(server, alex, steve):
return r
def nicks(server, alex, steve, out):
"""Nicknames are for operators: Steve can't pick one; Alex (an operator) gives him one, refused ones first, then
~Rocky in Alex's chat and the Tab list, /realname, /pay by nickname, and Steve takes it off himself. The one an
older version let through (a player's own pick) is gone."""
r = {}
r["purged"] = [l for l in server.lines if "taken off" in l]
assert any("nickname N0tch of" in l and "for operators only" in l for l in r["purged"]), r
time.sleep(6) # the spam guard's window from the step before
for line in ("/nick", "/nick Rocky"):
r[line] = steve.say(line)
assert any("Only operators set nicknames on this server." in l for l in r[line]), r
for line, error in (("/nick Steve Alex", "Alex looks like the name of another player."),
("/nick Steve A1ex_", "A1ex_ looks like the name of another player."),
("/nick Steve N0tch", "N0tch reads as \"notch\": that word is kept for the server."),
("/nick Steve ab", "A nickname is 3 to 16 letters, digits or _.")):
r[line] = alex.say(line)
assert any(error in l for l in r[line]), r
time.sleep(6)
alex.run("chat", "/nick Steve Rocky")
time.sleep(3) # made at the next second's tick
r["set"] = steve.chat()[-3:]
assert any("Steve now goes by ~Rocky." in l for l in r["set"]), r
steve.run("chat", "hello from rocky")
time.sleep(2)
r["chat"] = alex.chat()[-3:]
assert any("~Rocky" in l and "hello from rocky" in l and "Steve" not in l for l in r["chat"]), r
if os.environ.get("SMP_PROOF_MSG_NICK"):
# An engine whose player arguments take nicknames (horizon fix/commands-take-nicknames).
alex.run("chat", "/msg Rocky psst")
time.sleep(2)
r["msg"] = steve.chat()[-3:]
assert any("whispers to you: psst" in l for l in r["msg"]), r
alex.run("key", "tab", "down")
time.sleep(1)
alex.run("screenshot", str(Path(out).resolve() / "nick-tab.png"))
alex.run("key", "tab", "up")
r["realname"] = alex.say("/realname ~rocky")
assert any("~Rocky is Steve." in l for l in r["realname"]), r
r["taken"] = alex.say("/nick R0cky")
assert any("R0cky looks like the name of another player." in l for l in r["taken"]), r
# A nickname where a command names a player; paid back so the sums stay.
r["pay"] = alex.say("/pay Rocky 1")
assert any("You paid 1 coins to Steve." in l for l in r["pay"]), r
steve.say("/pay Alex 1")
r["another"] = steve.say("/nick Pebble")
assert any("Only operators set nicknames on this server." in l for l in r["another"]), r
time.sleep(6)
steve.run("chat", "/nick off")
time.sleep(3)
r["off"] = steve.chat()[-3:]
assert any("Steve goes by their name again." in l for l in r["off"]), r
steve.run("chat", "back to steve")
time.sleep(2)
assert any("Steve" in l and "back to steve" in l for l in alex.chat()[-3:]), alex.chat()[-3:]
return r
def shop(server, alex):
"""Alex alone, op, in survival: /money and /eco give *, then the /shop menu: a category, buying one and a stack,
"""Alex alone, op, in survival: paid from the Treasury (/eco give refused), 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("/money give Alex 2000")
assert any("Alex now has" in l for l in results["money"]), results
results["everyone"] = alex.say("/eco give * 10")
assert any("Gave 10 coins to 1 player(s) on." in l for l in results["everyone"]), results
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
assert alex.count("minecraft:iron_ingot") == 0
alex.say("/shop")
menu = alex.menu()
@@ -523,34 +623,136 @@ def shop(server, alex):
"minecraft:iron_ingot"], menu
alex.click(300 + 2) # one iron ingot
assert alex.count("minecraft:iron_ingot") == 1, alex.run("inventory")
results["bought"] = alex.chat()[-2:]
assert any("Bought 1 iron ingot for 30 coins." in l for l in results["bought"]), results
results["bought"] = alex.chat()[-3:]
# One price, 15 (2% of it rounds to no tax).
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")
# Her own stack of iron, clicked: sold.
# Along the price as the stack moves it: 64 at 1 coin come to 64.05, 65 whole coins, + 1 tax.
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
# 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["sold"] = alex.chat()[-2:]
assert any("Sold 1 iron ingot for 8 coins." in l for l in results["sold"]), results
assert alex.count("minecraft:iron_ingot") == 0
# Sell all, up to the 2,000 coins a day: 19 of 30 diamonds (8 coins earned already).
results["round_trip"] = alex.chat()[-1:]
assert any("You bought 1 iron ingot from the market in the last 24 h" in l for l in results["round_trip"]), results
server.console("give Alex minecraft:iron_ingot 1")
time.sleep(2)
alex.click(alex.slot_of("minecraft:iron_ingot"))
results["sold"] = alex.chat()[-4:]
# Sold at the price she bought at: one price.
assert any("Sold 1 iron ingot at 15 coins each: 15 coins." in l for l in results["sold"]), results
assert any("1 iron ingot kept: bought from the market in the last 24 h." in l for l in results["sold"]), results
assert alex.count("minecraft:iron_ingot") == 1
# Sell all, up to the 2,000 coins a day: 1,984 left (1 from Steve's /pay in the nickname step: money received
# counts too; 15 for the iron), 9 diamonds at 200. Each one sold lowers the price: 40,000 * (1 - e^-0.045)
# = 1,760.1 for the 9.
server.console("give Alex minecraft:diamond 30")
time.sleep(2)
alex.click(300 + 49)
results["sell_all"] = alex.chat()[-3:]
assert any("Sold 19 diamond for 1,900 coins." in l for l in results["sell_all"]), results
assert any("11 diamond kept: today's selling limit." in l for l in results["sell_all"]), results
assert alex.count("minecraft:diamond") == 11, alex.run("inventory")
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 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]
results["limits"] = limits
assert "92 coins left of your 2,000 coins a day" in json.dumps(limits), limits
# 1,984 - 1,760.
assert "224 coins left of your 2,000 coins a day" in json.dumps(limits), limits
alex.click(300 + 53) # close
assert not alex.menu()
results["worth"] = alex.say("/worth diamond")
assert any("You may sell 621 more diamond today." in l for l in results["worth"]), results
return 2000 + 10 - 30 - 64 + 8 + 1900, results
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
def invsee(alex, data):
"""Alex, an operator, opens offline Steve's inventory (his saved file): his 5 torches are there; a click takes
them whole into hers, logged before the move (audit.log) and summed up in staff.log when she closes it."""
results = {}
before = alex.count("minecraft:torch")
alex.say("/invsee Steve")
time.sleep(2)
menu = alex.menu()
results["info"] = menu.get(49)
assert menu.get(49, {}).get("item") == "minecraft:player_head" and "offline" in json.dumps(menu[49]), menu
torch = next(slot for slot, e in menu.items() if e["item"] == "minecraft:torch" and slot < 36)
assert menu[torch]["count"] == 5, menu
alex.click(300 + torch)
time.sleep(2)
assert alex.count("minecraft:torch") == before + 5, alex.run("inventory")
assert all(e["item"] != "minecraft:torch" for slot, e in alex.menu().items() if slot < 41), alex.menu()
alex.click(300 + 53) # close
time.sleep(1)
staff = (data / "staff.log").read_text()
results["staff"] = staff.strip().splitlines()[-1]
assert "Steve" in staff and "5 torch" in staff, staff
return results
def market(server, alex, out, data):
"""After the shop, prices moving: her sale moved diamond's one price down, /market's item page buys
one and the price climbs back the next minute, the 24 h chart comes as a map drawn in her hand, and
web/market.json has it all. Returns what she spent, and the lines."""
results = {}
def worth():
lines = alex.say("/worth diamond")
line = next(l for l in lines if "diamond: " in l and "each, to buy or sell." in l)
return int(re.search(r"diamond: ([\d,]+) coins each", line)[1].replace(",", "")), lines
# Her 9 diamonds lowered the price as they sold: 200 * e^-0.045 = 191.2 (it drifts back slowly, 6 h half-life).
price, lines = worth()
results["after_sale"] = lines
assert price == 191, lines
# The move itself is checked above; "Moves" compares with the oldest point of the last hour, which on a history
# this young can be the first minute's own (0.0%).
assert any("Moves: " in l and "traded in 24 h: 9" in l for l in lines), lines
alex.say("/market diamond")
page = alex.menu()
results["page"] = page[13]
assert page[13]["item"] == "minecraft:diamond" and page[48]["item"] == "minecraft:gold_ingot", page
assert page[19]["item"] == "minecraft:lime_concrete" and page[21]["count"] == 64, page
assert page[39]["item"] == "minecraft:map", page
shown = json.dumps(page[13], ensure_ascii=False)
# Its 24 h move is shown (▼, or "unchanged" on a minute-old history: see "Moves" above).
assert f"Price: {price} coins each, to buy or sell" in shown and "24 h " in shown, shown
alex.click(300 + 19) # buy one
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
alex.click(300 + 39) # the 24 h chart
assert alex.count("minecraft:filled_map") == 1, alex.run("inventory")
alex.click(300 + 53) # close
alex.run("select", "minecraft:filled_map")
time.sleep(2)
alex.run("screenshot", str(Path(out).resolve() / "market-chart.png"))
# Her purchase pushed it back up at once.
price2, lines = worth()
results["after_buy"] = lines
assert price2 > price, (price, lines)
# web/market.json is written every minute: wait for the one after her purchase.
deadline = time.time() + 90
while True:
doc = json.loads((data / "web" / "market.json").read_text())
diamond = next(i for i in doc["items"] if i["id"] == "minecraft:diamond")
if diamond["volume_24h"] == 10 or time.time() > deadline:
break
time.sleep(5)
results["web"] = {k: diamond[k] for k in ("price", "buy", "sell", "change_1h", "volume_24h", "category")}
assert doc["schema"] == 1 and doc["moving"] and diamond["category"] == "Mining", results["web"]
# 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
def trades(server, alex, steve, port, ctl, resources, root):
@@ -632,6 +834,13 @@ def trades(server, alex, steve, port, ctl, resources, root):
def run(ctl, resources, out):
if os.environ.get("SMP_PROOF") == "casino":
# The casino's own proof (proof_casino.py), apart: it plays its games for a few minutes.
import importlib.util
spec = importlib.util.spec_from_file_location("smp_proof_casino", Path(__file__).with_name("proof_casino.py"))
casino = importlib.util.module_from_spec(spec)
spec.loader.exec_module(casino)
return casino.run(ctl, resources, out)
out = Path(out)
out.mkdir(parents=True, exist_ok=True)
game = Path(ctl).with_name("horizon-client")
@@ -646,9 +855,27 @@ 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},
"places": {"warmup_seconds": 2, "cooldown_seconds": 0},
# 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}},
# Paid once, after the restart, not to change the sums.
"presence": {"daily_reward": 0}}))
"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
# 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.
"casino": {"float": 0}}))
# A Treasury with money, the only place operators may pay from (nobody can make money).
(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": {
"nick": "N0tch", "changed": 0}}}))
server.start()
# No monster may end a step early: a slime once killed Steve on the flat world.
for line in ("difficulty peaceful", "gamerule spawn_monsters false", "kill @e[type=minecraft:slime]"):
@@ -658,8 +885,9 @@ 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"] == ["board", "claims", "currency", "gambling", "market", "places", "presence",
"shop", "starting_balance"], 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
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"]
@@ -667,37 +895,53 @@ def run(ctl, resources, out):
assert config["gambling"] == {"enabled": True, "min_bet": 10, "max_bet": 1000, "cooldown_seconds": 20,
"offer_seconds": 60, "fee_percent": 0, "ticket_price": 10,
"max_tickets": 100, "lottery_seconds": 30}, config
assert config["market"]["items"]["minecraft:diamond"] == {"buy": 400, "sell": 100}, 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)
alex.say("/eco give Alex 500")
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["balance"] = alex.say("/balance")
assert any("Balance: 500 coins" in l for l in results["balance"]), results
results["pay"] = alex.say("/pay Steve 120")
assert any("You paid 120 coins to Steve. Balance: 380 coins" in l for l in results["pay"]), results
results["paid"] = steve.chat()[-3:]
assert any("Alex paid you 120 coins." in l for l in results["paid"]), results
results["refused"] = [alex.say(l) for l in ("/pay Steve 9999", "/pay Alex 1", "/pay Nobody 1")]
for lines, error in zip(results["refused"], ("You only have 380 coins.", "You can't pay yourself.",
# 9999 is past what Steve may still receive today (2,000 a day, 120 received): money paid counts toward the
# same daily limit as selling, so alts can't sell for a main past it.
results["refused"] = [alex.say(l) for l in ("/pay Steve 1500", "/pay Steve 9999", "/pay Alex 1",
"/pay Nobody 1")]
for lines, error in zip(results["refused"], ("You only have 380 coins.",
"Steve can only receive 1,880 coins more today",
"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["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
server.console("give Steve minecraft:diamond 3")
server.console("give Steve minecraft:wheat 20")
server.console("give Steve minecraft:diamond 1")
server.console("give Steve minecraft:wheat 60")
time.sleep(2)
results["worth"] = steve.say("/worth diamond")
assert any("diamond: the market pays 100 coins each, sells it for 400 coins each." in l
for l in results["worth"]), results
assert any("diamond: 200 coins each, to buy or sell." in l for l in results["worth"]), results
results["sell_wheat"] = steve.say("/sell wheat")
assert any("Sold 20 wheat for 20 coins." in l for l in results["sell_wheat"]), results
assert any("Sold 60 wheat at 2 coins each: 120 coins." in l for l in results["sell_wheat"]), results
results["sell_hand"] = steve.say("/sell hand")
assert any("Sold 3 diamond for 300 coins." in l for l in results["sell_hand"]), results
assert any("Sold 1 diamond at 200 coins each: 200 coins." in l for l in results["sell_hand"]), results
results["buy"] = steve.say("/buy torch 5")
assert any("Bought 5 torch for 5 coins." in l for l in results["buy"]), results
assert any("Bought 5 torch at 1 coins each: 5 coins." in l for l in results["buy"]), results
time.sleep(1)
counts = steve.run("inventory").get("counts", {})
results["steve_items"] = counts
@@ -719,6 +963,7 @@ def run(ctl, resources, out):
results["board"] = board(alex, steve, out)
results["polish"] = polish(server, alex, steve, out)
results["presence"] = presence(server, alex, steve)
results["nicks"] = nicks(server, alex, steve, out)
results["coinflip"], alex_wins = coinflips(alex, steve)
results["lottery"], alex_lotto = lottery(alex, steve, alex_wins)
alex_after = 415 + (100 if alex_wins else -100) + (20 if alex_lotto else -30)
@@ -740,9 +985,9 @@ 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", "eco_give", "pay", "sell 20 minecraft:wheat",
"sell 3 minecraft:diamond", "buy 5 minecraft:torch", "trade", "trade_failed",
"trade", "trade_money", "coinflip_stake", "coinflip_stake", "coinflip_win",
assert results["audit"] == ["open", "open", "grant", "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"] + (
["coinflip_cancel"] * (held == 0)), results["audit"]
swap = [a for a in audit if a["kind"] == "trade"][-1]
@@ -752,6 +997,10 @@ def run(ctl, resources, out):
# The daily reward, on: Alex gets it when she joins again.
config = json.loads((data / "config.json").read_text())
config["presence"]["daily_reward"] = 7
config["market"]["dynamic"]["enabled"] = True
# The web pages go to the website through Kira: here to a stand-in, with a token.
receiver = Receiver()
config["web"] = {"url": receiver.url, "token": "proof-token"}
(data / "config.json").write_text(json.dumps(config))
server.start()
alex = Client(ctl, resources, "Alex2", root)
@@ -762,23 +1011,42 @@ def run(ctl, resources, out):
assert any("Daily reward: 7 coins." in l for l in alex.chat()), alex.chat()
assert not any("joined for the first time" in l for l in alex.chat()), alex.chat()
added, results["shop"] = shop(server, alex)
spent, results["market"] = market(server, alex, out, data)
results["invsee"] = invsee(alex, data)
sent = {path: (auth, doc) for path, auth, doc in receiver.got}
results["pushed"] = sorted(sent)
assert {"/smp/ingest/market.json", "/smp/ingest/economy.json"} <= set(sent), receiver.got[:2]
assert all(auth == "Bearer proof-token" for auth, _ in sent.values()), [a for a, _ in sent.values()]
assert sent["/smp/ingest/market.json"][1]["schema"] == 1 and sent["/smp/ingest/market.json"][1]["items"]
receiver.server.shutdown()
added -= spent
alex.quit()
server.stop()
book = json.loads((data / "market_prices.json").read_text())
assert book["items"]["minecraft:diamond"]["minutes"], "the prices' history is written when the world closes"
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
kinds = [json.loads(l)["kind"] for l in (data / "audit.log").read_text().splitlines()]
shop_kinds = ["eco_give", "eco_give", "buy 1 minecraft:iron_ingot", "buy 64 minecraft:cobblestone",
"sell 1 minecraft:iron_ingot", "sell 19 minecraft:diamond"]
# 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"]
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"]
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:]
results["stake_back"] = kinds[-2]
sold = json.loads((data / "market.json").read_text())["sold"]
# Steve's sales of the first run's day, and Alex's.
# Steve's sales of the first run's day, and Alex's; coins received by /pay count too (#139): Steve got 120 + 1
# from Alex, Alex 1 from Steve.
assert sorted((s["coins"], s["items"]) for s in sold.values()) == [
(320, {"minecraft:diamond": 3, "minecraft:wheat": 20}),
(1908, {"minecraft:diamond": 19, "minecraft:iron_ingot": 1})], sold
(441, {"minecraft:diamond": 1, "minecraft:wheat": 60}),
(1776, {"minecraft:diamond": 9, "minecraft:iron_ingot": 1})], sold
print(json.dumps({"proof": "smp", "ok": True, **results}, indent=2))
finally:
(out / "server.log").write_text("\n".join(server.lines))
+293
View File
@@ -0,0 +1,293 @@
"""The casino on a real dedicated server: the built game runs `--dedicated` with the mod, two real clients (Alex and
Steve, `horizon-ctl`) join it and play every game through the server's chest menus, as a stock client would.
Run it with `SMP_PROOF=casino python3 native/tools/native.py proof --resources PATH smp` (proof.py hands over to it);
`SMP_PROOF_HEADLESS=1` starts the clients without a GPU (no screenshots).
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;
- `/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;
- slots: a double click pulls once; the reels scroll; closing the menu doesn't stop the pull;
- crash: both players bet in the same round; Steve leaves mid-flight (cashed out or crashed, his bet settled once),
Alex cashes out or crashes;
- 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
console command the casino ran was refused.
"""
import importlib.util
import json
import os
import re
import shutil
import tempfile
import time
from pathlib import Path
_spec = importlib.util.spec_from_file_location("smp_proof_harness", Path(__file__).with_name("proof.py"))
harness = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(harness)
TREASURY = 50_000
NAME = re.compile(r'"?text"?\s*:\s*"((?:[^"\\]|\\.)*)"')
GAMES = ("casino_roulette", "casino_slots", "casino_crash", "casino_blackjack")
class Client(harness.Client):
def join(self, port):
mode = "--headless" if os.environ.get("SMP_PROOF_HEADLESS") else "--offscreen"
self.run("launch", mode, "--resources", self.resources)
self.run("connect", f"127.0.0.1:{port}", self.name)
self.run("wait-for", "loaded", "--timeout", 300)
time.sleep(2)
def cells(self):
"""The open casino menu: slot -> (item, name)."""
out = {}
for slot, e in self.menu().items():
m = NAME.search(e.get("components", ""))
out[slot] = (e["item"], m.group(1) if m else "")
return out
def name_at(self, slot):
return self.cells().get(slot, ("", ""))[1]
def title(self):
return "".join(s["text"] for s in self.screen().get("title", []))
def quick(self, slot):
self.run("click-slot", 300 + slot)
def shot(self, out, name):
if not os.environ.get("SMP_PROOF_HEADLESS"):
self.run("screenshot", str(Path(out).resolve() / name), check=False)
def wait_name(self, slot, text, timeout=30):
end = time.time() + timeout
while time.time() < end:
name = self.name_at(slot)
if text in name:
return name
time.sleep(0.25)
raise AssertionError(f"{self.name}: slot {slot} never showed {text!r}: {self.cells()}")
def shown(c, seconds=3):
"""The titles and action bar lines `c` sees over the next `seconds`."""
seen = []
end = time.time() + seconds
while time.time() < end:
m = c.run("messages", 1)
for line in (m.get("title"), m.get("subtitle"), m.get("actionbar")):
if line and line not in seen:
seen.append(line)
time.sleep(0.2)
return seen
def audit(data):
return [json.loads(l) for l in (data / "audit.log").read_text().splitlines()]
def settled(data):
return [a for a in audit(data) if a["kind"] in GAMES]
def roulette(alex, out):
r = {}
alex.say("/casino roulette")
assert alex.title() == "Casino · Roulette", alex.title()
alex.click(300 + 18) # Red
assert "Red" in alex.name_at(40), alex.cells()
for _ in range(3):
alex.click(300 + 49) # more
r["bet"] = alex.name_at(48)
pictures = []
alex.quick(53)
alex.wait_name(53, "turns", 10)
shot, started = False, time.time()
end = time.time() + 30
# Until the wheel stops: each picture the menu shows, and the result's title or action bar.
while time.time() < end:
cells = alex.cells()
if "SPIN" in cells.get(53, ("", ""))[1]:
break
pictures.append(tuple(cells.get(s) for s in range(18)))
if not shot:
alex.shot(out, "roulette-spin.png")
shot = True
r["pictures"] = len(set(pictures))
r["samples"], r["spin_seconds"] = len(pictures), round(time.time() - started, 1)
assert r["pictures"] >= 3, "the wheel didn't turn"
r["result"] = alex.name_at(40)
r["shown"] = shown(alex)
assert r["shown"], "no title or action bar for the result"
alex.shot(out, "roulette-result.png")
# A second spin, the menu closed at once: paid all the same.
time.sleep(2.5)
alex.quick(53)
time.sleep(0.4)
alex.run("close")
time.sleep(6)
return r
def slots(alex, out):
r = {}
alex.say("/casino slots")
assert alex.title() == "Casino · Slots", alex.title()
paytable = [alex.name_at(s) for s in range(36, 44)]
assert "3 Nether Star: 500x" in paytable and "94.2% paid back on average" in paytable, paytable
alex.quick(53)
alex.quick(53) # the double click
time.sleep(0.8)
alex.shot(out, "slots-spin.png")
r["turning"] = alex.name_at(53)
assert "turn" in r["turning"], r
alex.wait_name(53, "PULL")
r["line"] = alex.name_at(4)
assert "won" in r["line"] or "no luck" in r["line"], r
time.sleep(2.5)
alex.quick(53)
time.sleep(0.3)
alex.run("close")
time.sleep(4)
return r
def crash(alex, steve, out):
r = {}
for c in (alex, steve):
c.say("/casino crash")
assert c.title() == "Casino · Crash", c.title()
alex.quick(53)
time.sleep(0.5)
steve.quick(53)
r["in"] = [c.wait_name(53, "Your bet is in", 8) for c in (alex, steve)]
end = time.time() + 20
while time.time() < end and not alex.name_at(38).startswith(("Flying", "Crashed")):
time.sleep(0.25)
r["status"] = alex.name_at(38)
alex.shot(out, "crash-flight.png")
steve.quit() # leaves mid-flight
time.sleep(1.5)
button = alex.name_at(53)
if button.startswith("CASH OUT"):
alex.quick(53)
r["alex"] = "cashed out"
else:
r["alex"] = button
r["shown"] = shown(alex)
# The round ends (a crash, at most 100x: wait for it to show).
end = time.time() + 120
while time.time() < end and not alex.name_at(38).startswith(("Crashed", "No round")):
time.sleep(1)
r["end"] = alex.name_at(38)
alex.run("close")
return r
def blackjack(alex, out):
r = {}
alex.say("/casino blackjack")
assert alex.title() == "Casino · Blackjack", alex.title()
alex.click(300 + 53)
alex.shot(out, "blackjack-hand.png")
r["dealt"] = alex.cells().get(40)
if r["dealt"] and r["dealt"][1] == "STAND":
alex.click(300 + 40)
time.sleep(3)
r["end"] = alex.name_at(4)
# The most a bet may be.
alex.run("click-slot", 300 + 49, "shift")
time.sleep(1)
r["max"] = alex.name_at(48)
assert r["max"] == "Bet: 1,000 coins", r
alex.run("close")
return r
def run(ctl, resources, out):
out = Path(out)
out.mkdir(parents=True, exist_ok=True)
game = Path(ctl).with_name("horizon-client")
root = Path(tempfile.mkdtemp(prefix="smp-casino-proof-"))
port = harness.free_port()
server = harness.Server(game, resources, root / "server", port)
alex = Client(ctl, resources, "Alex", root)
steve = Client(ctl, resources, "Steve", root)
results = {}
try:
data = server.folder / "world" / "horizon-smp"
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}}))
server.start()
for line in ("difficulty peaceful", "gamerule spawn_monsters false"):
server.console(line)
alex.join(port)
steve.join(port)
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"))
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"):
assert want in text, (want, results["help"])
alex.say("/casino")
assert alex.title() == "Casino", alex.title()
lobby = alex.cells()
results["lobby"] = {s: lobby.get(s) for s in (19, 21, 23, 25, 42)}
assert lobby[19][0] == "minecraft:red_concrete" and "Roulette" in lobby[19][1], lobby
assert lobby[42][1] == "The casino's pot: 20,000 coins", lobby
alex.shot(out, "lobby.png")
alex.click(300 + 21)
assert alex.title() == "Casino · Slots", alex.title()
alex.run("close")
results["roulette"] = roulette(alex, out)
results["slots"] = slots(alex, out)
results["blackjack"] = blackjack(alex, out)
results["crash"] = crash(alex, steve, out)
time.sleep(3)
lines = server.stop()
alex.quit()
refused = [l for l in lines if "Unknown or incomplete command" in l or "Incorrect argument" in l
or "panicked" in l]
assert not refused, refused
log = audit(data)
bets = [a for a in log if a["kind"] == "casino_bet"]
ends = [a for a in log if a["kind"] in GAMES or a["kind"] == "casino_void"]
results["audit"] = [a["kind"] for a in log if a["kind"].startswith("casino")]
# Roulette 2, slots 2 (the double click pulled once), blackjack 1, crash 2; a double adds a second hold.
counts = {g: sum(a["kind"] == g for a in ends) for g in GAMES}
results["rounds"] = counts
assert counts == {"casino_roulette": 2, "casino_slots": 2, "casino_blackjack": 1, "casino_crash": 2}, counts
assert len(bets) >= 7, bets
economy = json.loads((data / "economy.json").read_text())
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
ledger = json.loads((data / "casino.json").read_text())
results["casino.json"] = ledger
assert sum(t["rounds"] for t in ledger["totals"].values()) == 7, ledger
print(json.dumps({"proof": "smp-casino", "ok": True, **results}, indent=2, ensure_ascii=False))
except BaseException:
print(json.dumps({"proof": "smp-casino", "ok": False, **results}, indent=2, ensure_ascii=False, default=str))
if (server.folder / "world" / "horizon-smp" / "audit.log").exists():
print("audit:", [a["kind"] for a in audit(server.folder / "world" / "horizon-smp")])
raise
finally:
(out / "server.log").write_text("\n".join(server.lines))
for c in (alex, steve):
c.quit()
if server.__dict__.get("process") and server.process.poll() is None:
server.process.kill()
if not os.environ.get("SMP_PROOF_KEEP"):
shutil.rmtree(root, ignore_errors=True)
+32
View File
@@ -1,27 +1,59 @@
//! The SMP's rules, as the bundle adds them to the game: each file is a
//! module of `game` there and of this crate here, under the same name, so
//! `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"]
pub mod smp_casino;
#[path = "../mixins/smp/smp_casino_blackjack.rs"]
pub mod smp_casino_blackjack;
#[path = "../mixins/smp/smp_casino_crash.rs"]
pub mod smp_casino_crash;
#[path = "../mixins/smp/smp_casino_roulette.rs"]
pub mod smp_casino_roulette;
#[path = "../mixins/smp/smp_casino_slots.rs"]
pub mod smp_casino_slots;
#[path = "../mixins/smp/smp_claims.rs"]
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"]
pub mod smp_market;
#[path = "../mixins/smp/smp_market_chart.rs"]
pub mod smp_market_chart;
#[path = "../mixins/smp/smp_market_menu.rs"]
pub mod smp_market_menu;
#[path = "../mixins/smp/smp_market_prices.rs"]
pub mod smp_market_prices;
#[path = "../mixins/smp/smp_nick.rs"]
pub mod smp_nick;
#[path = "../mixins/smp/smp_places.rs"]
pub mod smp_places;
#[path = "../mixins/smp/smp_presence.rs"]
pub mod smp_presence;
#[path = "../mixins/smp/smp_recipes.rs"]
pub mod smp_recipes;
#[path = "../mixins/smp/smp_report.rs"]
pub mod smp_report;
#[path = "../mixins/smp/smp_shop.rs"]
pub mod smp_shop;
#[path = "../mixins/smp/smp_store.rs"]
pub mod smp_store;
#[path = "../mixins/smp/smp_tax.rs"]
pub mod smp_tax;
#[path = "../mixins/smp/smp_trade.rs"]
pub mod smp_trade;
+2 -1
View File
@@ -11,7 +11,8 @@ launcher builds into the game. It keeps the settings of earlier versions: the sa
## Install
In the Horizon launcher: Mods → Zoom → Install, then Settings → Game → Native mods (experimental) → Build. The
In the Horizon launcher: Mods → Zoom → Install, then **Build** on the same page (once built, switch it
on or off in the game under Options → Mods). The
launcher downloads its own Rust and builds the game with your native mods; see Horizon's
[native mods](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md).