Compare commits

...
Author SHA1 Message Date
Ludovic Marie 1db37be21e Merge commit 'f22d1edc7824f9a8e8321b6d6784b236223a09fb' into HEAD 2026-11-03 02:27:04 +00:00
Ludovic MarieandClaude Opus 5.5 f22d1edc78 voicechat-server: replay-proof re-authentication; packages carry LICENSE
- A secret belongs to the first address that authenticates with it: an
  Authenticate copied and sent from another address is refused however long
  the client was quiet (the 5 s move rule is gone). Exact replays are dropped
  too: the last 64 control packets' nonces per player, and a 64-wide window
  on voice packets' sequence numbers.
- A client the server stops hearing for 10 keep-alives gets a fresh secret
  over the game connection (Complex Voice Chat 2.1.0 reconnects on any
  voicechat:secret), keeping its group; the old key then opens nothing. So
  a NAT that rebinds recovers without a rejoin (2.1.0 never re-sends
  Authenticate after its ack, so the old move rule never helped it).
- Secret requests: one answer a second a player (more requests meanwhile
  get one answer), the snapshot replaces what still waited. Group and state
  changes 2 a second (burst 10). At most 512 messages wait per player.
- Client pings aren't echoed (the server never pings; SVC's server doesn't
  echo either), and the relay thread never spins on a socket error.
- The glue lists the players on a message only when the sender is new, not
  on every voice chat message.
- native.py package puts native/<mod>/LICENSE into the .hnmod as
  mixins/<id>/LICENSE (Horizon's bundle reader accepts LICENSE* there); the
  CI check requires it. Every native mod's package now carries its licence.

Tests: a_replayed_handshake_after_a_silence_moves_nothing,
a_client_the_server_stops_hearing_gets_a_fresh_secret,
repeated_secret_requests_and_group_changes_stay_bounded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:12:33 +01:00
Ludovic MarieandClaude Opus 5.5 ad74e43a70 SMP 0.31.0: the auction house lists whole stacks
rdbt's ask: /ah refused anything that wasn't a plain item. A listing now
carries the whole stack (enchantments, name, damage, lore, a shulker box's
or a bundle's contents, every component) as the game writes it in player
files (ItemStack.CODEC NBT, hex), through listing, purchase, auction win,
mail and returns, saved in economy.json in the same step as the money.
Given back with native_give_stack; what doesn't fit waits in the mail.

Taken only when it reads back equal; not market charts, nor stacks over
32 KiB of data. Creative players are still refused. Listings and mail from
0.30 (no `stack`) load unchanged and give plain items as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:03:35 +01:00
Ludovic MarieandClaude Opus 5.5 f5696b3e90 voicechat-server: proximity voice chat on Horizon's own server
A native server mod (voicechat_server 1.0.0): the server half for voice chat
clients that speak Simple Voice Chat 2.6.x's protocol, such as azetech's
Complex Voice Chat. The handshake rides Horizon's mod messages on voicechat:
channel names; voices go over UDP through the server, sealed per player with
AES-128-GCM, to the players nearby in the same dimension or in the speaker's
group. A player without a voice client is never sent anything, and the UDP
port opens only when the first voice client asks.

The relay is a bundle crate (horizon-mixins 0.6.0): every native mod now pins
horizon-mixins d9c83d7, since native.py builds them with one, and package
includes a bundle's crates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 21:39:32 +01:00
levzzzandClaude Opus 5.5 e5d840dabf Add Motion Blur (native mod) (#171)
* Add Motion Blur (native mod, levzzz5154/horizon-motion-blur)

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

* motion_blur: the registry's current entry format

source and a [[versions]] entry instead of repo; the file is in the
staging release as <sha256>.hnmod, as the submission form puts it.

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-02 13:36:10 +01:00
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 Marie 5108050b31 Merge pull request #100 from horizon-rust/submit/freelook-0.3.1
Add freelook 0.3.1
2026-10-31 22:37:32 +01:00
Ludovic Marie 706f3a6cb1 Merge pull request #95 from horizon-rust/submit/crosshair_indicator-1.0.0
Add crosshair_indicator 1.0.0
2026-10-31 22:37:24 +01:00
Ludovic Marie 6d05ae816f Merge pull request #89 from horizon-rust/submit/toggle_sprint-1.0.0
Add toggle_sprint 1.0.0
2026-10-31 22:37:16 +01:00
Ludovic Marie f5487d5b08 Merge pull request #88 from horizon-rust/submit/status_effect_hud-1.0.0
Add status_effect_hud 1.0.0
2026-10-31 22:37:09 +01:00
Ludovic Marie 4ede2e8aa7 Merge pull request #87 from horizon-rust/submit/light_overlay-1.0.0
Add light_overlay 1.0.0
2026-10-31 22:37:02 +01:00
Ludovic Marie 9e1a2b70a8 Merge pull request #85 from horizon-rust/submit/inventory_profiles-1.0.0
Add inventory_profiles 1.0.0
2026-10-31 22:36:56 +01:00
Ludovic Marie 969533780c Merge pull request #79 from horizon-rust/submit/fullbright_horizon-1.0.0
Add fullbright_horizon 1.0.0
2026-10-31 22:36:47 +01:00
Ludovic Marie 31e019dff8 Merge pull request #77 from horizon-rust/submit/armor_status-1.0.0
Add armor_status 1.0.0
2026-10-31 22:36:40 +01:00
Ludovic Marie cccfc99d37 Merge pull request #76 from horizon-rust/submit/appleskin_horizon-1.0.0
Add appleskin_horizon 1.0.0
2026-10-31 22:36:33 +01:00
Ludovic Marie 9c12738fb0 Merge pull request #71 from horizon-rust/submit/worldedit-1.2.1
Add worldedit 1.2.1
2026-10-31 22:36:28 +01:00
Ludovic Marie 6d9e3a2064 Merge pull request #59 from horizon-rust/docs/no-wasm-docs
Docs: drop the WebAssembly mod wording
2026-10-31 22:36:23 +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 Marie c6c309d01b Merge pull request #105 from horizon-rust/feat/smp-shop
SMP 0.12.0: /shop menu, daily selling limits, no profitable recipe loop, /money and /eco give *
2026-10-31 21:22:47 +01:00
Ludovic Marie a258ef03d9 Merge pull request #102 from horizon-rust/feat/smp-presence
SMP 0.11.0: first-join welcome, daily reward, AFK out of the sleep count, a command rate limit
2026-10-31 21:14:56 +01:00
Ludovic Marie acf5d8896a Merge pull request #101 from horizon-rust/feat/smp-polish
SMP 0.10.0: /back after a death or a teleport, /playtime, /claim show
2026-10-31 21:14:42 +01:00
Ludovic Marie 4dcb8fed29 Merge pull request #97 from horizon-rust/feat/smp-board
SMP 0.9.0: a sidebar of the richest players, /sidebar on|off, the Tab list's header and footer
2026-10-31 21:14:17 +01:00
Ludovic MarieandClaude Opus 5.5 f56752d74f SMP 0.12.0: /shop menu, daily selling limits, no profitable recipe loop, /money and /eco give *
- /shop: the market as a 6-row chest menu: categories (config.json `shop`), a click buys one, a shift-click 64,
  a click on a stack of yours sells it, "Sell all"; balance, today's limit and help in the bottom row. Through the
  market's own rules; items move by the console's clear/give, measured before and after.
- Market: buy always dearer than sell (else the sale closes), no recipe chain makes a sold item for less than it
  sells (smp_recipes, Bellman-Ford over the recipes; the tests walk every vanilla recipe when the data is there),
  daily limits per player (2,000 coins, 640 of each item, market.json), checked arithmetic, item ids validated.
- 56 default prices in 7 categories; a config still holding the 18 old defaults gets the new ones.
- /money give|take|set (= /eco), /eco give * <amount> for everyone on; recipients are told; all logged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:13:11 +01:00
Ludovic Marie 455a8b800f Merge branch 'feat/smp-presence' into feat/smp-shop 2026-10-31 20:47:27 +01:00
Ludovic Marie 238d9cd1f4 Merge branch 'feat/smp-polish' into feat/smp-presence 2026-10-31 20:47:18 +01:00
Ludovic Marie ddab3fa269 Merge branch 'feat/smp-board' into feat/smp-polish 2026-10-31 20:47:08 +01:00
Ludovic MarieandClaude Opus 5.5 a301fcc469 SMP: pinned to horizon main with #5390 (API 49)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:47:04 +01:00
Ludovic Marie c1ce388d41 WIP shop 2026-10-31 20:45:32 +01:00
Ludovic Marie 8bbf748e1f Merge pull request #72 from horizon-rust/feat/smp-grief
SMP: keep water, fire, pistons, explosions, buckets and blows out of claims
2026-10-31 20:17:02 +01:00
Ludovic Marie 50c23cb7d9 Merge pull request #69 from horizon-rust/feat/smp-claims
SMP: land claims (/claim, /trust)
2026-10-31 20:16:54 +01:00
Ludovic Marie 8d5c1ed7a3 Merge pull request #66 from horizon-rust/feat/smp-places
SMP: places (spawn, homes, warps, /tpa) and /rules
2026-10-31 20:16:44 +01:00
levzzz b6c1af58cd Merge pull request #103 from horizon-rust/registry/veinminer-repo
veinminer 0.3.2 (Nightly 2026-10-31)
2026-11-01 00:06:14 +05:00
Ludovic MarieandClaude Opus 5.5 3cf1b6aa8f SMP 0.11.0: first-join welcome, daily reward, AFK out of the sleep count, a command rate limit
A new presence module, looked at once a second: a player's first join is
announced and they read the welcome and the rules; each player gets a
daily reward (logged as daily_reward); a player who doesn't move, turn
or run a command for afk_minutes, or types /afk, leaves the sleep count
(native_sleep_ignored) until they move; and a player runs at most
spam_commands SMP commands in spam_seconds. All in config.json's
presence section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:05:56 +01:00
Ludovic MarieandClaude Opus 5.5 a11db16db3 SMP 0.10.0: /back after a death or a teleport, /playtime, /claim show
/back returns to where the player died or where their last teleport took
them from, with the same warmup and cooldown. /playtime reads the time
played that places already counts. /claim show draws the chunk's border
with dust particles for 10 s, to that player only, coloured by whose
land it is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:05:42 +01:00
Ludovic MarieandClaude Opus 5.5 dca26bf044 SMP: a sidebar of the richest players, /sidebar on|off, and the Tab list's header and footer
The sidebar lists the top 10 balances and the player's own rank and
balance; it is built again when a balance changed (at most every 5 s) or
every minute, and sent only to the players whose lines changed. /sidebar
off hides it, kept in board.json. The Tab list shows the server's name,
its TPS and the players on, every 5 s. An idle tick now costs a counter:
the SMP's work runs once a second, or each tick only while a teleport
warms up, a trade is open or a command's changes wait to be written.
On hooks API 43 (native_sidebar, native_tab_list, native_tick_stats). 0.9.0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:05:04 +01:00
levzzzandClaude Opus 5.5 95b8f15c6b veinminer 0.3.2: Nightly 2026-10-31
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:42:38 +05:00
levzzz be6b796a20 Merge pull request #98 from horizon-rust/remove/mod-menu
Remove mod_menu (superseded by the built-in Mods menu)
2026-10-31 23:35:44 +05:00
Ludovic MarieandKrzysix17 90e26686bd Add freelook 0.3.1
Submitted by @Krzysix17 through the form.

Submission: IvZxsJMgepBDFUkQDKiYoixlWAvyEWVFBoF6pQE1jkA
Co-authored-by: Krzysix17 <[email protected]>
2026-10-31 18:51:10 +01:00
levzzzandClaude Opus 5.5 2cd564f16b Remove mod_menu: superseded by Horizon's built-in Mods menu
The owner retires it: 0.1.1 needs an old launcher inventory feed and the game's own Mods screen now does the same job.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:39:59 +05: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 MarieandCayMac97 40f3d42984 Add crosshair_indicator 1.0.0
Submitted by @CayMac97 through the form.

Co-authored-by: CayMac97 <[email protected]>
2026-10-31 17:46:08 +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 Marie b571298b58 Merge branch 'feat/smp-claims' into feat/smp-grief 2026-10-31 16:49:55 +01:00
Ludovic Marie fefd3e8c6b Merge branch 'feat/smp-places' into feat/smp-claims 2026-10-31 16:49:53 +01:00
Ludovic Marie 4364dbfba3 Merge remote-tracking branch 'origin/main' into feat/smp-places 2026-10-31 16:49:46 +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 MarieandCayMac97 36b3c994e6 Add toggle_sprint 1.0.0
Submitted by @CayMac97 through the form.

Co-authored-by: CayMac97 <[email protected]>
2026-10-31 12:25:24 +01:00
Ludovic MarieandCayMac97 1ab3acaf70 Add status_effect_hud 1.0.0
Submitted by @CayMac97 through the form.

Co-authored-by: CayMac97 <[email protected]>
2026-10-31 12:24:07 +01:00
Ludovic MarieandCayMac97 ca4f82c665 Add light_overlay 1.0.0
Submitted by @CayMac97 through the form.

Co-authored-by: CayMac97 <[email protected]>
2026-10-31 12:22:54 +01:00
Ludovic MarieandCayMac97 bcf749c4d8 Add inventory_profiles 1.0.0
Submitted by @CayMac97 through the form.

Co-authored-by: CayMac97 <[email protected]>
2026-10-31 12:20:00 +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
Ludovic MarieandCayMac97 97170b869d Add fullbright_horizon 1.0.0
Submitted by @CayMac97 through the form.

Co-authored-by: CayMac97 <[email protected]>
2026-10-31 11:05:09 +01:00
Ludovic MarieandCayMac97 e09a094d25 Add armor_status 1.0.0
Submitted by @CayMac97 through the form.

Co-authored-by: CayMac97 <[email protected]>
2026-10-31 11:02:06 +01:00
Ludovic MarieandCayMac97 c5ba9aaf03 Add appleskin_horizon 1.0.0
Submitted by @CayMac97 through the form.

Co-authored-by: CayMac97 <[email protected]>
2026-10-31 10:58:21 +01:00
levzzz f7709827f6 Merge pull request #48 from Domanffe/blockscope-native-0.3.0
blockscope: a native-mod entry and 0.3.0
2026-10-31 09:19:01 +05:00
levzzzandClaude Sonnet 5.5 72250afe73 Merge main into
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:18:00 +05:00
levzzz 602a58cfd1 Merge pull request #63 from horizon-rust/fix/smp-audit-sync
SMP: the audit log syncs only after a tick that wrote to it (-2.8 ms MSPT)
2026-10-31 09:15:55 +05:00
levzzz f8a41e6c8b Merge pull request #62 from horizon-rust/feat/smp-lottery
SMP: the lottery (/lottery), part of gambling, off by default
2026-10-31 09:15:51 +05:00
levzzzandClaude Sonnet 5.5 5495b461fd Merge main into blockscope-native-0.3.0
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:15:27 +05:00
levzzz d1e80f8485 Merge pull request #67 from horizon-rust/submit/fly_speed-0.1.0
Add fly_speed 0.1.0
2026-10-31 09:14:40 +05:00
levzzz 65aaaafe56 Merge pull request #70 from horizon-rust/submit/jei-0.1.0
Add jei 0.1.0
2026-10-31 09:14:36 +05:00
levzzz 634fc6cb7a Merge pull request #47 from horizon-rust/submit/serverify-0.1.0
Add serverify 0.1.0
2026-10-31 09:14:20 +05:00
levzzz fb9a47061c Merge pull request #65 from horizon-rust/submit/fullbright-1.0.0
Add fullbright 1.0.0
2026-10-31 09:14:16 +05:00
levzzz 0cebbbfad8 Merge pull request #73 from horizon-rust/submit/appleskin-0.1.1
Update appleskin 0.1.1
2026-10-31 09:13:43 +05:00
levzzz 52069c3e49 Merge pull request #74 from horizon-rust/submit/eatinganimation-0.1.1
Update eatinganimation 0.1.1
2026-10-31 09:13:39 +05:00
levzzzandClaude Sonnet 5.5 8479dec66f Ship the icon in the registry (icons/serverify.png): the launcher does not load raw.githubusercontent.com links
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:12:04 +05:00
levzzzandClaude Sonnet 5.5 f489d26358 Ship the icon in the registry (icons/fullbright.png): the launcher does not load raw.githubusercontent.com links
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:12:02 +05:00
levzzzandClaude Sonnet 5.5 4904b24c69 Ship the icon in the registry (icons/appleskin.png): the launcher does not load raw.githubusercontent.com links
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:11:39 +05:00
levzzzandClaude Sonnet 5.5 38421f5a8d Ship the icon in the registry (icons/eatinganimation.png): the launcher does not load raw.githubusercontent.com links
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:11:37 +05:00
Ludovic Marieandsoufied 3488a88696 Update eatinganimation 0.1.1
Submitted by @soufied through the form.

Co-authored-by: soufied <[email protected]>
2026-10-31 05:03:04 +01:00
Ludovic Marieandsoufied 2708b6a02f Update appleskin 0.1.1
Submitted by @soufied through the form.

Co-authored-by: soufied <[email protected]>
2026-10-31 04:52:23 +01:00
Ludovic MarieandClaude Opus 5.5 aaf6ccefcc SMP claims: pinned to horizon main with #4739 (API 22)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:56:05 +01:00
Ludovic MarieandClaude Opus 5.5 b0e3d98b52 SMP claims: pinned to horizon#4739's head (API 22)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:52:27 +01:00
Ludovic MarieandClaude Opus 5.5 803907aaa5 SMP: keep water, fire, pistons, explosions, buckets and blows out of claims (hooks 22)
On Horizon's API 22 hook points: a fluid or fire never spreads into a
claim from land of another owner (or of a player they don't trust), a
piston never pushes or pulls blocks into one, explosions spare claimed
blocks whoever lit them, buckets are guarded where they fill or empty, and
the claim's frames, armor stands, paintings, vehicles and animals
(`claims.protected_entities`) take no blows from untrusted players. The
spread and piston hooks read the open claims without allocating and never
wait on the SMP's lock. The proof adds a world_guard step on the dedicated
server, with controls outside the claim; the claims step no longer fails
when grass spreads onto its dirt. 0.8.0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:18:36 +01:00
Ludovic MarieandClaude Opus 5.5 ec056f9f02 SMP: land claims (/claim, /trust) on the block hooks
A player claims the chunk they stand in; only they, the players they
trust and operators break or right click blocks there
(native_destroy_block, native_right_click_block), told on the action
bar (native_action_bar, API 16). Chunks a player may hold grow with
the time played, counted by places. claims.json, written atomically.
Proved on a dedicated server: a lever, a dirt block and a placement
refused to Steve in Alex's chunk, then allowed once trusted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:08:36 +01:00
Ludovic MarieandClaude Opus 5.5 63f2fa9f01 SMP places: pinned to horizon main with #4610 (API 21)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:08:34 +01:00
Ludovic MarieandClaude Opus 5.5 1ec378ac96 SMP places: hook points API 21 (main's mod screens took 18 to 20)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:59:14 +01:00
Ludovic MarieandXnotCykoX 04c0ee173a Add worldedit 1.2.1
Submitted by @XnotCykoX through the form.

Co-authored-by: XnotCykoX <[email protected]>
2026-10-30 21:28:41 +01:00
Ludovic Marieandenderliker 3516095141 Add jei 0.1.0
Submitted by @enderliker through the form.

Co-authored-by: enderliker <[email protected]>
2026-10-30 21:17:57 +01:00
Ludovic MarieandClaude Opus 5.5 0eb6ca75c3 SMP places: hook points API 18 (main took 16 and 17)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:45:55 +01:00
Ludovic MarieandClaude Opus 5.5 26079e71ed SMP places: hook points API 16, and the proof waits for each answer
#3996's block entities took API 15 on main: horizon#4610's hooks are 16.
say() polls for the command's answer instead of a fixed 1.5 s, which a
busy machine outran.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:39:31 +01:00
Ludovic MarieandImmortalWhisper 7d2b14f53e Add fly_speed 0.1.0
Submitted by @ImmortalWhisper through the form.

Co-authored-by: ImmortalWhisper <[email protected]>
2026-10-30 20:27:24 +01:00
Ludovic MarieandClaude Opus 5.5 b9bd159dcf SMP: places (spawn, homes, warps, /tpa) and /rules
Teleports wait a warmup that a move or a hit calls off, then run vanilla's
tp as the server (native_run_command, API 15), so they cross levels.
Homes grow with the time played here, counted by the mod. places.json
holds the spawn, warps, homes and time played, written atomically.
Proved on a dedicated server with two clients: home from the Nether,
called off by a move and by damage, /tpa, warps, operators only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:24:05 +01:00
Ludovic Marieandbezi5kt 52980b81e0 Add fullbright 1.0.0
Submitted by @bezi5kt through the form.

Co-authored-by: bezi5kt <[email protected]>
2026-10-30 20:18:00 +01:00
Ludovic MarieandClaude Opus 5.5 4fbe64e376 SMP: the lottery (/lottery), part of gambling, off by default
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:55:25 +01:00
Ludovic MarieandClaude Opus 5.5 5aacb47cd4 SMP: the audit log syncs only after a tick that wrote to it
A sync_data on every server tick, even with nothing appended, cost about
2.8 ms of MSPT with 55 bots on the Mac mini.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:55:01 +01:00
levzzz ff18bb42d5 Merge pull request #58 from horizon-rust/feat/smp-coinflip
SMP: coinflips with held stakes (/coinflip), off by default
2026-10-30 23:26:49 +05:00
levzzzandClaude Sonnet 5.5 9121109717 Merge main into feat/smp-coinflip
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:14:04 +05:00
levzzz 0faf62da1c Merge pull request #57 from horizon-rust/feat/smp-trade
SMP: player trades (/trade) with an atomic swap
2026-10-30 23:14:00 +05:00
levzzzandClaude Sonnet 5.5 5b03ea2d0c Docs: drop the WebAssembly mod wording from READMEs and entry descriptions
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:08:24 +05:00
Ludovic MarieandClaude Opus 5.5 5dad8648b3 SMP: coinflips with held stakes (/coinflip), off by default
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:07:38 +01:00
Ludovic MarieandClaude Opus 5.5 7f1bb6920b SMP: pinned on horizon main (f533c3a, hooks 10 merged)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:06:15 +01:00
Ludovic MarieandClaude Opus 5.5 7bd4f6207b SMP proof: the 26.3 keep_inventory rule
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:06:06 +01:00
Ludovic MarieandClaude Opus 5.5 1d4cb83ca2 SMP: player trades (/trade) on server menus
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:06:05 +01:00
levzzz 37f573d08f Merge pull request #56 from horizon-rust/feat/smp-market
SMP: the server market (/worth, /sell, /buy)
2026-10-30 23:04:50 +05:00
levzzz 847fd2dcbe Merge pull request #54 from horizon-rust/submit/xtoxray-2026.29.0
Add xtoxray 2026.29.0
2026-10-30 23:03:06 +05:00
levzzz 2c41bdf9fd Merge pull request #52 from horizon-rust/submit/rightharvest-0.1.0
Add rightharvest 0.1.0
2026-10-30 23:02:59 +05:00
levzzz 7704c01acf Merge pull request #51 from horizon-rust/submit/chatheads-0.1.0
Add chatheads 0.1.0
2026-10-30 23:01:45 +05:00
levzzz c2f369fb2e Merge pull request #43 from horizon-rust/submit/shulker_peek-0.1.0
Add shulker_peek 0.1.0
2026-10-30 23:01:37 +05:00
levzzzandClaude Sonnet 5.5 c13658c430 Merge main into feat/smp-market
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 22:57:04 +05:00
levzzz 5c1b79862e Merge pull request #55 from horizon-rust/feat/smp-economy
SMP native server mod: the economy (/balance, /pay, /baltop, /eco)
2026-10-30 22:56:59 +05:00
levzzz 8366f89925 Merge pull request #53 from horizon-rust/submit/eatinganimation-0.1.0
Add eatinganimation 0.1.0
2026-10-30 22:56:28 +05:00
levzzz 2917b77de1 Merge pull request #49 from horizon-rust/submit/appleskin-1.0.0
Add appleskin 1.0.0
2026-10-30 22:56:21 +05:00
levzzz b75251443c Merge pull request #44 from horizon-rust/submit/distant_horizons-0.1.0
Add distant_horizons 0.1.0
2026-10-30 22:55:57 +05:00
levzzzandClaude Sonnet 5.5 5d26a0bde3 rightharvest: add the icon its entry points at
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 22:55:33 +05:00
levzzzandClaude Sonnet 5.5 7339aa2992 chatheads: add the icon its entry points at
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 22:55:31 +05:00
levzzzandClaude Sonnet 5.5 0096a8a835 xtoxray: host the icon in the repo, SPDX licence id
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 22:55:28 +05:00
levzzzandClaude Sonnet 5.5 5728b12cbb shulker_peek: host the icon in the repo
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 22:55:20 +05:00
Ludovic MarieandClaude Opus 5.5 830bc7884b SMP README: players install nothing
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:54:15 +01:00
Ludovic MarieandClaude Opus 5.5 7e795012ed SMP: the server market (/worth, /sell, /buy)
The server buys and sells vanilla items at config.json's market prices
(a starting list the owner edits): plain items only, players in
survival or adventure mode only. Items move first, then money, both in
the audit log; a sale the log refuses gives the items back, a purchase
checks money and room first. Needs hook points API 9 (the acting
player's items). config.json is written again when a version adds keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:48:10 +01:00
Ludovic MarieandClaude Opus 5.5 666479ad05 SMP native server mod: the economy (/balance, /pay, /baltop, /eco)
The official server's SMP, opt-in: not listed in the registry, built only
into the official server's engine. Balances by UUID in
world/horizon-smp/economy.json (written atomically, at most once a tick),
an audit line per change written before it, config.json with defaults.
Hooks only Horizon's hook points (API 8: server commands and players).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:48:08 +01:00
Ludovic MarieandRealCat1st b498e0a5fc Add xtoxray 2026.29.0
Submitted by @RealCat1st through the form.

Co-authored-by: RealCat1st <[email protected]>
2026-10-30 16:53:17 +01:00
Ludovic Marieandsoufied ebcadb8dd6 Add eatinganimation 0.1.0
Submitted by @soufied through the form.

Co-authored-by: soufied <[email protected]>
2026-10-30 15:53:17 +01:00
Ludovic MarieandDomanffe b1ad307e93 Add rightharvest 0.1.0
Submitted by @Domanffe through the form.

Co-authored-by: Domanffe <[email protected]>
2026-10-30 15:39:29 +01:00
Ludovic MarieandDomanffe 395b1422e0 Add chatheads 0.1.0
Submitted by @Domanffe through the form.

Co-authored-by: Domanffe <[email protected]>
2026-10-30 15:38:18 +01:00
Ludovic Marieandsoufied 1910ab8709 Add appleskin 1.0.0
Submitted by @soufied through the form.

Co-authored-by: soufied <[email protected]>
2026-10-30 15:00:11 +01:00
Alexander Krylov 08ca24a176 blockscope: a native-mod entry and 0.3.0
0.3.0 is the WebAssembly mod rewritten as a mixin bundle, so the entry
turns native-mod once (docs/native-mods.md). Its two .hmod versions stay
as they are. The bundle itself is not in staging yet.
2026-10-30 16:56:34 +03:00
Ludovic MarieandLouchatfroff b27aeaa93f Add serverify 0.1.0
Submitted by @Louchatfroff through the form.

Co-authored-by: Louchatfroff <[email protected]>
2026-10-30 14:36:17 +01:00
Cosmic ae217daafd Merge pull request #46 from Domanffe/icon/blockscope
blockscope: an icon the launcher can fetch
2026-10-30 09:25:42 -04:00
Alexander KrylovandHermes Agent d6501160e1 blockscope: an icon the launcher can fetch
The entry points at raw.githubusercontent.com, which the launcher refuses: it
fetches project images only from Modrinth's and CurseForge's CDNs and this
registry's Pages (launcher/src/browser/images.rs, HOSTS). So Blockscope has
shown no icon in the launcher's Mods page while every registry icon does.

icons/blockscope.png is the mod's own 256x256 RGBA icon, the size the other
icons here use, and the entry now names the Pages URL the publish workflow
serves it from (publish.yml copies icons/ to the site root).

Checked: https://horizon-rust.github.io/horizon-mods/icons/biomecompass.png
answers 200 image/png, while icons/blockscope.png and icons/zoom.png (a mod
with no file here) answer 404, so the URL is live only once the file is.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-30 16:02:44 +03:00
Ludovic Marieanddenizsahin7744 c3669e5412 Add distant_horizons 0.1.0
Submitted by @denizsahin7744 through the form.

Co-authored-by: denizsahin7744 <[email protected]>
2026-10-29 22:54:51 +01:00
Ludovic Marieandvstxx 0e7ed706b6 Add shulker_peek 0.1.0
Submitted by @vstxx through the form.

Co-authored-by: vstxx <[email protected]>
2026-10-29 22:18:15 +01:00
138 changed files with 26914 additions and 97 deletions

No files matched your search

+3 -1
View File
@@ -78,7 +78,9 @@ jobs:
names = zipfile.ZipFile(path).namelist()
manifest = json.loads(zipfile.ZipFile(path).read("horizon-native.json"))
assert manifest["hooks"] >= 1, manifest
assert all(n == "horizon-native.json" or (n.startswith(f"mixins/{manifest['id']}/") and n.endswith((".rs", ".toml"))) for n in names), names
bundle = f"mixins/{manifest['id']}/"
assert all(n == "horizon-native.json" or n == bundle + "LICENSE" or (n.startswith(bundle) and n.endswith((".rs", ".toml"))) for n in names), names
assert bundle + "LICENSE" in names, "the mod's LICENSE travels in its package"
print(path, manifest)
PY
- uses: actions/upload-artifact@v4
+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:
+7 -5
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.
@@ -82,7 +83,8 @@ The index: `https://horizon-rust.github.io/horizon-mods/index.json`.
[horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundles on Horizon's native hook points
([docs/native-mods.md](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md)). Each folder keeps the
history of the repository it came from. A mod builds on any Horizon whose hook points are at least the version its
`compatibility.json` names; `horizon_revision` is the commit it was last tested on.
`compatibility.json` names; `horizon_revision` is the commit it was last tested on. `smp` and `voicechat-server`
are server mods, built into the official server's engine (see their READMEs).
```sh
python3 native/tools/native.py check zoom # apply on the tested Horizon and cargo check the game
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

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: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

+45
View File
@@ -0,0 +1,45 @@
id = "appleskin"
kind = "native-mod"
name = "AppleSkin"
summary = "Shows how much HP, hunger, and saturation will be restored."
description = """
AppleSkin for Horizon, a native mod.
Renders saturation, exhaustion, and held-food values over the hunger bar, as the original AppleSkin does.
Based on AppleSkin by squeek502. See the LICENSE in the source for the
original copyright and licence notices."""
authors = ["soufi"]
owners = ["soufied"]
license = "MIT"
source = "https://github.com/soufied/AppleSkin-Horizon"
icon = "icons/appleskin.png"
key = "RWT6eNq4e5WfVDA2lzOOi6XCm/+jJgilqT1+HUUM33ie5MCjcRfSg760"
[[versions]]
version = "0.1.1"
published = "2026-10-31"
game = "26.3"
file = "appleskin-0.1.1.hnmod"
sha256 = "182117d2a9767bc22a7c6652efd24551990cea015193784f20aeb8a7ba35b4ac"
signature = """
untrusted comment: signature from minisign secret key
RUT6eNq4e5WfVPcAN+WeTYujFNeWw6oMKKV7nXxfIfyeqg83qrFNNA/LYfOrmxwV0DgO+tf5Sk5piJjS7hEtyKhl9rs0VpBOfgg=
trusted comment: timestamp:1793418678\tfile:appleskin-0.1.1.hnmod\thashed
gK9BQoCtM8sRPSvSiBzAT2IE8nZlCDfUa7tWCG7cG/v7H41E+4DnflE0KtAPdqi/9+MQT3ONmT4qbGFVio58BA=="""
changelog = """
dumped to 0.1.1
added icon"""
[[versions]]
version = "1.0.0"
published = "2026-10-30"
game = "26.3"
file = "appleskin-1.0.0.hnmod"
sha256 = "3c25b916f74f47faf40070a8654c546d15cb79ac3947f140e257f4cb041aeb96"
signature = """
untrusted comment: signature from minisign secret key
RUT6eNq4e5WfVEMHvcSGm73mmamWosNGGcdt7Qn05wqgRuAlDj/DyzH66lS2u8nvB43GnZJ7xCmgoM5xArHJuN9kD30iIKj0zws=
trusted comment: timestamp:1793368049\tfile:appleskin-1.0.0.hnmod\thashed
gjEf247x8XwHpOI2Ra3ngk6umC/mFZiHOCfZyzxg5IFJ7CNJVpofnw/l7ITtePPeyi4r3+0D6FvmmhtuiqVAAg=="""
changelog = "First release. AppleSkin for Horizon as a native mod: saturation, exhaustion and the food in your hand, drawn over the food bar."
+29
View File
@@ -0,0 +1,29 @@
id = "appleskin_horizon"
kind = "native-mod"
name = "AppleSkin"
summary = "1:1 Minecraft Java parity food value preview & saturation HUD overlay."
description = """
1:1 Minecraft Java parity port of AppleSkin for the native Horizon Rust client.
Features
Saturation Overlay: Golden border and glow around active saturation drumsticks matching original icons.png pixel art.
Food Value Preview: Pulsing preview drumsticks and preview saturation border when holding edible items.
Clean Integration: Easily toggled via Options > Mods > AppleSkin."""
authors = ["CayMac"]
owners = ["CayMac97"]
license = "MIT"
source = "https://github.com/CayMac97/appleskin"
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
[[versions]]
version = "1.0.0"
published = "2026-10-31"
game = "26.3"
file = "appleskin_horizon-1.0.0.hnmod"
sha256 = "38a729f1aad00f6148f29578a2725cb5e7f9aea79a870fcf3cee76f39838c5ff"
signature = """
untrusted comment: signature from minisign secret key\r
RUS411U2KXdTNzxiQEELascXv6v/RXLycX9k/IkVf5ggI7lL2qjyDEVxdDl5nPxbdCJ3epwwHIE6D8ZHw6QGXCFQjRz3I6Z/UA0=\r
trusted comment: timestamp:1793440355\tfile:appleskin_horizon-1.0.0.hnmod\thashed\r
6kGj1vQ1DJypzS0iDdvwksQ2FFAB1jBcycZL83OgBgQgUzbQzKRhGtleKwqAuIrRLdpQO93ale4hSMAv1z/EDg=="""
changelog = "Initial release for Horizon 26.3."
+30
View File
@@ -0,0 +1,30 @@
id = "armor_status"
kind = "native-mod"
name = "Armor Status"
summary = "Displays equipped armor & held items with authentic Java durability colors."
description = """
A native ArmorStatusHUD mod for the Horizon Rust client.
Features
Displays all 4 armor slots plus main and off-hand items on the HUD.
Authentic Java durability color coding (Green > Yellow > Orange > Red).
Item count display for non-damageable held items (torches, food, blocks).
Default position: Bottom Right (freely customizable in Options > Mods)."""
authors = ["CayMac"]
owners = ["CayMac97"]
license = "MIT"
source = "https://github.com/CayMac97/armor-status"
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
[[versions]]
version = "1.0.0"
published = "2026-10-31"
game = "26.3"
file = "armor_status-1.0.0.hnmod"
sha256 = "82c8672fa2c43d7946a26a560fd56fdd8f34beecf4bcb459c2d508b3d09eeee6"
signature = """
untrusted comment: signature from minisign secret key\r
RUS411U2KXdTN+dR+lW2uDL5+FJfKRnHJvelJnWmhWGQeS/p0lNFVDFfq3BjZ2LCuNEtza8cZvcuKuuYF5asmxsnbtB/MMykYQA=\r
trusted comment: timestamp:1793439927\tfile:armor_status-1.0.0.hnmod\thashed\r
LTwf1kuPLucJIBfHPdx1nsp0YuHSlxbFG6PYKG2HnMxMf4acAsZbWR0QdQnzkn+7Oc/T60iQPh3X14RBST3+Bg=="""
changelog = "Initial release for Horizon 26.3."
+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.
+10 -2
View File
@@ -1,5 +1,5 @@
id = "blockscope"
kind = "mod"
kind = "native-mod"
name = "Blockscope"
summary = "The block under your crosshair: its name, id, distance and icon, with reach, placement and opacity on its own settings page. A Jade-style tooltip for Horizon."
description = "Jade-style block info for Horizon: the block under your crosshair, its name in your language, its block id, how far it is and its icon."
@@ -7,7 +7,15 @@ authors = ["Domanffe"]
owners = ["Domanffe"]
license = "MIT"
source = "https://github.com/Domanffe/blockscope"
icon = "https://raw.githubusercontent.com/Domanffe/blockscope/main/docs/icon.png"
icon = "https://horizon-rust.github.io/horizon-mods/icons/blockscope.png"
[[versions]]
version = "0.3.0"
published = "2026-10-30"
game = "26.3"
file = "blockscope-0.3.0.hnmod"
sha256 = "ba4a1c887ab26a2a9189c671cbdbea3ce2ed212a6e3d32f1fffaa60386198358"
changelog = "Native: the WebAssembly mod rewritten as a mixin bundle for the launcher's native mods, with hardness and the tool's icon, block states, crop growth, a container's slots and a creature's health, each on its own switch. Its settings carry over from 0.2.0."
[[versions]]
version = "0.2.0"
+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.
+23
View File
@@ -0,0 +1,23 @@
id = "chatheads"
kind = "native-mod"
name = "ChatHeads"
summary = "A player's head left of every chat line they wrote."
description = """
Each chat line from a player gets their 8x8 skin face in the strip to its left, like the Chat Heads Java mod. Lines without a player (system messages, /me, wrapped continuations) stay as they are; the face fades with its line.
Settings: Options > Mods > ChatHeads > Settings (on or off).
Known limit: the chat's hit-test does not know about the 10 px indent, so clicking a link in chat lands 10 px left of its pixels."""
authors = ["Domanffe"]
owners = ["Domanffe"]
license = "MIT"
source = "https://github.com/Domanffe/chatheads"
icon = "https://horizon-rust.github.io/horizon-mods/icons/chatheads.png"
[[versions]]
version = "0.1.0"
published = "2026-10-30"
game = "26.3"
file = "chatheads-0.1.0.hnmod"
sha256 = "c20605c24e29a762b9aea85cba1b0cc7955955a40a031efae70a5b01e4e79df4"
changelog = "First release: a native mod for Horizon 26.3. Every chat line from a player gets their 8x8 skin face in the strip to its left; system lines, /me and wrapped continuations get none. Settings in Options > Mods."
+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. 1.0.0 is the WebAssembly version.
**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). It was the WebAssembly SDK's example mod; 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 = "crosshair_indicator"
kind = "native-mod"
name = "Crosshair Indicator"
summary = "Dynamic target reach awareness: visual feedback on your crosshair when hovering over an entity in attack range."
description = """
Crosshair Indicator
A native Horizon mod by CayMac that ports the popular Java Crosshair Indicator mod into Horizon:
Dynamic Target Reach Awareness: Real-time visual feedback directly on your crosshair when hovering over an entity in attack reach (<= 3.0 blocks).
Iconic PvP Target Reticle: Default crisp white corner brackets [ + ] hugging the crosshair, matching classic competitive PvP clients.
Multiple Styles: Choose between Brackets [ + ], Center Dot, Diamond, and Tactical Chevron.
Custom Color Themes: Classic White (Default), Dynamic (Green/Orange cooldown awareness), Combat Red, Pink Aesthetic, Diamond Cyan, and Gold.
Cooldown Bar: Optional subtle weapon cooldown progress indicator below the crosshair.
Configurable: Easily customize styles, themes, and keybinds in Options > Mods > Crosshair Indicator."""
authors = ["CayMac"]
owners = ["CayMac97"]
license = "MIT"
source = "https://github.com/CayMac97/crosshair-indicator"
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
[[versions]]
version = "1.0.0"
published = "2026-10-31"
game = "26.3"
file = "crosshair_indicator-1.0.0.hnmod"
sha256 = "96beba1f7c94fca492b552fb12af0d20047dc1799f0801607841d56b12786226"
signature = """
untrusted comment: signature from minisign secret key\r
RUS411U2KXdTNz67XDjUwaJ8if7+jDk+qXnJLdhtyjh8mk04UuZ2bme/Tzcpi1Od4ppYmvqzfZ+rEAeG2hb9Lytn+J2nFy1u8gc=\r
trusted comment: timestamp:1793461576\tfile:crosshair_indicator-1.0.0.hnmod\thashed\r
zF7EJHDjJPwVG+cYcSgvGJv4kjjYRMdMZipUQyABH0DEMIZ2Fe13hb2nbYYRZlNDG1j4kjhgEYQTODFiAlaCBQ=="""
changelog = "Initial release for 26.3 Horizon."
+33
View File
@@ -0,0 +1,33 @@
id = "distant_horizons"
kind = "native-mod"
name = "Distant Horizons for Horizon Client"
summary = "See far past your render distance: Distant Horizons' level-of-detail terrain, ported to Rust as a native mod."
description = """
An unofficial Rust port of [Distant Horizons](https://gitlab.com/distant-horizons-team/distant-horizons) by James Seibel and contributors: the world past your render distance, drawn at lower detail the farther it is.
- Far terrain built from the chunks you visit, and in single player from the world generator, out to 64–1024 chunks.
- Detail drops with distance by Distant Horizons' own rule, with its five quality presets.
- LODs are kept on disk per world, so they load at once next time.
- Drawn by Horizon's far-terrain renderer with block textures and biome tints.
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: 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.
Known limits: heavy at 256+ chunks (about 30–40 fps on an Apple M5 MacBook Pro); water at LOD edges can show see-through walls.
LGPL-3.0, as Distant Horizons; its copyright notices are kept. Not made or endorsed by the Distant Horizons team. No Minecraft code or assets are included. Ported by Deniz Şahin with Claude (Anthropic)."""
authors = ["denizsahin7744", "Deniz7744"]
owners = ["denizsahin7744"]
license = "LGPL-3.0-only"
source = "https://github.com/denizsahin7744/distant-horizons-for-horizon-client"
[[versions]]
version = "0.1.0"
published = "2026-10-29"
game = "26.3"
file = "distant_horizons-0.1.0.hnmod"
sha256 = "c53a2501f14efb624839329aac6999ddf8c64a399d41bec42a0c878902feea81"
changelog = "First release: pinned to Horizon nightly d009ebd and Horizon Mixins 0d6b103."
+46
View File
@@ -0,0 +1,46 @@
id = "eatinganimation"
kind = "native-mod"
name = "Eating Animation"
summary = "While you eat food, or drink a potion or milk, the item in your hand shows its bitten frames."
description = """
It is a port of [Eating Animation](https://github.com/Theoness1/EatingAnimation) for Horizon.
While you eat food, or drink a potion or milk, the item in your hand shows its bitten frames.
The frames come from a resource pack you build from your own copy of the EatingAnimation download with `tools/make_frame_pack.py`, and drop into Horizon's `resourcepacks/` folder.
~~Or you can just download it from GitHub Releases...~~"""
authors = ["soufi"]
owners = ["soufied"]
license = "MIT"
source = "https://github.com/soufied/EatingAnimation-Horizon"
icon = "icons/eatinganimation.png"
key = "RWT6eNq4e5WfVDA2lzOOi6XCm/+jJgilqT1+HUUM33ie5MCjcRfSg760"
[[versions]]
version = "0.1.1"
published = "2026-10-31"
game = "26.3"
file = "eatinganimation-0.1.1.hnmod"
sha256 = "3b4d7ce3c9791facc862da7727c2c7c71025cd9bb3ee4cbeaf40f48c72bf5cb0"
signature = """
untrusted comment: signature from minisign secret key
RUT6eNq4e5WfVC2NLoRuU8m5JDlH1mh8dNmGbAZUj6bB4grd487xtihz4OLxxJRyO7jzQHrOz6nAPjcZjT/o1RIxebtX+A3tPg8=
trusted comment: timestamp:1793419092\tfile:eatinganimation-0.1.1.hnmod\thashed
OeN+0r/c6BVNO4b82/T9jp/Wh9iGpvL/B0enoxWiIB31W+7E2OmKWhceMaIZqc4w3N3gn3D8+sFrDICK55WoDQ=="""
changelog = """
dumped to v0.1.1
added icon"""
[[versions]]
version = "0.1.0"
published = "2026-10-30"
game = "26.3"
file = "eatinganimation-0.1.0.hnmod"
sha256 = "07ef4fde3ebbc4f132aab593b12187bce15e1a46689ab40bd3067c685aca85b9"
signature = """
untrusted comment: signature from minisign secret key
RUT6eNq4e5WfVJPSxJOh730W1FMzydyVRO2S58rux9xbEtEU+mDgsqWX65rscWaUH3zqOkLan6quW/u9nEZDgXrNz1n6IHnT5Aw=
trusted comment: timestamp:1793371727\tfile:eatinganimation-0.1.0.hnmod\thashed
qCCGAWAc39kDujbX9CsdXu/URjpNybAOxkomTi18GnzJhMLtsbj0ZfVJaVCSxnh0VvznGQvoH9/DEdRAxBAVBg=="""
changelog = "First release. Eating Animation for Horizon as a native mod: the food, milk and potion in your hand show bitten frames while you eat or drink them."
+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."
+23
View File
@@ -0,0 +1,23 @@
id = "fly_speed"
kind = "native-mod"
name = "Fly Speed"
summary = "Allows you to change your fly speed"
description = "Fly 0.1x to 20x your normal speed: /flyspeed, /flyspeed +1 to step, /flyspeed reset for 1x. Native Horizon mod."
authors = ["ImmortalDev"]
owners = ["ImmortalWhisper"]
license = "All Rights Reserved"
source = "https://github.com/ImmortalWhisper/Fly-Speed"
key = "RWSy3RAfbzD2a0WMm0tQlVl+3GHigjwvwXKvzvnwfmoYrIAoIffbrSbP"
[[versions]]
version = "0.1.0"
published = "2026-10-30"
game = "26.3"
file = "fly_speed-0.1.0.hnmod"
sha256 = "c50a3dcfd9ff68af00560f9cd330c4d18b3d9028113b6769ded54997d5267ddf"
signature = """
untrusted comment: signature from minisign secret key\r
RUSy3RAfbzD2a/1H3F6VX9i5wllM8eP2z7FagPND9tqKj76TY2RNWvF/b4xYcqPLU7OLKAPDVY2+HglKTx9GKnAPPt0KQUYwmQQ=\r
trusted comment: fly_speed-0.1.0.hnmod\r
FcLZSTLhxoNvfQAXmKtGb96iTxdJgh2llOMq4GlGhJPgjrhCvllUHEsHWvXnk0sNKEJREKxQ3CVy8VmYcNQzBQ=="""
changelog = "Created Mod"
+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. "
+19
View File
@@ -0,0 +1,19 @@
id = "freelook"
kind = "native-mod"
name = "Freelook"
summary = "Hold a key for a third person camera you can look around in"
description = """
Freelook is a Freelook/Perspective Mod for Fabric, allowing you to move your camera freely without changing your player's rotation.
Camera distance can be adjusted by scrolling."""
authors = ["Krzysix17"]
owners = ["Krzysix17"]
license = "MIT"
source = "https://github.com/Krzysix17/horizon-freelook"
[[versions]]
version = "0.3.1"
published = "2026-10-31"
game = "26.3"
file = "freelook-0.3.1.hnmod"
sha256 = "9c0c92ce9f0fc8e7bc1bd34349e26d42b32b8abd2c7a09ec710936f9b6ce9aaf"
changelog = "First release"
+29
View File
@@ -0,0 +1,29 @@
id = "fullbright"
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 (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.
- `/gamma <value>`: your own gamma. `1` is vanilla's "Bright", `15` is fullbright, up to `100`; `/gamma 1500%` works too.
- `/gamma` shows the gamma in effect.
- `/gamma reset` (or `off`) goes back to your video settings slider; `/gamma on` turns your last custom gamma on again.
- `/fullbright` toggles full brightness (gamma 15).
- Options → Mods → Fullbright: an on/off button, a button cycling 100/200/500/1000/1500%, and a toggle key you can bind.
Client-side: the commands are handled by your game before anything reaches a server, so it works in single player, on LAN and on servers. Your brightness slider and `options.txt` are never changed, so turning it off gives your own brightness back. Settings are remembered between sessions."""
authors = ["bezi5kt"]
owners = ["bezi5kt"]
license = "MIT"
source = "https://github.com/bezi5kt/gamma-rust"
icon = "icons/fullbright.png"
[[versions]]
version = "1.0.0"
published = "2026-10-30"
game = "26.3"
file = "fullbright-1.0.0.hnmod"
sha256 = "573c5e04188e0b1a26eb42a8aeaeeefeb6fb334a096afaa834e71b8efa353ad7"
changelog = "First release."
+29
View File
@@ -0,0 +1,29 @@
id = "fullbright_horizon"
kind = "native-mod"
name = "Fullbright"
summary = "Toggle maximum 1000% gamma cave and night brightness with smooth fade via keybind G."
description = """
Maximum brightness and night illumination for the Horizon Rust client.
Features
Press G (rebindable in Options > Mods > Fullbright) to immediately toggle 1000% Gamma cave and night illumination without shadows.
Smooth 250ms brightness fade transition and HUD status notification.
100% client-side lightmap modification, safe for multiplayer."""
authors = ["CayMac"]
owners = ["CayMac97"]
license = "MIT"
source = "https://github.com/CayMac97/fullbright"
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
[[versions]]
version = "1.0.0"
published = "2026-10-31"
game = "26.3"
file = "fullbright_horizon-1.0.0.hnmod"
sha256 = "db6175eec1711dbc5ab2a2edc40eee9cbc4080ec1d706a3cef688e54ba9cb017"
signature = """
untrusted comment: signature from minisign secret key\r
RUS411U2KXdTN70A+lNWzZF2M2MiOMfjOIqVnTGNY05bQXW47Ov/mHp8io8o9HZiOYQXOYbxhazjWOfGU3WWuRzqj05swkoG/QY=\r
trusted comment: timestamp:1793440355\tfile:fullbright_horizon-1.0.0.hnmod\thashed\r
mJGiv7YnJHRGreiX5K3mx6zwEtRTC4rAygoIC+rkp1tjUeazIwyehtojb6T+iQBQaq2p97PTor410KnbK675BA=="""
changelog = "Initial release for Horizon 26.3."
+23
View File
@@ -0,0 +1,23 @@
id = "inventory_profiles"
kind = "native-mod"
name = "Inventory Profiles"
summary = "R-key & UI sorting (Category, A-Z, Count, ID), Auto-Refill depleted stacks, Tool Break Protection, and Quick-Stack to chests."
description = "A 1:1 native Rust port of Inventory Profiles Next & Inventory Sorter for Horizon. Press R or click the UI sort buttons to merge partial stacks and sort chests or your inventory using 4 selectable modes: Category (Creative Tab), Alphabetical (A-Z), Stack Count (High-Low), or Item ID. Includes automatic hand stack refilling when placing blocks or consuming items, Tool Break Protection that swaps out low-durability tools before they break, and G-key Quick-Stacking to move matching items into open containers."
authors = ["CayMac"]
owners = ["CayMac97"]
license = "MIT"
source = "https://github.com/CayMac97/inventory-profiles"
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
[[versions]]
version = "1.0.0"
published = "2026-10-31"
game = "26.3"
file = "inventory_profiles-1.0.0.hnmod"
sha256 = "1e5d1c75d05230172820b5bc016d7d3ac792adf473de7dbfb84394d8b795a633"
signature = """
untrusted comment: signature from minisign secret key\r
RUS411U2KXdTNzTcNlnhp0xG8ZqxNqwzpSnc+ghT4/m+aR0dga48gdD04aioDSIPK5DBUtd4i2Vf+e4r7LKX3zb6T90aDdYJjgI=\r
trusted comment: timestamp:1793443789\tfile:inventory_profiles-1.0.0.hnmod\thashed\r
RcTkkWUMpp4nRQ4uXRFCD4m67ir4J0y3lo2nhOVUgriLIMndinAsVjnNsrfAoJLvKN9DO5vT/xp2+oW1iW7xAQ=="""
changelog = "Initial release for 26.3 Horizon. "
+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.
+96
View File
@@ -0,0 +1,96 @@
id = "jei"
kind = "native-mod"
name = "JEI for Horizon"
summary = "An item and recipe viewer for Horizon: every item beside container screens, with search, recipes and uses"
description = """
An item and recipe viewer for [Horizon](https://playhorizon.dev), the Rust reimplementation of Minecraft: Java Edition.
Horizon JEI is inspired by [JustEnoughItems (JEI)](https://github.com/mezz/JustEnoughItems). JEI serves as the reference specification, while this project is a completely new Rust implementation built around Horizon's native hook points.
**Version 0.1.0 — Core Viewer**
### Features
- **Item Browser:** Browse every item registered by the game through a paginated panel alongside container screens, with a search bar supporting JEI's search grammar: words, `"quoted phrases"`, `-exclusions`, `|` alternatives, `@namespace`, and `#tag`.
- **Recipe Viewer:** Press **R** to view an item's recipes or **U** to see its uses. Both work over the item panel and container slots.
- **Recipe Categories:** Dedicated tabs for crafting, furnace, blast furnace, smoker, campfire, stonecutter, smithing, and brewing, supporting all five of the game's recipe layouts, including cooking time and XP.
- **Recipe Navigation:** Browse recipe history with **Back** and click recipe slots to open the corresponding item.
- **Recipe Placement:** Use the **+** button to place a recipe into the open crafting grid, provided it is available in the recipe book.
- **Item Giving:** Give items through Options → Mods → JEI or `/jei give`. In creative mode, left-click gives one item and right-click gives a full stack. In survival mode, clicking opens the recipe viewer instead.
- **Localization:** English and Spanish interface translations. Item names are retrieved directly from the game and respect the player's language.
- **Diagnostics:** `/jei` reports the game's recipe table sizes.
### Current Limitations
The 0.1.0 release focuses on the core viewer. Bookmarks, container slot click integration, tooltips, creative inventory integration, non-recipe categories (anvil, grindstone, composter, and fuel), and shift-click recipe placement are not yet supported.
Some of these features require native hook points that Horizon does not currently expose. See the [JEI parity matrix](https://github.com/enderliker/horizon-jei/blob/main/docs/jei-parity.md) for implementation details.
### Requirements & Security
Requires Horizon **26.3**, hook points version **2**, and the current Nightly build.
Horizon native mods are compiled directly into the game and are **not sandboxed**. Only build source code you trust.
Horizon JEI reads the game's tables, container interface, chat, and its own settings directory (`horizon-mods/.horizon-mod-data/jei/`). It does not write to any other location.
*Unofficial project. Not affiliated with mezz, Mojang, Microsoft, or the Horizon project.*"""
authors = ["enderliker"]
owners = ["enderliker"]
license = "MIT"
source = "https://github.com/enderliker/horizon-jei"
key = "RWRqzx718YYz/OHRM+pAnFWlDv8Rrgar5C4NszQg3EKJWQZWNel5Sslf"
[[versions]]
version = "0.1.0"
published = "2026-10-30"
game = "26.3"
file = "jei-0.1.0.hnmod"
sha256 = "c5590e0d4618d435fc0c56abcc9cdabf253be694710068a3dd4643ac25466b5b"
signature = """
untrusted comment: signature from minisign secret key
RURqzx718YYz/AZEFNcRh0lQFAgg8JsladNTOULcWqBnZFoFoApH0UvoQQkTk8wC/LfZpfJkrn7XRzSUOPUOfudUebBSHHdVYQ0=
trusted comment: jei 0.1.0 for Horizon 26.3
OqC3JuFFGuGNdxATsGIvM20nrld1ME2ks3tI2hfaMX2s5bCgwYjieUDk7FbDQD8zxd3KHqOYaUTz6T+njgUNCw=="""
changelog = """
The first playable Horizon JEI viewer, built on Horizon's native hook points (version 2).
### Item Browser & Search
- Added an item panel alongside container screens, displaying every item known to the game with pagination.
- Implemented JEI's search grammar:
- Words and `"quoted phrases"`.
- `-exclusions` and `|` alternatives.
- `@namespace` and `#tag` filters.
- `$tooltip` and `%creative tab` are not supported yet, pending access to the required data.
### Recipe & Usage Viewer
- Press **R** to view an item's recipes and **U** to view its uses.
- Works over the item panel and any slot in a container screen.
- Clicking an item in the panel opens the same views.
- Added recipe pages with dedicated tabs for:
- Crafting
- Furnace
- Blast Furnace
- Smoker
- Campfire
- Stonecutter
- Smithing
- Brewing
- Supports all five of the game's recipe layouts, including cooking time and XP.
- Added a **Back** button to navigate through viewing history.
- Clicking a recipe slot opens that item's recipes.
### Recipe Placement
- Added the **+** button to place recipes into the open crafting grid when the recipe book contains them.
- The button remains disabled until the recipe is available in the recipe book.
- Shift-click is not supported yet because the panel's action event does not expose modifier keys.
### Item Giving
- Added item giving through **Options → Mods → JEI** or `/jei give`.
- In creative mode, left-click gives one item and right-click gives a full stack.
- In survival mode, clicking an item opens its recipes instead.
### Localization & Diagnostics
- Added English and Spanish UI translations, configurable through the mod's **Language** setting.
- Item names are retrieved directly from the game and always respect the player's language.
- Added `/jei` to report the game's table sizes."""
+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).
"""
+23
View File
@@ -0,0 +1,23 @@
id = "light_overlay"
kind = "native-mod"
name = "Light Overlay"
summary = "1:1 Light Overlay & MiniHUD (F7): visualizes mob-spawnable blocks with 3D Yellow (night spawn) and Red (always/cave spawn) crosses (X) or 3D light level numbers"
description = "A 1:1 native Rust port of Light Overlay and MiniHUD's F7 Light Level Overlay for Horizon. Press F7 during gameplay to scan nearby spawnable block surfaces and render depth-tested 3D markers directly on block tops. Red markers indicate blocks where hostile mobs can spawn at any time (caves/indoor darkness), Yellow markers indicate blocks where hostile mobs can spawn at night, and optional Green markers show safe blocks. Supports Crosses (X), 3D Numbers (0-15), or Both, with configurable scan radius (8 to 20 blocks) and Modern (== 0) or Legacy (<= 7) spawn thresholds."
authors = ["CayMac"]
owners = ["CayMac97"]
license = "MIT"
source = "https://github.com/CayMac97/light-overlay"
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
[[versions]]
version = "1.0.0"
published = "2026-10-31"
game = "26.3"
file = "light_overlay-1.0.0.hnmod"
sha256 = "b4aeb56b13d9c73df267a1dc7b1f588f4db39bfd55f7beaef97a014fffb5b076"
signature = """
untrusted comment: signature from minisign secret key\r
RUS411U2KXdTNy4CWemZ33Twp2eJ5FybX79KHuEbkVXRfv1C8+lWasqO1vOdx2A+8oN9eTR9XIh+QKjjw6xwrkG1u2nDoYkk5ww=\r
trusted comment: timestamp:1793444149\tfile:light_overlay-1.0.0.hnmod\thashed\r
3mGbUejwekJoW98TqQVK9QKS0aXMamM9rfCVOEwot/Xq/ZbaT1kUdDJMo6mn0iJIeodvdAFd7TnDEoguj7jkCA=="""
changelog = "Initial release for 26.3 Horizon. "
+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. 0.1.0 is the WebAssembly version."""
**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).
"""
-42
View File
@@ -1,42 +0,0 @@
id = "mod_menu"
kind = "mod"
name = "Mod Menu"
summary = "Browse installed mods, including native mods, with versions, enabled states, filters and search."
description = """
An independent, sandboxed mod browser by levzzz5154.
Press **M** while playing, run **`/modmenu`**, or open
**Options → Mods → Mod Menu → Settings**. Use `/modmenu text` to search names,
ids and mod types. Filter all/enabled/disabled mods, browse pages, select a row
for details, and refresh after changing mods in the launcher.
Lists installed sandboxed, content and native source mods in the current instance.
Enabled means the launcher's selected state, not proof a mod is running. This is
a read-only menu: it does not toggle other mods, run commands, or start native builds.
**Requires a launcher with the schema-1 Mod Menu inventory feed**
([Horizon #3112](https://github.com/horizon-rust/horizon/pull/3112)). On a launcher
without it, the menu shows an explicit update message. The launcher supplies display
metadata through this mod's own sandbox storage; the mod has no general filesystem
or network access. Direct external `--dev-mod` paths and runtime failure status
are not included in the feed.
Tested on Horizon nightly `83f15fa27` (Minecraft 26.3), mod API 0.1 / API level 0.
Other Horizon revisions have unverified support. This `.hmod` does not require Rust,
Git, Horizon Mixins or any native build to install.
Independently written, not a port of another project's Mod Menu. No Minecraft code
or artwork is included. MIT licensed; source, tests and feed specification are linked below.
"""
authors = ["levzzz5154"]
owners = ["levzzz5154"]
license = "MIT"
source = "https://github.com/levzzz5154/horizon-mod-menu"
[[versions]]
version = "0.1.1"
published = "2026-10-28"
game = "26.3"
file = "mod_menu-0.1.1.hmod"
sha256 = "3833b79bbb9607c5cab6dbdc4a6d3e31754c2ed183b1c6bf431f3615b93ef3b5"
changelog = "First release: installed mods, enabled states, native/content/sandboxed types, paging, filters, search and refresh. Requires the launcher inventory feed."
+22
View File
@@ -0,0 +1,22 @@
id = "motion_blur"
kind = "native-mod"
name = "Motion Blur"
summary = "Blurs what moves: mobs, players, items and vehicles along their own motion, with optional camera blur."
description = """
Moving mobs, players, items, minecarts and boats blur along their own motion. The camera's motion (turning, walking) can blur too, and is off by default.
Each frame the mod works out every pixel's motion since the last frame (the world's from the depth buffer and the camera, entities from last frame's vertices), finds the largest motion near each tile, and gathers along it with depth-aware weights (McGuire et al., "A Reconstruction Filter for Plausible Motion Blur", 2012).
Settings are in Options → Mods → Motion Blur: strength for moving things and the camera, shutter angle or a fixed exposure, the longest blur, quality, blurring yourself in third person, and a view that shows each pixel's motion."""
authors = ["levzzz5154"]
owners = ["levzzz5154"]
license = "MIT"
source = "https://github.com/levzzz5154/horizon-motion-blur"
[[versions]]
version = "0.1.0"
published = "2026-11-02"
game = "26.3"
file = "motion_blur-0.1.0.hnmod"
sha256 = "4cdaa6369fffea8116b013edfc618358ca989f48ed65c83306d2102f0e506c47"
changelog = "First release, built against Nightly 2026-11-01: motion blur for moving mobs, players, items and vehicles, with optional camera blur."
+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).
+21
View File
@@ -0,0 +1,21 @@
id = "rightharvest"
kind = "native-mod"
name = "RightClickHarvest"
summary = "Right-click a ripe crop to pick it and sow it again."
description = """
With an empty hand, right-click a ripe wheat, carrot, potato, beetroot or nether wart: the crop is harvested with the game's own drops, sound and statistics, and sown again from a seed in your inventory.
No settings: turn it off in Options > Mods."""
authors = ["Domanffe"]
owners = ["Domanffe"]
license = "MIT"
source = "https://github.com/Domanffe/rightharvest"
icon = "https://horizon-rust.github.io/horizon-mods/icons/rightharvest.png"
[[versions]]
version = "0.1.0"
published = "2026-10-30"
game = "26.3"
file = "rightharvest-0.1.0.hnmod"
sha256 = "a43870a89827c1d0fa861a663aa1781819b236f994191abf971c98125a3a4653"
changelog = "First release: a native mod for Horizon 26.3. With an empty hand, right-click a ripe crop to harvest it with the game's own drops and sow it again from a seed you carry."
+56
View File
@@ -0,0 +1,56 @@
id = "serverify"
kind = "native-mod"
name = "Serverify"
summary = "Find Minecraft servers and add them to your Multiplayer list, from a page in your web browser."
description = """
Serverify is a server browser. Press **F8** (or type `/serverify web`, or press the button on the mod's settings page) and your web browser opens a page with a search box, a category menu and cards showing each server's picture, address, player count and country. Press **Add** and the server goes to the top of your Multiplayer list. Then join it from Horizon's own Multiplayer screen.
It is the Horizon version of [Serverify](https://github.com/Louchatfroff/serverify), the server browser for Fabric, Quilt, Forge and NeoForge, by the same author.
## What it does
- Opens a server browser page in your web browser: search, 22 categories, an offline-mode filter and an **Add** button.
- Adds servers to `servers.dat` in the game folder, with the name cleaned of colour codes.
- Adds chat commands: `/serverify web`, `list [category]`, `search <words>`, `next`, `prev`, `add <number>` and `categories`.
- Adds a row in Options > Mods with a rebindable key (F8 by default) and a settings page with two buttons: open the browser, and **Offline-mode servers only** (on by default).
Horizon cannot join servers that check Minecraft accounts yet, which is most public servers, so by default the page shows servers that accept offline-mode players. Turn the filter off to see all of them.
## What it contacts
- **Modrinth.** The game asks `api.modrinth.com` for the server list and `cdn.modrinth.com` for server pictures, only when you look for servers.
- **Three listing sites.** With the offline-mode filter off (and for top-mc-servers.net's list of cracked servers when it is on), the game fetches public pages of `top-mc-servers.net`, `minecraftservers.org` and `minecraft-mp.com`, and their server pictures. The game builds those addresses itself. The page reads the fetched pages with the browser's own HTML parser.
- **A page on your computer.** The first time you open the page, the mod starts a small web server on `127.0.0.1` (this computer only, never the network) with a random secret in its address, made for each session. The page talks only to that server. A request with another host name, another origin or no secret is refused. The server can show the list, send a picture and add one server to your Multiplayer list.
- It sends no data about you, and makes no request at any other time.
## What it does not do
- It does not run any code from the sites it reads, and it does not send your data anywhere.
- It does not change anything but `servers.dat` (one entry at a time) and its own settings in `horizon-mods/.horizon-mod-data/serverify/`.
- It does not draw screens or buttons in the game: Horizon's hook points have none, so the screen is your web browser.
- It cannot make Horizon join servers that check Minecraft accounts.
- Adding a server rewrites `servers.dat`, which drops the hidden direct-join entries Horizon keeps. Your saved servers stay.
## Install
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"]
owners = ["Louchatfroff"]
license = "GPL-3.0-only"
source = "https://github.com/Louchatfroff/serverify"
icon = "icons/serverify.png"
[[versions]]
version = "0.1.0"
published = "2026-10-30"
game = "26.3"
file = "serverify-0.1.0.hnmod"
sha256 = "f9ad0777f8fc7ea532f799da8a9eb72df2348c8d6bb5dc104f81b6ab47209501"
changelog = """
First release for Horizon, ported from the Java Edition mod of the same name.
- A server browser page in your web browser (F8, `/serverify web`, or the settings button).
- Chat commands to list, search and add servers.
- An offline-mode filter, on by default, because Horizon cannot join servers that check Minecraft accounts yet."""
+31
View File
@@ -0,0 +1,31 @@
id = "shulker_peek"
kind = "native-mod"
name = "ShulkerPeek"
summary = "A shulker box's contents as a 9 x 3 grid in its tooltip."
description = """
Hover a shulker box to see what's inside: its 27 slots appear as a 9 x 3 grid right in the tooltip, laid out exactly like the box's own screen, with item icons and stack counts.
- Works with the plain shulker box and all 16 dyed ones, whether empty, partly filled or full.
- Works on every screen with item tooltips: your inventory, chests and other containers, the creative inventory, the item panel, chat item links and trading.
- Keeps the tooltip's usual lines (name, lore, "64 x Diamond"...); the grid goes underneath.
- Respects hidden tooltips: a box whose `minecraft:tooltip_display` hides its contents stays hidden.
- Settings in Options > Mods > ShulkerPeek > Settings: show the grid always or only while holding Shift, show a grid for empty boxes, and tint the grid with the box's dye colour.
- Uses your resource pack's own shulker box texture.
Works in single player and on LAN worlds you host. Horizon currently leaves a box's contents out of what its built-in server sends to the game window, which is why even the "64 x Diamond" lines are missing there. ShulkerPeek sends the contents the way a vanilla server does. On vanilla servers the grid works too; on other Horizon servers without the mod, boxes look empty.
It never changes items, inventories or worlds. Turn it off any time in Options > Mods.
This is a native mod built for Horizon Nightly 2026-10-29 (`5e391eb`). It builds together with Horizon's official native mods (FPS Counter, Zoom, Minimap, Recipes and others)."""
authors = ["vstxx"]
owners = ["vstxx"]
license = "MIT"
source = "https://github.com/vstxx/shulkerpeek"
icon = "icons/shulker_peek.png"
[[versions]]
version = "0.1.0"
published = "2026-10-29"
game = "26.3"
file = "shulker_peek-0.1.0.hnmod"
sha256 = "e4e33cb698d91042b2ed2f93a1c935f279d16d83988115d53c0f33530cabeab5"
+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.
+23
View File
@@ -0,0 +1,23 @@
id = "status_effect_hud"
kind = "native-mod"
name = "Status Effect HUD"
summary = "1:1 Status Effect Bars & Inventory HUD+ Potion HUD: displays active potion/beacon effects with icons, Roman numeral amplifiers, color-coded mm:ss countdown time"
description = "A 1:1 native Rust port of Status Effect Bars and Inventory HUD+ (Potion HUD) for Horizon. Renders all active potion and beacon status effects directly on the HUD during gameplay with their official mob_effect sprites, translated effect names, and Roman numeral potency levels (II, III, IV). Includes color-coded mm:ss countdown timers (White for >30s, Yellow for 10-30s, Flashing Red for <=10s urgent expiration warning, and Aqua infinity symbol for permanent effects) alongside smooth duration progress bars colored in each effect's authentic vanilla potion color."
authors = ["CayMac"]
owners = ["CayMac97"]
license = "MIT"
source = "https://github.com/CayMac97/status-effect-hud"
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
[[versions]]
version = "1.0.0"
published = "2026-10-31"
game = "26.3"
file = "status_effect_hud-1.0.0.hnmod"
sha256 = "afa30125bb8b402892f1719e550b66d4eb4d7264895f71bb9c98a4caf5c7b432"
signature = """
untrusted comment: signature from minisign secret key\r
RUS411U2KXdTNzV+wcfmnupP9b3ECNs7/D2jW8vAV9eAd1rxLpETdSVxvuPpnbTPB/W9APouT8uLbsZLrB9wFsOoBtrhuUPmCw0=\r
trusted comment: timestamp:1793443789\tfile:status_effect_hud-1.0.0.hnmod\thashed\r
MkNOcfvQQP8dWwvaSdoGIasz6tYOCB13U0OVFIApxz9VzhCUGRZXTYaSitlSSoBfGqYCQk87zvliwvklR0lbDQ=="""
changelog = "Initial release for 26.3 Horizon. "
+23
View File
@@ -0,0 +1,23 @@
id = "toggle_sprint"
kind = "native-mod"
name = "Toggle Sprint"
summary = "1:1 Better Sprinting & Lunar ToggleSprint HUD: toggle sprint and toggle sneak with [Sprinting (Toggled)] / [Key Held] / [Flying] status HUD and configurable Cre"
description = "A 1:1 native Rust port of Better Sprinting and the Lunar/Badlion ToggleSprint HUD for Horizon. Allows toggling continuous sprint (V or vanilla Sprint key) and sneak (C) without holding keys down, while automatically pausing toggled sneak inside inventory and chest screens so Shift-clicking items works normally. Renders the classic movement status HUD ([Sprinting (Toggled)], [Sprinting (Key Held)], [Sneaking (Toggled)], [Sneaking (Key Held)], [Flying (2.0x Boost)], [Swimming], [Gliding (Elytra)], [Riding]) and includes a configurable Creative Fly Speed Boost multiplier (1.0x to 4.0x)."
authors = ["CayMac"]
owners = ["CayMac97"]
license = "MIT"
source = "https://github.com/CayMac97/toggle-sprint"
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
[[versions]]
version = "1.0.0"
published = "2026-10-31"
game = "26.3"
file = "toggle_sprint-1.0.0.hnmod"
sha256 = "b2a4190f9c19adbceae4a639342b7630bfc3047339eabf57673b7807db5edc6a"
signature = """
untrusted comment: signature from minisign secret key\r
RUS411U2KXdTN3mUUTEw+mmiuQhcddYpNrjRnGA43/GS3y/qgY1EnL0kGp4NTBouTlVJbiThEkQfOrqIjnwlshUB18PrQloxAAw=\r
trusted comment: timestamp:1793443789\tfile:toggle_sprint-1.0.0.hnmod\thashed\r
2kNF92yIivWMWhgUH7ypwU3pciKG3ouiHhpZrQmX8fWZHay2da9HgdOYFAUp5KaHJ1NAgBuwL1zMfwVf61X+Cw=="""
changelog = "Initial release for 26.3 Horizon. "
+13 -6
View File
@@ -7,16 +7,15 @@ 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.
Tested against Horizon nightly `83f15fa276c44711276b6ceb5f0fe0c9c534dc66`
(`nightly/build-2026-10-28-3`) with Horizon Mixins 0.5.0 at
`0500ff24aa5933fca95a877ef41125353c4b4fe3`. The launcher builds these exact pinned revisions.
Other Horizon revisions have not been validated. Ordinary Play does not compile mods;
rebuild after changing the enabled native-mod combination.
Each version is pinned to one Horizon Nightly and Horizon Mixins revision, named in its
changelog; the launcher builds those exact revisions. Other Horizon revisions have not
been validated. Ordinary Play does not compile mods; rebuild after changing the enabled
native-mod combination.
Use `/veinminer toggle`, `/veinminer on`, `/veinminer off` or `/veinminer status`.
`/veinminer reload` requires operator permission. Survival mining only; install on the
@@ -37,3 +36,11 @@ game = "26.3"
file = "veinminer-0.3.0.hnmod"
sha256 = "ec308c02c65bad64043828a19ef6254d1eb9d422bb1123cd065cb9e6e7b126c8"
changelog = "Native launcher bundle, pinned to nightly 83f15fa27 and Horizon Mixins 0.5.0."
[[versions]]
version = "0.3.2"
published = "2026-10-31"
game = "26.3"
file = "veinminer-0.3.2.hnmod"
sha256 = "31a4249da1b35972ae8b34201373c86352f4adc2f5cb3e015749d1fbfecf00a0"
changelog = "Pinned to Nightly 2026-10-31 (d91cab6) and Horizon Mixins 4342a0b. Reads each level's height from the loaded world, so data-pack dimension heights are respected."
+19
View File
@@ -0,0 +1,19 @@
id = "worldedit"
kind = "native-mod"
name = "WorldEdit"
summary = "working port of java world edit"
description = """
//we for commands
working schematics."""
authors = ["_cykoo_"]
owners = ["XnotCykoX"]
license = "MIT"
source = "https://github.com/XnotCykoX/WorldEdit-Horizon"
[[versions]]
version = "1.2.1"
published = "2026-10-30"
game = "26.3"
file = "worldedit-1.2.1.hnmod"
sha256 = "b2bb3019ecd4d5392644c54c70a87d6c3277538a6e18d0c311f3a051f7d2f015"
+33
View File
@@ -0,0 +1,33 @@
id = "xtoxray"
kind = "native-mod"
name = "X To Xray"
summary = "Opensource Xraymod"
description = """
Press X To Xray!
Does Not Work On Servers.
Instalation:
1. Download Horizon
2. Get Horizon Working
3. Add The Mod Trough The Horizon Launcher
4. Start Xraying
"""
authors = ["1_st_Cat"]
owners = ["RealCat1st"]
license = "Apache-2.0"
source = "https://github.com/RealCat1st/XToXrayRust"
icon = "icons/xtoxray.png"
key = "RWQVF+EFGGnX6eUNms+cP5AceLvx9b0rFYYXCty7so4MVbJ1SNpi9zNK"
[[versions]]
version = "2026.29.0"
published = "2026-10-30"
game = "26.3"
file = "xtoxray-2026.29.0.hnmod"
sha256 = "3446a4a70a2b87ef6e9de00ce8a255aa83c2e44ccd5acc9f14887662ed55f27c"
signature = """
untrusted comment: signature from rsign secret key
RUQVF+EFGGnX6cZkn/iGZ9FUHXC45MU+8uuj7l7eRkZ+bR/x7S8s2U34F7nv/f6jKXdzeLZi3QoSA5g5oXqP99/KlGgd/19ygwY=
trusted comment: timestamp:1793375231\tfile:xtoxray-2026.29.0.hnmod\tprehashed
2h6QOc/dnek1KMCGL2jerhCObc9pECmShr0kKYJhEEFz52Hhb4C3vmjc9vuwE3CftUHs+StHkalHQVLVQy8eCg=="""
changelog = "Release"
+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).
"""
+3 -2
View File
@@ -6,12 +6,13 @@ Your block position and the direction you face in a box in the top-left corner o
- `/coords` hides or shows the box, and the game keeps your choice (also Options > Mods > Coordinates HUD > Settings).
It is a [horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundle (`mixins/coords_hud/`) that the Horizon
launcher builds into the game. It replaces the WebAssembly Coordinates HUD 0.1.0, the WebAssembly SDK's example mod.
launcher builds into the game. It is a small example mod.
The time of day line is gone: Horizon's hook points don't give the game's clock yet.
## 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).
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "4baa571566e39bfd6594988d3bb2bc85440c30e7",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+4 -3
View File
@@ -3,11 +3,12 @@
Horizon's official FPS counter: frames per second in a box in a corner of the screen, and, if you like, the slowest frame and the 1% low of the last 5 seconds. Its settings page (Options > Mods > FPS Counter > Settings) picks the corner and the lows.
It is a [horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundle (`mixins/fps_overlay/`) that the Horizon
launcher builds into the game. It replaces the WebAssembly FPS Counter 1.x and keeps its settings: the same files (`horizon-mods/.horizon-mod-data/fps_overlay/corner`, `lows`).
launcher builds into the game. It keeps the settings of earlier versions: the same files (`horizon-mods/.horizon-mod-data/fps_overlay/corner`, `lows`).
## 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).
@@ -38,6 +39,6 @@ python3 native/tools/native.py proof fps-overlay --resources /path/to/.local/hor
```
The proof needs resources prepared from your own Minecraft 26.3 (`horizon-prepare`); none are in this repository.
It seeds the WebAssembly mod's settings (bottom right, lows on) in a temporary game folder, then checks that Options > Mods shows them, and that the Corner button moves the box to the top left and writes the same file. CI runs `cargo test`, packages the `.hnmod` and checks the game on the tested commit, and every Monday on the newest Nightly.
It seeds an earlier version's settings (bottom right, lows on) in a temporary game folder, then checks that Options > Mods shows them, and that the Corner button moves the box to the top left and writes the same file. CI runs `cargo test`, packages the `.hnmod` and checks the game on the tested commit, and every Monday on the newest Nightly.
License: MIT.
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+4 -3
View File
@@ -3,11 +3,12 @@
Horizon's official Keystrokes: your movement keys, mouse buttons and space bar in a corner, lit while held, with clicks per second. Its settings page (Options > Mods > Keystrokes > Settings) picks the corner and the clicks per second.
It is a [horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundle (`mixins/keystrokes/`) that the Horizon
launcher builds into the game. It replaces the WebAssembly Keystrokes 1.x and keeps its settings: the same files (`horizon-mods/.horizon-mod-data/keystrokes/corner`, `cps`).
launcher builds into the game. It keeps the settings of earlier versions: the same files (`horizon-mods/.horizon-mod-data/keystrokes/corner`, `cps`).
## 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).
@@ -38,6 +39,6 @@ python3 native/tools/native.py proof keystrokes --resources /path/to/.local/hori
```
The proof needs resources prepared from your own Minecraft 26.3 (`horizon-prepare`); none are in this repository.
It seeds the WebAssembly mod's settings (bottom right, no clicks per second) in a temporary game folder, then checks that Options > Mods shows them, and that the Corner button moves the keys to the top left and writes the same file. CI runs `cargo test`, packages the `.hnmod` and checks the game on the tested commit, and every Monday on the newest Nightly.
It seeds an earlier version's settings (bottom right, no clicks per second) in a temporary game folder, then checks that Options > Mods shows them, and that the Corner button moves the keys to the top left and writes the same file. CI runs `cargo test`, packages the `.hnmod` and checks the game on the tested commit, and every Monday on the newest Nightly.
License: MIT.
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+4 -3
View File
@@ -3,11 +3,12 @@
Horizon's official Minimap: a map of the terrain around you in a corner, in map colours, square or round, north up or turning with you, drawn by Horizon's own minimap. Its settings page (Options > Mods > Minimap > Settings) picks the corner, size, shape, zoom and turning.
It is a [horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundle (`mixins/minimap/`) that the Horizon
launcher builds into the game. It replaces the WebAssembly Minimap 1.x and keeps its settings: the same files (`horizon-mods/.horizon-mod-data/minimap/corner`, `size`, `shape`, `zoom`, `rotate`).
launcher builds into the game. It keeps the settings of earlier versions: the same files (`horizon-mods/.horizon-mod-data/minimap/corner`, `size`, `shape`, `zoom`, `rotate`).
## 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).
@@ -38,6 +39,6 @@ python3 native/tools/native.py proof minimap --resources /path/to/.local/horizon
```
The proof needs resources prepared from your own Minecraft 26.3 (`horizon-prepare`); none are in this repository.
It seeds the WebAssembly mod's settings (bottom right, round, 2 blocks per pixel) in a temporary game folder, then checks that Options > Mods shows them, and that the Corner button moves the map to the top left and writes the same file. CI runs `cargo test`, packages the `.hnmod` and checks the game on the tested commit, and every Monday on the newest Nightly.
It seeds an earlier version's settings (bottom right, round, 2 blocks per pixel) in a temporary game folder, then checks that Options > Mods shows them, and that the Corner button moves the map to the top left and writes the same file. CI runs `cargo test`, packages the `.hnmod` and checks the game on the tested commit, and every Monday on the newest Nightly.
License: MIT.
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+4 -3
View File
@@ -7,13 +7,14 @@ Settings): a click on an item in the panel gives you one, a right click a stack.
Options > Mods.
It is a [horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundle (`mixins/recipes/`) that the Horizon
launcher builds into the game. It replaces the WebAssembly Recipes 1.x and keeps its settings: the same file
launcher builds into the game. It keeps the settings of earlier versions: the same file
(`horizon-mods/.horizon-mod-data/recipes/give`) and the same keys (`key.recipes.recipe`, `key.recipes.uses` in
`horizon-mods.txt`).
## 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).
@@ -44,7 +45,7 @@ python3 native/tools/native.py proof recipes --resources /path/to/.local/horizon
```
The proof needs resources prepared from your own Minecraft 26.3 (`horizon-prepare`); none are in this repository.
It seeds the WebAssembly mod's setting ("Click gives items" on) in a temporary game folder, checks that Options > Mods
It seeds an earlier version's setting ("Click gives items" on) in a temporary game folder, checks that Options > Mods
shows the mod, its keys and the setting, then opens the inventory: the panel shows items, a click on one in creative
mode gives it, and with the setting turned off the same click opens its recipe page.
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "4baa571566e39bfd6594988d3bb2bc85440c30e7",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
target/
+107
View File
@@ -0,0 +1,107 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "horizon-mod-smp"
version = "0.31.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "horizon-mod-smp"
version = "0.31.0"
edition = "2024"
license = "MIT"
publish = false
description = "The official Horizon server's SMP, a native server mod: economy (and next, market, trades, claims)."
# The mod's rules and their tests: the same files the bundle adds to the game
# (as game::smp_store, game::smp_economy), under the same names.
[lib]
path = "src/lib.rs"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+363
View File
@@ -0,0 +1,363 @@
# SMP (Horizon native server mod)
The official Horizon server's survival layer ([design](https://github.com/horizon-rust/horizon/blob/main/docs/smp.md)).
It is **not** part of Horizon's own server: only a server that builds this mod into its engine has it. It isn't listed
in the registry.
Players install nothing: it speaks only what a stock Horizon client already shows (commands with completions, chat
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 auction house, the server market and its `/shop` menu, player trades, the coinflip, the lottery, places (spawn, homes, warps,
teleport requests, the rules), land claims, and the scoreboard (a sidebar with your balance, playtime and the richest players, the Tab list's
header and footer).
| Command | Does |
|---|---|
| `/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 <player> <amount> <reason>`, `/money give ...` | operators of level 3: makes money for a player (an event prize, a bug refund); everyone reads it with the reason, and the audit log keeps it |
| `/eco take <player> <amount> [reason]`, `/money take ...` | operators of level 3: takes a cheat's gains out of the game, logged with the reason |
| `/eco stats` | the money supply and where it sits, what the server made and destroyed today, how evenly money is spread |
| `/ah` | the auction house: a chest menu of listings by category, cheapest a unit first; click one to buy it or bid; your listings (click to take one back) and your mail |
| `/ah sell <price> [count]` | lists the stack in your hand (or part of it), whole (enchantments, name, contents...), at a fixed price, for `listing_hours` (48); listing is free |
| `/ah auction <start> [hours]` | auctions the stack in your hand from a starting bid, for 1 to 24 hours (12) |
| `/ah bid <listing> <amount>`, `/ah buy <listing>` | bids (the money is held until someone bids more or it ends), buys a fixed-price listing |
| `/ah cancel <listing>`, `/ah claim`, `/ah search <words>`, `/ah mine`, `/ah mail` | takes back a listing nobody bid on; gives you your mail; the menu on a search, your listings, your mail |
| `/shop` | the market as a chest menu: categories, a click buys one, a shift-click 64, a click on a stack of yours sells it, "Sell all" |
| `/market [item]` | the moving market as a chest menu: categories, each item's price, its move over 1 h and 24 h and its volume; an item's page buys or sells 1, 16 or 64, sells all you carry, and gives its price chart as a map |
| `/worth [item]` | an item's price, the same to buy and to sell (the one in your hand without a name), and what you may still sell today |
| `/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) |
| `/trade accept [player]`, `/trade deny [player]` | answers a request |
| `/trade money <amount>` | the money you add to your offer (0: none) |
| `/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 |
| `/warp <name>`, `/warps` | to a warp, the list; `/setwarp <name>`, `/delwarp <name>` for operators of level 3 |
| `/tpa <player>`, `/tpaccept [player]`, `/tpdeny [player]` | asks to go to a player (60 s to answer); answers a request |
| `/rules` | the server's rules |
| `/back` | back to where you died, or where your last teleport took you from (warmup and cooldown as any teleport) |
| `/afk` | marks you away from the keyboard until you move, turn or run a command |
| `/playtime [player]` | how long you (or a player who is on) have played here |
| `/claim`, `/claim info`, `/unclaim` | claims the chunk you stand in (all its height), says whose it is, frees it (operators of level 3: anyone's) |
| `/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 |
| `/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:
- 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 whole-coin rounding): a round trip never earns (tested for 1, 64 and past the minute's band, and
over thousands of random trades with the drift in between).
- No chain of recipes turns what the market sells into something it buys for more: the cheapest way to get each item
from the market (bought, or crafted, smelted, cut from bought items, through any number of recipes) is worked out,
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 all the stakes: they move money between players and never make any.
Default prices (coins; "buy only": the market sells it but doesn't buy it, "sell only" the other way):
| Category | Items |
|---|---|
| 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. There is no tax on trades (an older config's
`tax_percent` is ignored): the receipt is the price ("Sold 3 diamond at 198 coins each: 595 coins."). Items a player bought from the market in the last `round_trip_hours` (24) can't be
sold back to it. (`max_trade_impact_percent` and `max_player_impact_percent` are no longer used: the curve and the
minute's band do their work.) History: one point a minute for 24 hours and one an hour for 30 days (the mid price, the
items traded and those sold), in `market_prices.json`, written by a thread every 5 minutes and when the world closes.
At the first minute of each UTC day, the day's three biggest risers and fallers (at least 1%) and news from real
activity ("Diamond supply up 40% today", sales against the day before's, at least `news_percent` and
`news_min_volume`) go to everyone's chat and to `web/market.json` for Kira to post. `enabled: false` keeps the
fixed prices of 0.23. For other mods: `Market::spot`, `volume(id, now, hours)`, `history(id, Span)`.
**`/market`.** A 6-row chest menu: the shop's categories and the day's movers; a category's items, each with its
price, its change over 1 h and 24 h (green ▲, red ▼) and what was traded in 24 h; an item's page with its
base price and what you may still sell today, buttons to buy or sell 1, 16 and 64 (what they come to
along the curve), sell all of it you carry, and the price chart over 24 hours or 7 days, given as a filled map (128 × 128,
drawn by the server: the curve green when it rose, red when it fell, the price, the change, the low and high). A
chart map is drawn again each minute for whoever carries it, and once a minute the open menus follow the prices.
`/market <item>` opens the item's page. `/worth` adds the item's moves and volume.
**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
shows your balance and today's selling limit. It goes through the market's own rules (survival or adventure only,
items then money, logged, the daily limits); the items move by the console's `clear` and `give`, measured on the
inventory before and after, so the money follows what really moved.
**Trades.** Once accepted, both players see a 6-row chest menu: their own offer on the left (16 stacks), the other's
on the right, each side's money, their confirm and cancel buttons, and whether the other has confirmed. A click on a
stack in your inventory, below the menu, adds it to your offer; a click on it in your offer takes it back. The menu
moves nothing: offered stacks stay in their owners' inventories, the menu shows copies, and a stack that changes
(used, dropped, grown by a pickup) leaves the offer. Any change clears both confirmations. When both have confirmed,
the server checks that each still holds exactly what they offer, has room for what they get and has the money, logs
the trade, and swaps everything in one step, or nothing (and both must confirm again). Closing the menu,
`/trade cancel`, leaving, dying or the other player leaving ends the trade with nothing moved. Survival and
adventure players only.
**Coinflips.** Off by default: the owner turns them on in `config.json` (`gambling`: `enabled`, `min_bet` 10,
`max_bet` 1000, `cooldown_seconds` 30, `offer_seconds` 60). A stake is held from the offer, in
`economy.json` with the balances, until the coin falls; an offer cancelled, not taken in time, or whose player
left gives it back, and so does the next start for one the server stopped with. The coin is a fresh random key from
the system, never the world's random. Only the in-game currency is staked and won.
**The lottery.** Part of `gambling` (`ticket_price` 10, `max_tickets` 100 a player a draw, `lottery_seconds` 3600
from a round's first ticket to its draw). Tickets are held like coinflip stakes; at the draw one ticket is picked at
random among all sold (more tickets, more chances), its owner gets the whole pot, and everyone hears
it. A round with a single player, or one the server stopped with, gives every ticket back.
**The casino.** On with `gambling.enabled` (and `casino.enabled`, true by default). Every game is a chest menu any
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`): a win larger than the pot is paid up to it. A new casino's pot gets `float`
(20,000) once, made for it (`casino_float`; an older pot only what it lacks of it), then keeps every lost bet: the
house edge stays in the pot, out of the players' hands. A win of `jackpot_multiple` (50) times the stake, or of `announce_profit` (5000) coins, is
told to everyone. Settings in `config.json`'s `casino`, also `crash_edge_percent` (4) and `crash_max` (100).
**Places.** A teleport waits `warmup_seconds` (3) in `config.json`'s `places`; a player who moves or is hurt meanwhile
stays where they are, and `cooldown_seconds` (10) passes between two of one player's teleports. Teleports are free (an older config's
`places.fees` is ignored). `/tpa` takes the
asker to where the other player is when the warmup ends. Homes grow with the time played on the server since the mod
was on (`homes`: `[hours, homes]`, by default 1 home, 2 from 5 hours, 3 from 20, 5 from 100). `rules` holds `/rules`'
lines. Teleports are vanilla's `tp`, run as the server (`execute in <level> run tp`), so they cross levels and land
as any teleport does.
**Claims.** In a claimed chunk, only its owner, the players they trust and operators of level 3 break blocks or right
click blocks: doors, chests, levers, buttons, placing a block, a bucket or flint on a block. Anyone else's click does
nothing, and the action bar says whose land it is. The chunks a player may hold grow with the time played here
(`claims.chunks`: `[hours, chunks]`, by default 4, 16 from 5 hours, 36 from 20, 64 from 100); `claims.enabled`
turns them off. `claims.free_radius` (0, none) keeps the land within that many blocks of 0 0, in every dimension, free
for all: no one claims a chunk that touches it, and claims already there are dropped at start (each logged). The world stays out too: no water or lava flows, no fire spreads or burns, and no piston pushes into
a claim from land that isn't its owner's (or a player's they trust), and explosions break no claimed block, whoever
lit them. Buckets are guarded where they fill or empty, and hitting the claim's item frames, armour stands,
paintings, boats, minecarts and animals does nothing for others (`claims.protected_entities`, entity ids; monsters
and players aren't in it). Not guarded yet: right clicking an entity (rotating a frame's item, an armour stand's
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, 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) 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
whole price); of two players who buy at once (the game runs every click on one
thread), the second finds it gone. Bids wait in the `auction` pool: a higher bid gives the last one back at once,
raising one's own only holds the difference, and a bid in the last `snipe_seconds` (60) moves the end back to
60 seconds away. When an auction ends, the seller is paid the winning bid and the items go to the winner's mail. Sellers
and outbid players who are offline find a note when they come back. Everything (listings, bids, mail, notes) is
written in `economy.json` with the balances, in one step: a crash can't keep the money and lose the items, or the
reverse. A listing carries the whole stack (since 0.31): its enchantments, name, damage, lore, a shulker box's
or a bundle's contents and every other component, written as the game writes it in player files, and given back
exactly so (Horizon's `native_give_stack`) to the buyer, the winner, or the seller when it comes back. `/ah sell
<price> <count>` lists part of the stack in hand; the rest stays in the hand, the same. The menu shows the stack
itself (its tooltip), with the listing's price, seller and time under it. A stack is taken only if it would come
back the same (its data written and read back equal); not market charts (free at `/market`), nor a stack holding
more than 32 KiB of data (a box of books). Listings and mail from before 0.31 hold plain items and are given back as
before. Survival and adventure players only. `config.json`'s `auction`:
`enabled`, `max_listings` (8 a player), `listing_hours` (48), `auction_hours` (12),
`auction_hours_max` (24), `bid_step_percent` (5: the smallest raise), `snipe_seconds` (60). Ended listings are
looked at once a second (a pass over the open listings).
**Money in and out.** Money enters the game through what the market buys from players, the daily reward, the
casino's float (once) and operators' `/eco give` (with a reason everyone reads, logged as `eco_give`). It leaves
through what the market sells and operators' `/eco take`. There are no fees, taxes, bank, contracts or futures: no
VAT, land or wealth tax, no tax on market trades, no teleport, auction or coinflip fee. A world from before 0.30 is
cleaned up at its first start: bank savings and the margins of open futures positions go back to their owners
(`bank_closed`, `futures_closed`), and the Treasury, its insurance fund, contracts and land tax owed are dropped.
`config.json`'s old `taxes`, `bank`, `jobs` and `futures` sections and the `fee_percent` and `listing_fee_percent`
keys are ignored.
**The report.** `/eco stats` (anyone) shows the money supply (wallets and money held, each pool, the casino's pot),
what the server made today (market sales, the daily reward, `/eco give`) and destroyed (market purchases, operators'
`/eco take`), and how evenly money is spread (Gini over players
with money, the richest ten's share). At 00:00 UTC everyone reads the day before in one line (`report.daily_report`).
A snapshot of each day is kept 60 days (`report.history_days`) in `economy.json` (`ext.report`). Once a minute
`world/horizon-smp/web/economy.json` (`report.web_json`) is written off the tick thread for the site's /hub:
`{"schema": 1, "generated": <ms>, "currency", "today", "pools", "minted_today", "burned_today", "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
Everything is in the world's folder, `world/horizon-smp/`, so a backup of the world carries it. A world without that
folder is untouched until the mod first runs on it; it changes no block and no player.
| File | Holds |
|---|---|
| `config.json` | `currency` (default `coins`), `starting_balance` (default 0), `market`: `enabled`, `items` (each item's `buy`, what the market asks, and `sell`, what it pays, 0 closing that side), `daily_sell_coins` (2000) and `daily_sell_items` (640), 0 for no limit; `shop`: `enabled`, `title`, `categories` (`name`, `icon`, `items`); `web`: `url` (Kira's `/smp/ingest`) and `token` (empty: nothing is sent), where the pages of `web/` go; written with the defaults the first time, and again when a newer version adds keys (your values kept) |
| `economy.json` | balances by player UUID, with the name last seen; `held`: coinflip stakes and lottery tickets being decided; `pools`: the casino's pot and the auction house's bids; `ext`: other parts' state written in the same step as the money (`auction`: the listings, bids, mail and notes) |
| `places.json` | the spawn, the warps, each player's homes, and the seconds each player has played (written once a minute) |
| `nicks.json` | each player's nickname and when they last changed it |
| `staff.log` | one line per `/invsee` or `/endersee` menu in which something moved: who, whose, what was taken and put |
| `market.json` | what each player sold today (coins and items), for the daily limits |
| `market_prices.json` | each item's price factor and history (minutes for 24 h, hours for 30 days: `[time, mid, traded, sold]`), what each player bought in the last day (no selling it back), the day's summary |
| `web/market.json` | for the website (`/hub/market`, through Kira), written by a thread each minute prices move: `schema` 1, `generated`, `currency`, `tax_percent` (always 0, for older readers), `daily_summary` (`day`, `lines`), `items` (`id`, `name`, `category`, `price` (the one price; `buy` and `sell` repeat it on the sides the market trades, for older readers), `mid`, `base`, `change_1h`, `change_24h`, `volume_24h`, `day`: 5-minute points, `month`: hours, each `{step, t, price, volume}`). When `web.token` is set, the same thread POSTs each page of `web/` that changed (gzipped, `Authorization: Bearer <token>`) to `<web.url>/<name>`: Kira keeps the last one and serves it to the site; the server needs no inbound access and Kira no key to it |
| `presence.json` | the players who have joined (no welcome again), and the day each last got the daily reward |
| `board.json` | the players who hid the sidebar (`/sidebar off`) |
| `casino.json` | the day's ledger: each player's bets, wins and rounds today (the daily loss cap), the day's biggest wins, each game's totals, the pot's last sweep |
| `claims.json` | each claimed chunk (`<dimension> <chunk x> <chunk z>`) with its owner, and whom each owner trusts |
| `audit.log` | one JSON line per change, written before the change: `time` (ms), `kind` (`open`, `pay`, `grant`, `ah_listing_fee`...), `amount`, `from`/`to` with their balance before, `by` for an operator, `to_pool`/`from_pool` with `pool_before` and `reason` for a pool's movement; a trade's line (`trade`: both players, their items and money) comes before its swap, `trade_failed` after one that didn't go through |
`economy.json` is written whole to a temporary file, flushed and renamed over the old one, at most once a server tick
and when the world closes: a crash leaves the old file or the new one. If it can't be read, the economy stays off (the
commands say so) rather than start from nothing.
## Made on the hook points
It hooks only Horizon's native hook points: `native_register_commands` and `native_perform_command` (API 8), the
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),
`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: twenty-seven modules and the HEAD hooks |
| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs`, `smp_recipes.rs`, `smp_shop.rs`, `smp_nick.rs`, `smp_trade.rs`, `smp_games.rs`, `smp_lottery.rs`, `smp_places.rs`, `smp_claims.rs`, `smp_board.rs`, `smp_presence.rs`, `smp_auction.rs`, `smp_report.rs`, `smp_market_prices.rs`, `smp_market_menu.rs`, `smp_market_chart.rs`, `smp_invsee.rs` | The rules, no game types (a trade's stacks are a type parameter): added to the game as `game::smp_store`, `game::smp_economy`, `game::smp_market`, `game::smp_recipes`, `game::smp_shop`, `game::smp_nick`, `game::smp_trade`, `game::smp_games`, `game::smp_lottery`, `game::smp_places`, `game::smp_claims`, `game::smp_board`, `game::smp_presence`, `game::smp_auction`, `game::smp_report`, `game::smp_market_prices`, `game::smp_market_menu`, `game::smp_market_chart`, `game::smp_invsee`; also this crate's modules (`src/lib.rs`), for their tests |
| `mixins/smp/glue.rs` | The state for the open world and the edges to the game's types (`game::smp_glue`) |
| `mixins/smp/{register,command,tick,lifecycle,menu_clicked,menu_closed,destroy_block,right_click_block,block_spread,piston_move,explosion_detonate,fill_bucket,attack_entity}.rs` | One small helper per hook |
| `mixins/smp/smp_casino.rs`, `smp_casino_roulette.rs`, `smp_casino_slots.rs`, `smp_casino_crash.rs`, `smp_casino_blackjack.rs` | The casino's rules, menus and effects (`game::smp_casino*`) |
| `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
```sh
(cd native/smp && cargo test) # the rules
python3 native/tools/native.py check smp # apply on the tested Horizon and cargo check the game
python3 native/tools/native.py proof smp --resources /path/to/.local/horizon-client/26.3
```
## The official server's engine
The server runs `horizon-server` as usual, with `[engine] path` pointing at a game built with this mod:
```sh
NATIVE_NO_DEFAULT_FEATURES=1 python3 native/tools/native.py build smp --horizon <the release's commit>
```
prints the built `horizon-client` (`--no-default-features`: the server build, without audio and controllers). When
to deploy it on the official server is the owners' decision.
+8
View File
@@ -0,0 +1,8 @@
{
"game": "26.3",
"hooks": 62,
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "975c07d9151847e6af13afc4b5ab37b6bbe78ef5",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+10
View File
@@ -0,0 +1,10 @@
// A player hits an entity: a claim's frames, armor stands and animals are
// kept from players its owner doesn't trust.
fn smp_attack_entity(
game: &mut crate::game::Game<'_>,
kind: &str,
pos: [f64; 3],
canceled: &mut bool,
) {
crate::game::smp_glue::attack(game, kind, pos, canceled);
}
+14
View File
@@ -0,0 +1,14 @@
// A fluid flows or fire spreads into a block: kept out of the land others
// claimed.
fn smp_block_spread(
dimension: crate::game::LevelDimension,
from: horizon_core::position::BlockPos,
to: horizon_core::position::BlockPos,
canceled: &mut bool,
) {
let dimension = crate::game::smp_glue::dimension_id(dimension);
let (from, to) = ([from.x, from.y, from.z], [to.x, to.y, to.z]);
if !crate::game::smp_glue::world_allows(dimension, from, &[to]) {
*canceled = true;
}
}
+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 float (once). 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
}
+14
View File
@@ -0,0 +1,14 @@
// A player's command on the server: the SMP's own, when it is one.
fn smp_perform_command(
game: &mut crate::game::Game<'_>,
player: &crate::game::native_server::NativeServerPlayer,
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);
}
}
+4
View File
@@ -0,0 +1,4 @@
// A player breaks a block: kept out of the land others claimed.
fn smp_destroy_block(game: &mut crate::game::Game<'_>, pos: [i32; 3], canceled: &mut bool) {
crate::game::smp_glue::guard(game, pos, canceled);
}
@@ -0,0 +1,8 @@
// An explosion's blocks: claimed land is spared.
fn smp_explosion_detonate(
game: &crate::game::Game<'_>,
dimension: &str,
blocks: &mut Vec<[i32; 3]>,
) {
crate::game::smp_glue::explosion(game, dimension, blocks);
}
+4
View File
@@ -0,0 +1,4 @@
// A player fills or empties a bucket: kept out of the land others claimed.
fn smp_fill_bucket(game: &mut crate::game::Game<'_>, pos: [i32; 3], canceled: &mut bool) {
crate::game::smp_glue::guard(game, pos, canceled);
}
File diff suppressed because it is too large. Load diff
+317
View File
@@ -0,0 +1,317 @@
[mod]
id = "smp"
version = "0.31.0"
priority = 100
# 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 = "975c07d9151847e6af13afc4b5ab37b6bbe78ef5"
[[module]]
id = "store"
file = "crates/horizon-client/src/game.rs"
name = "smp_store"
source = "smp_store.rs"
[[module]]
id = "economy"
file = "crates/horizon-client/src/game.rs"
name = "smp_economy"
source = "smp_economy.rs"
[[module]]
id = "market"
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"
name = "smp_recipes"
source = "smp_recipes.rs"
[[module]]
id = "shop"
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 = "auction"
file = "crates/horizon-client/src/game.rs"
name = "smp_auction"
source = "smp_auction.rs"
[[module]]
id = "trade"
file = "crates/horizon-client/src/game.rs"
name = "smp_trade"
source = "smp_trade.rs"
[[module]]
id = "games"
file = "crates/horizon-client/src/game.rs"
name = "smp_games"
source = "smp_games.rs"
[[module]]
id = "lottery"
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"
name = "smp_places"
source = "smp_places.rs"
[[module]]
id = "board"
file = "crates/horizon-client/src/game.rs"
name = "smp_board"
source = "smp_board.rs"
[[module]]
id = "presence"
file = "crates/horizon-client/src/game.rs"
name = "smp_presence"
source = "smp_presence.rs"
[[module]]
id = "claims"
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"
name = "smp_glue"
source = "glue.rs"
[[injection]]
id = "register"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_register_commands"
helper = "register.rs"
bindings = [
{ name = "commands", ty = "&mut Vec<crate::game::native_server::NativeCommand>", value = "&mut *commands" },
]
[[injection]]
id = "command"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_perform_command"
helper = "command.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "player", ty = "&crate::game::native_server::NativeServerPlayer", value = "player" },
{ name = "command", ty = "&str", value = "command" },
{ name = "ran", ty = "&mut bool", value = "&mut *ran" },
]
[[injection]]
id = "tick"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_server_tick"
helper = "tick.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "phase", ty = "crate::game::native_lifecycle::ServerTickPhase", value = "phase" },
]
[[injection]]
id = "lifecycle"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_world_lifecycle"
helper = "lifecycle.rs"
bindings = [
{ name = "phase", ty = "crate::game::native_lifecycle::WorldPhase", value = "phase" },
]
[[injection]]
id = "menu_clicked"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_menu_clicked"
helper = "menu_clicked.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "uuid", ty = "u128", value = "uuid" },
{ name = "window", ty = "i32", value = "window" },
{ name = "slot", ty = "i32", value = "slot" },
{ name = "mode", ty = "i32", value = "mode" },
]
[[injection]]
id = "menu_closed"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_menu_closed"
helper = "menu_closed.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "uuid", ty = "u128", value = "uuid" },
{ name = "window", ty = "i32", value = "window" },
]
[[injection]]
id = "destroy_block"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_destroy_block"
helper = "destroy_block.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "pos", ty = "[i32; 3]", value = "pos" },
{ name = "canceled", ty = "&mut bool", value = "&mut *canceled" },
]
[[injection]]
id = "right_click_block"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_right_click_block"
helper = "right_click_block.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "pos", ty = "[i32; 3]", value = "pos" },
{ name = "canceled", ty = "&mut bool", value = "&mut *canceled" },
]
[[injection]]
id = "block_spread"
kind = "HEAD"
target = "horizon_client::game::native_hooks::GameLevel<'_>::native_block_spread"
helper = "block_spread.rs"
bindings = [
{ name = "dimension", ty = "crate::game::LevelDimension", value = "dimension" },
{ name = "from", ty = "horizon_core::position::BlockPos", value = "from" },
{ name = "to", ty = "horizon_core::position::BlockPos", value = "to" },
{ name = "canceled", ty = "&mut bool", value = "&mut *canceled" },
]
[[injection]]
id = "piston_move"
kind = "HEAD"
target = "horizon_client::game::native_hooks::GameLevel<'_>::native_piston_move"
helper = "piston_move.rs"
bindings = [
{ name = "dimension", ty = "crate::game::LevelDimension", value = "dimension" },
{ name = "piston", ty = "horizon_core::position::BlockPos", value = "piston" },
{ name = "direction", ty = "[i32; 3]", value = "direction" },
{ name = "push", ty = "&[horizon_core::position::BlockPos]", value = "push" },
{ name = "destroy", ty = "&[horizon_core::position::BlockPos]", value = "destroy" },
{ name = "canceled", ty = "&mut bool", value = "&mut *canceled" },
]
[[injection]]
id = "explosion_detonate"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_explosion_detonate"
helper = "explosion_detonate.rs"
bindings = [
{ name = "game", ty = "&crate::game::Game<'_>", value = "&*self" },
{ name = "dimension", ty = "&str", value = "dimension" },
{ name = "blocks", ty = "&mut Vec<[i32; 3]>", value = "&mut *blocks" },
]
[[injection]]
id = "fill_bucket"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_fill_bucket"
helper = "fill_bucket.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "pos", ty = "[i32; 3]", value = "pos" },
{ name = "canceled", ty = "&mut bool", value = "&mut *canceled" },
]
[[injection]]
id = "attack_entity"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_attack_entity"
helper = "attack_entity.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "kind", ty = "&str", value = "kind" },
{ name = "pos", ty = "[f64; 3]", value = "pos" },
{ name = "canceled", ty = "&mut bool", value = "&mut *canceled" },
]
+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
}
+10
View File
@@ -0,0 +1,10 @@
// The world closing: the SMP writes its files and lets its state go.
fn smp_world_lifecycle(phase: crate::game::native_lifecycle::WorldPhase) {
use crate::game::native_lifecycle::WorldPhase;
if matches!(
phase,
WorldPhase::WorldCloseFinal | WorldPhase::WorldCloseFailed
) {
crate::game::smp_glue::closed();
}
}
+6
View File
@@ -0,0 +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) {
if !crate::game::smp_invsee_glue::menu_clicked(game, uuid, window, slot, mode) {
crate::game::smp_glue::menu_clicked(game, uuid, window, slot, mode);
}
}
+6
View File
@@ -0,0 +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) {
if !crate::game::smp_invsee_glue::menu_closed(game, uuid, window) {
crate::game::smp_glue::menu_closed(game, uuid, window);
}
}
+24
View File
@@ -0,0 +1,24 @@
// A piston pushes, pulls or destroys blocks: kept out of the land others
// claimed.
fn smp_piston_move(
dimension: crate::game::LevelDimension,
piston: horizon_core::position::BlockPos,
direction: [i32; 3],
push: &[horizon_core::position::BlockPos],
destroy: &[horizon_core::position::BlockPos],
canceled: &mut bool,
) {
let dimension = crate::game::smp_glue::dimension_id(dimension);
let step = |p: &horizon_core::position::BlockPos| {
[p.x + direction[0], p.y + direction[1], p.z + direction[2]]
};
let touched: Vec<[i32; 3]> = push
.iter()
.flat_map(|p| [[p.x, p.y, p.z], step(p)])
.chain(destroy.iter().map(|p| [p.x, p.y, p.z]))
.collect();
let piston = [piston.x, piston.y, piston.z];
if !crate::game::smp_glue::world_allows(dimension, piston, &touched) {
*canceled = true;
}
}
+5
View File
@@ -0,0 +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);
}
@@ -0,0 +1,4 @@
// A player right clicks a block: kept out of the land others claimed.
fn smp_right_click_block(game: &mut crate::game::Game<'_>, pos: [i32; 3], canceled: &mut bool) {
crate::game::smp_glue::guard(game, pos, canceled);
}
File diff suppressed because it is too large. Load diff
+571
View File
@@ -0,0 +1,571 @@
// 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};
use super::smp_store;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};
/// 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 (each player may hide it).
pub sidebar: bool,
pub title: String,
/// How many of the richest players it lists (at most 7; 0: none).
pub top: usize,
/// 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,
/// The Tab list's header and footer.
pub tab: bool,
/// `{server}`, `{tps}`, `{mspt}`, `{players}` are filled in.
pub tab_header: String,
pub tab_footer: String,
pub server_name: String,
pub tab_refresh_seconds: i64,
}
impl Default for Config {
fn default() -> Self {
Self {
sidebar: true,
title: "Horizon SMP".into(),
top: 3,
footer: "play.playhorizon.dev".into(),
refresh_seconds: 60,
min_seconds: 5,
tab: true,
tab_header: "{server}".into(),
tab_footer: "TPS {tps} · {players} online".into(),
server_name: "Horizon SMP".into(),
tab_refresh_seconds: 5,
}
}
}
/// 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)]
struct Data {
/// The players who hid the sidebar, by UUID.
hidden: BTreeSet<String>,
}
/// 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 {
rank: usize,
name: String,
amount: String,
me: bool,
},
/// 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 {
pub config: Config,
path: PathBuf,
data: Data,
changed: bool,
/// Every account by balance, richest first: UUID, name, balance.
ranked: Vec<(u128, String, i64)>,
rank_of: HashMap<u128, usize>,
/// When the ranking was built, and over which economy revision.
built_at: Option<i64>,
built_revision: u64,
/// What each player on was last sent.
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>,
}
/// `1,250`, `125.4k`, `3.2M`: short enough for the sidebar's right side.
pub fn compact(n: i64) -> String {
let a = n.unsigned_abs();
let sign = if n < 0 { "-" } else { "" };
if a < 100_000 {
let digits = a.to_string();
let mut out = String::new();
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
return format!("{sign}{out}");
}
let (value, unit) = match a {
100_000..1_000_000 => (a as f64 / 1e3, "k"),
1_000_000..1_000_000_000 => (a as f64 / 1e6, "M"),
_ => (a as f64 / 1e9, "B"),
};
format!("{sign}{value:.1}{unit}")
}
impl Board {
/// `board.json` in `dir`; an error when it exists but can't be read.
pub fn open(dir: &Path, config: Config) -> Result<Self, String> {
let path = dir.join("board.json");
let data = smp_store::read_json(&path)?;
Ok(Self {
config,
path,
data,
changed: false,
ranked: Vec::new(),
rank_of: HashMap::new(),
built_at: None,
built_revision: 0,
sent: HashMap::new(),
stale: false,
personal_at: None,
tab_at: None,
tab_sent: HashSet::new(),
})
}
pub fn save(&mut self) -> Result<(), String> {
if self.changed {
smp_store::write_json(&self.path, &self.data)?;
self.changed = false;
}
Ok(())
}
pub fn hidden(&self, uuid: u128) -> bool {
self.data.hidden.contains(&smp_store::uuid_string(uuid))
}
/// Whether the ranking should be built again: the economy changed
/// (`revision`) and `min_seconds` passed, or `refresh_seconds` passed.
pub fn due(&self, now: i64, revision: u64) -> bool {
if !self.config.sidebar {
return false;
}
let Some(at) = self.built_at else {
return true;
};
let since = now - at;
(revision != self.built_revision && since >= self.config.min_seconds * 1000)
|| since >= self.config.refresh_seconds * 1000
}
/// The ranking from the accounts (UUID, name, balance, any order).
pub fn rebuild(&mut self, mut accounts: Vec<(u128, String, i64)>, now: i64, revision: u64) {
accounts.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.1.cmp(&b.1)));
self.rank_of = accounts
.iter()
.enumerate()
.map(|(i, a)| (a.0, i + 1))
.collect();
self.ranked = accounts;
self.built_at = Some(now);
self.built_revision = revision;
self.stale = true;
}
/// `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 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,
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 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) || (!all && self.sent.contains_key(&uuid)) {
continue;
}
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));
}
}
out
}
/// Whom the Tab list goes to now: everyone each `tab_refresh_seconds`,
/// and players who just joined.
pub fn tab_due(&mut self, now: i64, on: &[u128]) -> Vec<u128> {
if !self.config.tab {
return Vec::new();
}
let all = self
.tab_at
.is_none_or(|t| now - t >= self.config.tab_refresh_seconds * 1000);
if all {
self.tab_at = Some(now);
}
let to: Vec<u128> = on
.iter()
.copied()
.filter(|u| all || !self.tab_sent.contains(u))
.collect();
self.tab_sent.extend(to.iter().copied());
to
}
/// The Tab list's header and footer.
pub fn tab_lines(&self, tps: f64, mspt: f64, players: usize) -> (String, String) {
let fill = |s: &str| {
s.replace("{server}", &self.config.server_name)
.replace("{tps}", &format!("{tps:.1}"))
.replace("{mspt}", &format!("{mspt:.1}"))
.replace("{players}", &players.to_string())
};
(fill(&self.config.tab_header), fill(&self.config.tab_footer))
}
/// `/sidebar [on|off]`: the answer, and the sidebar to show (true) or
/// take away (false). None for another command.
pub fn command(&mut self, me: &Player, line: &str) -> Option<(Vec<Out>, Option<bool>)> {
let words: Vec<&str> = line.split_whitespace().collect();
if words.first() != Some(&"sidebar") {
return None;
}
if !self.config.sidebar {
return Some((
vec![Out::Error("The sidebar is off on this server.".into())],
None,
));
}
let key = smp_store::uuid_string(me.uuid);
let show = match words[1..] {
[] => self.hidden(me.uuid),
["on"] => true,
["off"] => false,
_ => {
return Some((vec![Out::Error("Usage: /sidebar [on|off]".into())], None));
}
};
if show {
self.changed |= self.data.hidden.remove(&key);
self.sent.remove(&me.uuid);
} else {
self.changed |= self.data.hidden.insert(key);
}
let reply = if show {
"The sidebar is on. /sidebar off hides it."
} else {
"The sidebar is off. /sidebar on shows it again."
};
Some((vec![Out::Reply(reply.into())], Some(show)))
}
}
pub const COMMANDS: [&str; 1] = ["sidebar"];
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
fn dir() -> PathBuf {
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("smp-board-{}-{n}", std::process::id()))
}
fn me(uuid: u128) -> Player {
Player {
uuid,
name: format!("P{uuid}"),
level: 0,
}
}
fn accounts(n: u128) -> Vec<(u128, String, i64)> {
(1..=n)
.map(|u| (u, format!("P{u}"), u as i64 * 100))
.collect()
}
#[test]
fn amounts_are_short() {
assert_eq!(compact(0), "0");
assert_eq!(compact(1_250), "1,250");
assert_eq!(compact(99_999), "99,999");
assert_eq!(compact(125_400), "125.4k");
assert_eq!(compact(3_210_000), "3.2M");
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 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 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!(
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 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 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(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(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);
}
#[test]
fn a_hidden_sidebar_stays_hidden_after_a_restart() {
let d = dir();
let mut b = Board::open(&d, Config::default()).unwrap();
b.rebuild(accounts(3), 0, 1);
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(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(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_000, &[1], me_of).len(), 1);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn the_tab_list_fills_its_placeholders_and_goes_to_newcomers_at_once() {
let d = dir();
let mut b = Board::open(&d, Config::default()).unwrap();
let (h, f) = b.tab_lines(19.96, 6.3, 42);
assert_eq!(
(h.as_str(), f.as_str()),
("Horizon SMP", "TPS 20.0 · 42 online")
);
assert_eq!(b.tab_due(0, &[1, 2]), vec![1, 2]);
assert!(b.tab_due(1_000, &[1, 2]).is_empty());
assert_eq!(b.tab_due(2_000, &[1, 2, 3]), vec![3]);
assert_eq!(b.tab_due(5_000, &[1, 2, 3]), vec![1, 2, 3]);
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();
}
}
+781
View File
@@ -0,0 +1,781 @@
// Land claims: a player claims the chunk they stand in (`/claim`), all its
// height, and only they and the players they trust (`/trust`) break blocks
// or right click blocks there (doors, chests, levers, placing, buckets on a
// block), or hit its frames, armor stands and animals. Nothing flows, burns
// or is pushed into it from land of someone else, and explosions spare it.
// The chunks a player may hold grow with the time they played here.
// Operators of level 3 pass everywhere and may `/unclaim` any chunk. Kept in
// `claims.json`, written atomically. No game types. Added to the game as
// `game::smp_claims`; also a module of this crate, for its tests.
use super::smp_economy::{Out, Player};
use super::smp_places::Place;
use super::smp_store;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
/// `config.json`'s `claims` section.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub enabled: bool,
/// Chunks a player may claim, by hours played here: `[hours, chunks]`,
/// the last line reached counts.
pub chunks: Vec<[i64; 2]>,
/// The entities only the owner and trusted players may hit in a claim.
pub protected_entities: Vec<String>,
/// Blocks around 0 0, in every dimension, where no one may claim (free
/// for all); 0 for none. Claims already there are dropped at start.
pub free_radius: i64,
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: true,
chunks: vec![[0, 4], [5, 16], [20, 36], [100, 64]],
protected_entities: PROTECTED
.iter()
.map(|&e| format!("minecraft:{e}"))
.collect(),
free_radius: 0,
}
}
}
/// Frames, stands, vehicles and the animals a player keeps: not monsters.
/// Points along a chunk's four edges, every `step` blocks, at height `y`:
/// where `/claim show` draws its border.
pub fn border(cx: i32, cz: i32, y: f64, step: usize) -> Vec<[f64; 3]> {
let (x0, z0) = (f64::from(cx * 16), f64::from(cz * 16));
let mut points = Vec::new();
for i in (0..=16).step_by(step.max(1)) {
let t = f64::from(i);
points.push([x0 + t, y, z0]);
points.push([x0 + t, y, z0 + 16.0]);
if i != 0 && i != 16 {
points.push([x0, y, z0 + t]);
points.push([x0 + 16.0, y, z0 + t]);
}
}
points
}
const PROTECTED: &[&str] = &[
"item_frame",
"glow_item_frame",
"armor_stand",
"painting",
"leash_knot",
"minecart",
"chest_minecart",
"furnace_minecart",
"hopper_minecart",
"tnt_minecart",
"oak_boat",
"spruce_boat",
"birch_boat",
"jungle_boat",
"acacia_boat",
"dark_oak_boat",
"mangrove_boat",
"cherry_boat",
"pale_oak_boat",
"bamboo_raft",
"oak_chest_boat",
"spruce_chest_boat",
"birch_chest_boat",
"jungle_chest_boat",
"acacia_chest_boat",
"dark_oak_chest_boat",
"mangrove_chest_boat",
"cherry_chest_boat",
"pale_oak_chest_boat",
"bamboo_chest_raft",
"allay",
"armadillo",
"axolotl",
"bee",
"camel",
"cat",
"chicken",
"cow",
"donkey",
"fox",
"frog",
"goat",
"happy_ghast",
"horse",
"llama",
"mooshroom",
"mule",
"ocelot",
"panda",
"parrot",
"pig",
"rabbit",
"sheep",
"sniffer",
"snow_golem",
"iron_golem",
"copper_golem",
"strider",
"turtle",
"villager",
"wolf",
"trader_llama",
"wandering_trader",
];
/// `claims.json`.
#[derive(Default, Serialize, Deserialize)]
#[serde(default)]
struct Data {
/// Claimed chunks, `<dimension> <chunk x> <chunk z>`, to their owner's UUID.
chunks: BTreeMap<String, String>,
/// The players each owner trusts, by UUID.
trust: BTreeMap<String, BTreeSet<String>>,
}
pub struct Claims {
pub config: Config,
path: PathBuf,
data: Data,
changed: bool,
/// `data.chunks` by dimension, then (chunk x, chunk z): asked on every
/// fluid flow, so a lookup allocates nothing.
owners: HashMap<String, HashMap<(i32, i32), u128>>,
/// When each player was last told a chunk isn't theirs.
told: HashMap<u128, i64>,
}
fn key(dimension: &str, cx: i32, cz: i32) -> String {
format!("{dimension} {cx} {cz}")
}
fn chunk_of(at: &Place) -> (i32, i32) {
((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4)
}
impl Claims {
/// `claims.json` in `dir`; an error when it exists but can't be read
/// (never replaced by an empty one).
pub fn open(dir: &Path, config: Config) -> Result<Self, String> {
let path = dir.join("claims.json");
let data: Data = smp_store::read_json(&path)?;
let mut owners: HashMap<String, HashMap<(i32, i32), u128>> = HashMap::new();
for (k, owner) in &data.chunks {
let mut w = k.split(' ');
let (Some(d), Some(x), Some(z), Some(u)) = (
w.next(),
w.next().and_then(|x| x.parse().ok()),
w.next().and_then(|z| z.parse().ok()),
smp_store::parse_uuid(owner),
) else {
return Err(format!("{}: a bad claim `{k}`", path.display()));
};
owners.entry(d.to_owned()).or_default().insert((x, z), u);
}
Ok(Self {
config,
path,
data,
changed: false,
owners,
told: HashMap::new(),
})
}
/// Whether a chunk touches the free zone around 0 0 (any dimension).
fn in_free_zone(&self, cx: i32, cz: i32) -> bool {
let r = self.config.free_radius;
let near = |c: i32| i64::from(c) * 16 <= r && i64::from(c) * 16 + 15 >= -r;
r > 0 && near(cx) && near(cz)
}
/// Drops the claims inside the free zone (a radius set after they were
/// made): one line each, for the log.
pub fn clear_free_zone(&mut self) -> Vec<String> {
let inside: Vec<(String, i32, i32, u128)> = self
.owners
.iter()
.flat_map(|(d, c)| {
c.iter()
.filter(|((x, z), _)| self.in_free_zone(*x, *z))
.map(|((x, z), u)| (d.clone(), *x, *z, *u))
})
.collect();
let mut lines = Vec::new();
for (dimension, cx, cz, owner) in inside {
if let Some(c) = self.owners.get_mut(&dimension) {
c.remove(&(cx, cz));
}
self.data.chunks.remove(&key(&dimension, cx, cz));
self.changed = true;
lines.push(format!(
"chunk {cx} {cz} (x {}, z {}) in {dimension} of {} is in the free zone: unclaimed",
cx * 16,
cz * 16,
smp_store::uuid_string(owner)
));
}
lines.sort();
lines
}
pub fn save(&mut self) -> Result<(), String> {
if self.changed {
smp_store::write_json(&self.path, &self.data)?;
self.changed = false;
}
Ok(())
}
/// Who holds the chunk of this block, if anyone.
pub fn owner(&self, dimension: &str, x: i32, z: i32) -> Option<u128> {
self.chunk_owner(dimension, x >> 4, z >> 4)
}
fn chunk_owner(&self, dimension: &str, cx: i32, cz: i32) -> Option<u128> {
self.owners.get(dimension)?.get(&(cx, cz)).copied()
}
/// Whether the world may change the block at `to` from `from` (a fluid,
/// fire, a piston): yes, unless `to` is claimed by someone other than
/// `from`'s owner or a player they trust.
pub fn spread_allowed(&self, dimension: &str, from: [i32; 3], to: [i32; 3]) -> bool {
if !self.config.enabled {
return true;
}
let Some(owner) = self.owner(dimension, to[0], to[2]) else {
return true;
};
match self.owner(dimension, from[0], from[2]) {
Some(source) => source == owner || self.trusts(owner, source),
None => false,
}
}
/// Takes out of an explosion's blocks those in a claim: explosions
/// spare claimed land, whoever lit them.
pub fn spare(&self, dimension: &str, blocks: &mut Vec<[i32; 3]>) {
if self.config.enabled && self.owners.get(dimension).is_some_and(|c| !c.is_empty()) {
blocks.retain(|b| self.owner(dimension, b[0], b[2]).is_none());
}
}
/// Whose land a block is to `who`: None when free, true when theirs or
/// trusted to them, false when someone else's.
pub fn relation(&self, dimension: &str, x: i32, z: i32, who: u128) -> Option<bool> {
let owner = self.owner(dimension, x, z)?;
Some(owner == who || self.trusts(owner, who))
}
/// Whether entities of type `kind` (`minecraft:cow`) are kept from
/// players a claim's owner doesn't trust.
pub fn protects(&self, kind: &str) -> bool {
self.config.protected_entities.iter().any(|e| e == kind)
}
fn trusts(&self, owner: u128, who: u128) -> bool {
self.data
.trust
.get(&smp_store::uuid_string(owner))
.is_some_and(|t| t.contains(&smp_store::uuid_string(who)))
}
/// Whether `who` may break or use the block at `pos`. When not, the line
/// to tell them: at most one each 2 s, so holding a click doesn't flood
/// their chat.
pub fn check(
&mut self,
who: &Player,
dimension: &str,
pos: [i32; 3],
name_of: &dyn Fn(u128) -> String,
now: i64,
) -> Result<(), Option<String>> {
if !self.config.enabled || who.level >= 3 {
return Ok(());
}
let Some(owner) = self.owner(dimension, pos[0], pos[2]) else {
return Ok(());
};
if owner == who.uuid || self.trusts(owner, who.uuid) {
return Ok(());
}
let last = self.told.insert(who.uuid, now);
if last.is_some_and(|t| now - t < 2_000) {
return Err(None);
}
Err(Some(format!("This land is claimed by {}.", name_of(owner))))
}
/// How many chunks `uuid` holds.
pub fn held(&self, uuid: u128) -> usize {
self.owners
.values()
.flat_map(|c| c.values())
.filter(|&&u| u == uuid)
.count()
}
/// How many chunks a player may hold after `played` seconds here.
pub fn limit(&self, played: i64) -> usize {
self.config
.chunks
.iter()
.filter(|[h, _]| played / 3600 >= *h)
.map(|[_, n]| (*n).max(0) as usize)
.next_back()
.unwrap_or(0)
}
/// The claims' commands; None for another command. `at` is where the
/// player stands; `find` a player's UUID and name from a name (on or
/// known); `name_of` a name from a UUID.
pub fn command(
&mut self,
me: &Player,
at: &Place,
played: i64,
find: &dyn Fn(&str) -> Option<(u128, String)>,
name_of: &dyn Fn(u128) -> String,
line: &str,
) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
let command = *words.first()?;
if !COMMANDS.contains(&command) {
return None;
}
if !self.config.enabled {
return Some(vec![Out::Error("Claims are off on this server.".into())]);
}
let (cx, cz) = chunk_of(at);
let here = at.dimension.as_str();
let blocks = format!(
"x {} to {}, z {} to {}",
cx * 16,
cx * 16 + 15,
cz * 16,
cz * 16 + 15
);
let me_key = smp_store::uuid_string(me.uuid);
Some(match (command, &words[1..]) {
("claim", []) => match self.chunk_owner(here, cx, cz) {
Some(u) if u == me.uuid => vec![Out::Error("This chunk is already yours.".into())],
Some(u) => vec![Out::Error(format!("This chunk is {}'s.", name_of(u)))],
None if self.in_free_zone(cx, cz) => vec![Out::Error(format!(
"No claims within {} blocks of 0 0: that land is free for all.",
self.config.free_radius
))],
None => {
let (held, limit) = (self.held(me.uuid), self.limit(played));
if held >= limit {
vec![Out::Error(format!(
"You hold {held} of {limit} chunks: play longer for more, or /unclaim one."
))]
} else {
self.data.chunks.insert(key(here, cx, cz), me_key);
let chunks = self.owners.entry(here.to_owned()).or_default();
chunks.insert((cx, cz), me.uuid);
self.changed = true;
vec![Out::Reply(format!(
"Claimed this chunk ({blocks}), {} of {limit}. /trust <player> lets a friend build.",
held + 1
))]
}
}
},
("claim", ["info"]) => vec![Out::Reply(match self.chunk_owner(here, cx, cz) {
Some(u) => format!("This chunk ({blocks}) is {}'s.", name_of(u)),
None => format!("This chunk ({blocks}) is free: /claim it."),
})],
("unclaim", []) => match self.chunk_owner(here, cx, cz) {
Some(u) if u == me.uuid || me.level >= 3 => {
if let Some(chunks) = self.owners.get_mut(here) {
chunks.remove(&(cx, cz));
}
self.data.chunks.remove(&key(here, cx, cz));
self.changed = true;
vec![Out::Reply(format!("Unclaimed this chunk ({blocks})."))]
}
Some(u) => vec![Out::Error(format!("This chunk is {}'s.", name_of(u)))],
None => vec![Out::Error("This chunk isn't claimed.".into())],
},
("claims", []) => {
let mine: Vec<String> = self
.data
.chunks
.iter()
.filter(|(_, o)| **o == me_key)
.map(|(k, _)| {
let w: Vec<&str> = k.split(' ').collect();
let n = |s: &str| s.parse::<i32>().unwrap_or(0) * 16;
format!(
"{} at x {} z {}",
w[0].trim_start_matches("minecraft:"),
n(w[1]),
n(w[2])
)
})
.collect();
let trusted: Vec<String> = self
.data
.trust
.get(&me_key)
.into_iter()
.flatten()
.filter_map(|u| smp_store::parse_uuid(u))
.map(name_of)
.collect();
vec![
Out::Reply(format!(
"Your chunks ({} of {}): {}",
mine.len(),
self.limit(played),
if mine.is_empty() {
"none, /claim where you stand".into()
} else {
mine.join("; ")
}
)),
Out::Reply(format!(
"Trusted: {}",
if trusted.is_empty() {
"nobody".into()
} else {
trusted.join(", ")
}
)),
]
}
("trust", [name]) | ("untrust", [name]) => match find(name) {
None => vec![Out::Error(format!(
"No player named {name} has played here."
))],
Some((u, _)) if u == me.uuid => vec![Out::Error("That's you.".into())],
Some((u, name)) => {
let set = self.data.trust.entry(me_key.clone()).or_default();
let changed = if command == "trust" {
set.insert(smp_store::uuid_string(u))
} else {
set.remove(&smp_store::uuid_string(u))
};
if set.is_empty() {
self.data.trust.remove(&me_key);
}
self.changed |= changed;
vec![Out::Reply(match (command, changed) {
("trust", true) => {
format!("{name} may now build and open things on your land.")
}
("trust", false) => format!("You already trust {name}."),
(_, true) => format!("{name} no longer may build on your land."),
_ => format!("You didn't trust {name}."),
})]
}
},
_ => vec![Out::Error(
"Usage: /claim, /claim info, /claim show, /unclaim, /claims, /trust <player>, /untrust <player>"
.into(),
)],
})
}
}
/// The claims' commands.
pub const COMMANDS: [&str; 5] = ["claim", "unclaim", "claims", "trust", "untrust"];
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
const A: u128 = 1;
const B: u128 = 2;
fn dir() -> PathBuf {
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("smp-claims-{}-{n}", std::process::id()))
}
fn at(x: f64, z: f64) -> Place {
Place {
dimension: "minecraft:overworld".into(),
x,
y: 64.0,
z,
}
}
fn player(uuid: u128, level: u8) -> Player {
Player {
uuid,
name: if uuid == A { "Alex" } else { "Steve" }.into(),
level,
}
}
fn find(n: &str) -> Option<(u128, String)> {
match n.to_ascii_lowercase().as_str() {
"alex" => Some((A, "Alex".into())),
"steve" => Some((B, "Steve".into())),
_ => None,
}
}
fn name_of(u: u128) -> String {
if u == A { "Alex" } else { "Steve" }.into()
}
#[test]
fn a_border_runs_along_the_chunks_edges() {
let points = border(1, -1, 70.0, 2);
// 9 points on each edge, the corners once.
assert_eq!(points.len(), 32);
assert!(points.contains(&[16.0, 70.0, -16.0]) && points.contains(&[32.0, 70.0, 0.0]));
assert!(
points
.iter()
.all(|p| p[0] == 16.0 || p[0] == 32.0 || p[2] == -16.0 || p[2] == 0.0)
);
let d = dir();
let mut c = Claims::open(&d, Config::default()).unwrap();
c.command(&player(A, 0), &at(8.0, 8.0), 0, &find, &name_of, "claim")
.unwrap();
let ow = "minecraft:overworld";
assert_eq!(c.relation(ow, 3, 3, A), Some(true));
assert_eq!(c.relation(ow, 3, 3, B), Some(false));
assert_eq!(c.relation(ow, 20, 3, B), None);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn no_claims_in_the_free_zone_and_old_ones_are_dropped() {
let d = dir();
let mut c = Claims::open(&d, Config::default()).unwrap();
let alex = player(A, 0);
for (x, z) in [(8.0, 8.0), (-520.0, 8.0), (600.0, 600.0)] {
c.command(&alex, &at(x, z), 1_000_000, &find, &name_of, "claim")
.unwrap();
}
c.save().unwrap();
let config = Config {
free_radius: 500,
..Config::default()
};
let mut c = Claims::open(&d, config).unwrap();
// Chunk 0 0 and chunk -33 0 (x -528 to -513: outside 500) stay apart.
assert_eq!(c.clear_free_zone().len(), 1);
let ow = "minecraft:overworld";
assert_eq!(c.owner(ow, 8, 8), None);
assert_eq!(c.owner(ow, -520, 8), Some(A));
assert_eq!(c.owner(ow, 600, 600), Some(A));
let out = c
.command(
&alex,
&at(490.0, -490.0),
1_000_000,
&find,
&name_of,
"claim",
)
.unwrap();
assert!(matches!(&out[0], Out::Error(e) if e.contains("free for all")));
// The zone is the same in every dimension.
let nether = Place {
dimension: "minecraft:the_nether".into(),
..at(8.0, 8.0)
};
let out = c
.command(&alex, &nether, 1_000_000, &find, &name_of, "claim")
.unwrap();
assert!(matches!(&out[0], Out::Error(e) if e.contains("free for all")));
// Saved without them.
c.save().unwrap();
let again = Claims::open(&d, Config::default()).unwrap();
assert_eq!(again.owner(ow, 8, 8), None);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn the_world_stays_out_of_a_claim() {
let d = dir();
let mut c = Claims::open(&d, Config::default()).unwrap();
c.command(&player(A, 0), &at(8.0, 8.0), 0, &find, &name_of, "claim")
.unwrap();
let ow = "minecraft:overworld";
// Into Alex's chunk from outside: refused; inside it and out of it: allowed.
assert!(!c.spread_allowed(ow, [16, 64, 8], [15, 64, 8]));
assert!(c.spread_allowed(ow, [14, 64, 8], [15, 64, 8]));
assert!(c.spread_allowed(ow, [15, 64, 8], [16, 64, 8]));
assert!(c.spread_allowed("minecraft:the_nether", [16, 64, 8], [15, 64, 8]));
// From Steve's chunk next door: allowed once Alex trusts Steve.
c.command(&player(B, 0), &at(24.0, 8.0), 0, &find, &name_of, "claim")
.unwrap();
assert!(!c.spread_allowed(ow, [16, 64, 8], [15, 64, 8]));
c.command(
&player(A, 0),
&at(8.0, 8.0),
0,
&find,
&name_of,
"trust steve",
)
.unwrap();
assert!(c.spread_allowed(ow, [16, 64, 8], [15, 64, 8]));
assert!(!c.spread_allowed(ow, [15, 64, 8], [16, 64, 8]));
// Explosions spare both claims, not the land around them.
let mut blocks = vec![[0, 64, 0], [20, 64, 0], [40, 64, 0], [-1, 64, 0]];
c.spare(ow, &mut blocks);
assert_eq!(blocks, vec![[40, 64, 0], [-1, 64, 0]]);
assert!(c.protects("minecraft:item_frame") && c.protects("minecraft:cow"));
assert!(!c.protects("minecraft:zombie") && !c.protects("minecraft:player"));
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn a_claim_keeps_others_out_until_trusted() {
let d = dir();
let mut c = Claims::open(&d, Config::default()).unwrap();
let out = c
.command(&player(A, 0), &at(-3.5, 20.0), 0, &find, &name_of, "claim")
.unwrap();
assert!(
matches!(&out[0], Out::Reply(s) if s.contains("x -16 to -1, z 16 to 31")),
"{out:?}"
);
let ow = "minecraft:overworld";
assert_eq!(
c.check(&player(A, 0), ow, [-16, 0, 31], &name_of, 0),
Ok(())
);
assert_eq!(
c.check(&player(B, 0), ow, [-1, 100, 16], &name_of, 0),
Err(Some("This land is claimed by Alex.".into()))
);
// Told once each 2 s; the next chunk and the Nether are free.
assert_eq!(
c.check(&player(B, 0), ow, [-1, 100, 16], &name_of, 1_000),
Err(None)
);
assert_eq!(c.check(&player(B, 0), ow, [0, 64, 16], &name_of, 0), Ok(()));
assert_eq!(
c.check(
&player(B, 0),
"minecraft:the_nether",
[-1, 64, 16],
&name_of,
0
),
Ok(())
);
// Operators pass.
assert_eq!(
c.check(&player(B, 3), ow, [-1, 64, 16], &name_of, 0),
Ok(())
);
c.command(
&player(A, 0),
&at(0.0, 0.0),
0,
&find,
&name_of,
"trust steve",
)
.unwrap();
assert_eq!(
c.check(&player(B, 0), ow, [-1, 64, 16], &name_of, 9_000),
Ok(())
);
let out = c
.command(
&player(B, 0),
&at(-3.5, 20.0),
0,
&find,
&name_of,
"unclaim",
)
.unwrap();
assert!(
matches!(&out[0], Out::Error(s) if s.contains("Alex's")),
"trust isn't ownership"
);
c.save().unwrap();
let mut again = Claims::open(&d, Config::default()).unwrap();
assert_eq!(again.owner(ow, -1, 16), Some(A));
again
.command(
&player(A, 0),
&at(0.0, 0.0),
0,
&find,
&name_of,
"untrust Steve",
)
.unwrap();
assert!(
again
.check(&player(B, 0), ow, [-1, 64, 16], &name_of, 20_000)
.is_err()
);
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn chunks_grow_with_time_played() {
let d = dir();
let mut c = Claims::open(&d, Config::default()).unwrap();
for x in 0..4 {
let out = c
.command(
&player(A, 0),
&at(x as f64 * 16.0, 0.0),
0,
&find,
&name_of,
"claim",
)
.unwrap();
assert!(matches!(&out[0], Out::Reply(_)), "{out:?}");
}
let out = c
.command(&player(A, 0), &at(64.0, 0.0), 0, &find, &name_of, "claim")
.unwrap();
assert!(
matches!(&out[0], Out::Error(s) if s.contains("4 of 4")),
"{out:?}"
);
let out = c
.command(
&player(A, 0),
&at(64.0, 0.0),
5 * 3600,
&find,
&name_of,
"claim",
)
.unwrap();
assert!(
matches!(&out[0], Out::Reply(s) if s.contains("5 of 16")),
"{out:?}"
);
let out = c
.command(&player(B, 0), &at(0.0, 0.0), 0, &find, &name_of, "claim")
.unwrap();
assert_eq!(out, vec![Out::Error("This chunk is Alex's.".into())]);
// An operator may free any chunk.
let out = c
.command(&player(B, 3), &at(0.0, 0.0), 0, &find, &name_of, "unclaim")
.unwrap();
assert!(matches!(&out[0], Out::Reply(_)));
let out = c
.command(&player(A, 0), &at(0.0, 0.0), 0, &find, &name_of, "claims")
.unwrap();
assert!(
matches!(&out[0], Out::Reply(s) if s.starts_with("Your chunks (4 of 4)")),
"{out:?}"
);
let _ = std::fs::remove_dir_all(d);
}
}
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more