Compare commits

...
Author SHA1 Message Date
Ludovic MarieandCayMac97 871f22d21a Add low_fire 1.0.0
Submitted by @CayMac97 through the form.

Submission: 81IEs062isOeDeg-qeCI3U0-EMPIXNkqBVB5K0Ary08
Co-authored-by: CayMac97 <[email protected]>
2026-10-31 21:54:48 +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
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 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
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
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 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
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
levzzz 678eb2d1af Merge pull request #40 from SU4G3/classic-zoom-native
classic_zoom 2.0.0: a native mod
2026-10-29 23:59:32 +05:00
levzzz 55e203b1cd Merge pull request #31 from d3grando/lookat-native
LookAt 0.2.1: turn native
2026-10-29 23:59:24 +05:00
levzzz e9f14ea77f Merge pull request #41 from d3grando/mod-icons
Icons for TreeFall, SmartF3, Item New, BiomeCompass, Enchantment Hints, Mouse Tricks and Carry Block
2026-10-29 23:45:09 +05:00
d3grandoandClaude Opus 5.5 190d8f9a60 Icons for d3grando's native mods, served from this repository
The entries pointed at raw.githubusercontent.com, which playhorizon.dev's
img-src and the launcher don't load. The same 256x256 PNGs now live in
icons/, as coords_hud's does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:51:23 +07:00
SU4G3andClaude Opus 5.5 bbe01b52ad classic_zoom 2.0.0: a native mod
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:04:39 +03:00
Ludovic Marie 0d8411b352 Merge pull request #39 from horizon-rust/native-coords-hud
Coordinates HUD turns native (2.0.0), on the hook points
2026-10-29 14:32:02 +01:00
Ludovic MarieandClaude Opus 5.5 890d330600 Coordinates HUD: drop a stray Python cache file
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:08:25 +01:00
Ludovic MarieandClaude Opus 5.5 7fd746624a Coordinates HUD turns native (2.0.0), on the hook points
The WebAssembly SDK's example mod, ported to a horizon-mixins bundle in
native/coords-hud: the position and facing box in the top left, /where and
/coords, and a Shown setting that /coords now keeps. The time of day line is
gone: the hook points don't give the game's clock yet. Tested on Nightly
4baa571, alone and with the five other native mods; the proof drives the
built game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:08:17 +01:00
Ludovic MarieandClaude Opus 5.5 7ce014f8a9 Recipes turns native (2.0.0), on hook points version 2 (#38)
Pinned to Nightly 4baa571, the first with horizon#3435's hook points v2.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 13:26:55 +01:00
d3grandoandClaude Opus 5.5 f6dade0df2 LookAt 0.2.1: turn native
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:18:27 +07:00
95 changed files with 10380 additions and 83 deletions

No files matched your search

+1 -1
View File
@@ -65,7 +65,7 @@ why.
| `.github/workflows/publish.yml` | On merge: releases, then `index.json` on Pages |
| `.github/workflows/cleanup.yml` | Weekly: staging files nobody needs any more |
| `.github/workflows/native.yml` | Horizon's native mods: each changed one's tests, `.hnmod` and game check, all of them together, and weekly on the newest Nightly |
| `native/<name>/` | Horizon's own native mods (Zoom, FPS Counter, Keystrokes, Minimap, Recipes): the source of their `.hnmod` files |
| `native/<name>/` | Horizon's own native mods (Zoom, FPS Counter, Keystrokes, Minimap, Recipes, Coordinates HUD): the source of their `.hnmod` files |
| `native/tools/` | Shared by them: apply on Horizon, check, build, package, prove (`native/tools/native.py`) |
| `scripts/` | What the workflows share |
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 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: 975 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 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: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 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."
+1 -1
View File
@@ -16,7 +16,7 @@ authors = ["d3grando"]
owners = ["d3grando"]
license = "MIT"
source = "https://github.com/d3grando/biomecompass"
icon = "https://raw.githubusercontent.com/d3grando/biomecompass/main/icon.png"
icon = "icons/biomecompass.png"
[[versions]]
version = "0.2.2"
+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
@@ -17,7 +17,7 @@ authors = ["d3grando"]
owners = ["d3grando"]
license = "MIT"
source = "https://github.com/d3grando/carryblock"
icon = "https://raw.githubusercontent.com/d3grando/carryblock/main/icon.png"
icon = "icons/carryblock.png"
[[versions]]
version = "0.1.2"
+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."
+17 -2
View File
@@ -1,5 +1,5 @@
id = "classic_zoom"
kind = "mod"
kind = "native-mod"
name = "Classic Zoom"
summary = "Hold V to zoom 3x, scroll for more, release to snap back. Instant, no settings, inspired by WI Zoom."
description = """
@@ -32,14 +32,29 @@ 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.
Source and issues: [github.com/SU4G3/classic-zoom](https://github.com/SU4G3/classic-zoom)"""
authors = ["Surge"]
owners = ["SU4G3"]
license = "GPL-3.0-or-later"
source = "https://github.com/SU4G3/classic-zoom"
icon = "https://github.com/SU4G3/classic-zoom/blob/main/.github/icon.png?raw=true"
icon = "icons/classic_zoom.png"
key = "RWTOxMcIG6MtC0QQnhWEMC+nLKJ6+u4trV/rF+4juSRuow8W4b5P+CPS"
[[versions]]
version = "2.0.0"
published = "2026-10-29"
game = "26.3"
file = "classic_zoom-2.0.0.hnmod"
sha256 = "a8bdeba615ed31b9bd10280fcf8ba04a31935bc65fff429fc0e83d1c8090dac9"
signature = """
untrusted comment: signature from minisign secret key
RUTOxMcIG6MtCyXVhFrgrbWwg3pEBeR8iQ6g3w8MlWbzunH02kAF3swud3hq59EQvuoNpQxj7eTNRy6IJ4l+++7H1EazgGLV3AY=
trusted comment: timestamp:1793286240\tfile:classic_zoom-2.0.0.hnmod\thashed
bbYFmm0wap7Mgnt+wxSMkC/FqklbuAZage02x4Kt5Mi2mzknFbhWij/6sOx5ojRAquDT1XLRpRd2UrrAy4x4Bg=="""
changelog = "Now a native mod (WebAssembly code mods are going away). Same zoom: V by default, 3x, x1.1 per wheel step within 1x-10x, instant, slower turning while zoomed; a key set in 1.0.0 carries over. Pinned to Horizon 6f8c7a0 and Horizon Mixins 0d6b103."
[[versions]]
version = "1.0.0"
published = "2026-10-29"
+20 -7
View File
@@ -1,22 +1,35 @@
id = "coords_hud"
kind = "mod"
kind = "native-mod"
name = "Coordinates HUD"
summary = "Your coordinates, facing and the time of day in a corner of the screen."
summary = "Your coordinates and facing in a corner of the screen."
description = """
The SDK's example mod. It draws your block position, the direction you face and the time of day
in the top-left corner, every frame.
Your block position and the direction you face in the top-left corner of the screen.
- `/where` puts your coordinates and dimension in chat.
- `/coords` hides or shows the panel.
- `/coords` hides or shows the box, and the game keeps your choice (also Options > Mods > Coordinates HUD > Settings).
It reads the game (position, health, time), draws on the HUD and writes to chat: nothing else.
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
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).
"""
authors = ["Horizon"]
owners = ["ludovic111"]
license = "MIT"
source = "https://github.com/horizon-rust/horizon/tree/main/crates/horizon-mod-sdk/examples/coords-hud"
source = "https://github.com/horizon-rust/horizon-mods/tree/main/native/coords-hud"
icon = "icons/coords_hud.png"
[[versions]]
version = "2.0.0"
published = "2026-10-29"
game = "26.3"
file = "coords_hud-2.0.0.hnmod"
sha256 = "432376fad3062b3517e274d3b8c0d0b52fc5d6de2aee20a0b340c8cab5a8cf08"
changelog = "Native: built into the game by the launcher on Horizon's native hook points; /coords is kept; turn it off in Options > Mods. The time of day line is gone for now."
[[versions]]
version = "0.1.0"
published = "2026-10-24"
+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: enable Settings → Game → Native mods (experimental), choose Build and confirm the native-code warning. The launcher downloads its own Rust and builds a separate game (10–30 minutes the first time). It runs with your account's permissions.
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
@@ -15,7 +15,7 @@ authors = ["d3grando"]
owners = ["d3grando"]
license = "MIT"
source = "https://github.com/d3grando/enchantmenthints"
icon = "https://raw.githubusercontent.com/d3grando/enchantmenthints/main/icon.png"
icon = "icons/enchantmenthints.png"
[[versions]]
version = "0.1.0"
+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"
+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 (Settings → Game → Native mods, then Build). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Uses only Horizon's native hook points (hooks 1); tested on Horizon 41672b1 and horizon-mixins 0d6b103.
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."
+1 -1
View File
@@ -15,7 +15,7 @@ authors = ["d3grando"]
owners = ["d3grando"]
license = "MIT"
source = "https://github.com/d3grando/itemnew"
icon = "https://raw.githubusercontent.com/d3grando/itemnew/main/icon.png"
icon = "icons/itemnew.png"
[[versions]]
version = "0.1.1"
+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."""
+17 -13
View File
@@ -1,26 +1,30 @@
id = "lookat"
kind = "mod"
kind = "native-mod"
name = "LookAt"
summary = "The block under your crosshair as a vanilla tooltip: its icon, its name in your language and where it comes from. Reach up to 1024 blocks"
summary = "The block or mob under your crosshair as a vanilla tooltip: icon, name in your language, health, the tool it needs and where it comes from"
description = """
The block under your crosshair at the top of the screen, drawn as a vanilla tooltip: its item's icon, its name in your language and the namespace it comes from.
The block or entity under your crosshair at the top of the screen, drawn as a vanilla tooltip: its item's icon, its name in your language and the namespace it comes from.
**Settings** (Options → Mods → LookAt):
- **Blocks**: up to 1024 blocks away, across distant terrain.
- **Mobs and players**: name and health as hearts or numbers; boats, minecarts, armor stands, paintings and item frames (with what they hold).
- **Harvest tool**: ✔ or ✘ with the tool a block needs.
- **Range**: Reach (5 blocks), 16, 64 or 1024 blocks, across distant terrain
- **Position**: Top, Top left, Top right
- **Background opacity**: 100%, 75%, 50%, 25%
- **Item icon**, **Where it comes from**: on or off
- **Position and distance**: the block's coordinates and how far it is
**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.
To make the box bigger, raise the game's GUI Scale.
**Blocks only for now**: the mod API can't see entities yet, so mobs show nothing."""
**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."""
authors = ["d3grando"]
owners = ["d3grando"]
license = "MIT"
source = "https://github.com/d3grando/lookat"
icon = "https://raw.githubusercontent.com/d3grando/lookat/main/icon.png"
icon = "icons/lookat.png"
[[versions]]
version = "0.2.1"
published = "2026-10-29"
game = "26.3"
file = "lookat-0.2.1.hnmod"
sha256 = "6c15726116825b5955984223d0e1bccd5d18375005af243a061b2ba2b9139f33"
changelog = "Now a native mod (WebAssembly code mods are going away). New: mobs, players and other entities under the crosshair with their health, the harvest tool a block needs, a size setting, and boxes that keep clear of F3, boss bars, the minimap and effect icons. Pinned to Horizon 142388e and Horizon Mixins 0d6b103."
[[versions]]
version = "0.1.0"
+37
View File
@@ -0,0 +1,37 @@
id = "low_fire"
kind = "native-mod"
name = "Low Fire"
summary = "Lowers or hides the first-person burning fire overlay for crystal clear vision in PvP and survival."
description = """
Low Fire
A native Horizon mod by CayMac that ports the essential Low Fire PvP & survival feature to Horizon:
Crystal Clear Visibility: Lowers the intrusive first-person burning fire overlay down to the bottom edge of the screen so your crosshair and view are never obstructed in combat.
Customizable Height:
Low (22%): Standard competitive PvP low fire.
Very Low (14%): Sleek, minimal flame bar.
Minimal (8%): Discreet bottom-border burning indicator.
No Fire (0%): Completely hides the first-person fire overlay.
Vanilla (100%): Normal vanilla fire rendering.
Adjustable Opacity: Choose between 100%, 75%, and 50% transparency.
100% Damage & Server Accurate: Fire ticks and burning damage logic remain completely unaltered.
In-Game Toggle: Toggleable on the fly with a keybind or in Options > Mods > Low Fire."""
authors = ["CayMac"]
owners = ["CayMac97"]
license = "MIT"
source = "https://github.com/CayMac97/low-fire"
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
[[versions]]
version = "1.0.0"
published = "2026-10-31"
game = "26.3"
file = "low_fire-1.0.0.hnmod"
sha256 = "1e5ef66fa87280aef9a33513361c49ceee02851e088b50a0f7ec593111a67945"
signature = """
untrusted comment: signature from minisign secret key\r
RUS411U2KXdTNxo/S3UBZ2rWGje1JZL6zSmh81Gx5hRVx8wT3znLTpJ0bxvxXgx5KwwHxFCfpAjU1FYYRdOIiZwYeV6t8gQlyQE=\r
trusted comment: timestamp:1793479715\tfile:low_fire-1.0.0.hnmod\thashed\r
a6xiGNXbohzBQHIf5Yg/yPQFT4n2be4IsBSLnM5rz8yZ0Y49NGpXm0pFLbI1MqG1eI2J1krlygD2ipPg8yzVDQ=="""
changelog = "Initial release for 26.3 Horizon. "
-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."
+1 -1
View File
@@ -15,7 +15,7 @@ authors = ["d3grando"]
owners = ["d3grando"]
license = "MIT"
source = "https://github.com/d3grando/mousetricks"
icon = "https://raw.githubusercontent.com/d3grando/mousetricks/main/icon.png"
icon = "icons/mousetricks.png"
[[versions]]
version = "0.2.0"
+16 -2
View File
@@ -1,5 +1,5 @@
id = "recipes"
kind = "mod"
kind = "native-mod"
name = "Recipes"
summary = "Every item beside your inventory, with its recipes and uses."
description = """
@@ -13,11 +13,25 @@ Horizon's own recipe viewer.
- On servers that are not Horizon, it shows this game's own 26.3 recipes. Which recipes you know, and what + can fill, follow the server's recipe book.
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
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).
"""
authors = ["Horizon"]
owners = ["ludovic111"]
license = "MIT"
source = "https://github.com/horizon-rust/horizon/tree/main/mods/official/recipes"
source = "https://github.com/horizon-rust/horizon-mods/tree/main/native/recipes"
[[versions]]
version = "2.0.0"
published = "2026-10-29"
game = "26.3"
file = "recipes-2.0.0.hnmod"
sha256 = "51fddf300709404170061355afdf0b407f28eb928bcc0a9133c241cd63f4a0ed"
changelog = "Native: built into the game by the launcher on Horizon's native hook points (version 2); settings and keys carry over; turn it off in Options > Mods."
[[versions]]
version = "1.0.0"
+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 Settings > Native mods (experimental) > Build, and play the Native mods 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
@@ -16,7 +16,7 @@ authors = ["d3grando"]
owners = ["d3grando"]
license = "MIT"
source = "https://github.com/d3grando/smartf3"
icon = "https://raw.githubusercontent.com/d3grando/smartf3/main/icon.png"
icon = "icons/smartf3.png"
[[versions]]
version = "0.1.0"
+1 -1
View File
@@ -16,7 +16,7 @@ authors = ["d3grando"]
owners = ["d3grando"]
license = "MIT"
source = "https://github.com/d3grando/treefall"
icon = "https://raw.githubusercontent.com/d3grando/treefall/main/icon.png"
icon = "icons/treefall.png"
[[versions]]
version = "0.3.0"
+12 -5
View File
@@ -12,11 +12,10 @@ and confirm the native-code warning. A supported launcher downloads its own Rust
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."
+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"
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "horizon-mod-coords-hud"
version = "2.0.0"
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "horizon-mod-coords-hud"
version = "2.0.0"
edition = "2024"
license = "MIT"
publish = false
description = "Horizon's Coordinates HUD, a native mod: your position and facing in a corner of the screen."
# The mod's logic and its tests: the same file the bundle adds to the game.
[lib]
path = "mixins/coords_hud/runtime.rs"
+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.
+46
View File
@@ -0,0 +1,46 @@
# Coordinates HUD (Horizon native mod)
Your block position and the direction you face in a box in the top-left corner of the screen.
- `/where` puts your coordinates and dimension in chat.
- `/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.
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
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).
## Made on the hook points
The bundle hooks only Horizon's native hook points (`crates/horizon-client/src/game/native_hooks.rs`), which keep
their names and signatures across releases. `compatibility.json` names the version it needs (`"hooks": 1`), so the
launcher builds it on any Horizon with that version or a later one, together with any other mod. `horizon_revision` is
only the commit it was last tested on.
| File | What it is |
|---|---|
| `mixins/coords_hud/horizon-mixin.toml` | The bundle: one module and the HEAD hooks |
| `mixins/coords_hud/runtime.rs` | The mod's logic and state, added to the game as `game::official_coords_hud`; also this crate's library, for its tests |
| `mixins/coords_hud/*.rs` | One small helper per hook |
| `proof.py` (with `../tools/game.py`) | The proof: the built game driven with `horizon-ctl --offscreen` on fresh, temporary data |
## Build and prove
```sh
(cd native/coords-hud && cargo test) # the logic
python3 native/tools/native.py check coords-hud # apply on the tested Horizon and cargo check the game
python3 native/tools/native.py check all # with the other native mods, as a player's set
python3 native/tools/native.py package coords-hud # native/target/coords_hud-<version>.hnmod for the registry
python3 native/tools/native.py proof coords-hud --resources /path/to/.local/horizon-client/26.3
```
The proof needs resources prepared from your own Minecraft 26.3 (`horizon-prepare`); none are in this repository.
It checks the row and the settings page in Options > Mods, that the box shows in the top left, that `/coords` hides it
and writes `horizon-mods/.horizon-mod-data/coords_hud/shown`, and takes a screenshot of `/where`'s line in chat.
License: MIT.
+8
View File
@@ -0,0 +1,8 @@
{
"game": "26.3",
"hooks": 1,
"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"
}
@@ -0,0 +1,10 @@
// A click on the mod's settings page: show or hide the box, and keep it.
fn official_coords_hud_click(game: &mut crate::game::Game<'_>, mod_id: &str, button: usize) {
use crate::game::official_coords_hud as m;
if mod_id != m::ID || button != 0 {
return;
}
let shown = !m::shown(|| game.native_setting(m::ID, "shown"));
m::set_shown(shown);
game.native_set_setting(m::ID, "shown", if shown { "true" } else { "false" });
}
@@ -0,0 +1,20 @@
// /where puts the position in chat; /coords hides or shows the box.
fn coords_hud_command(game: &mut crate::game::Game<'_>, command: &str, ran: &mut bool) {
use crate::game::official_coords_hud as m;
let name = command.split(' ').next().unwrap_or("");
if !game.native_on(m::ID) || !matches!(name, "where" | "coords") {
return;
}
*ran = true;
if name == "coords" {
let shown = !m::shown(|| game.native_setting(m::ID, "shown"));
m::set_shown(shown);
game.native_set_setting(m::ID, "shown", if shown { "true" } else { "false" });
game.native_chat(if shown { "Coordinates panel shown" } else { "Coordinates panel hidden" });
return;
}
match game.native_player() {
Some(p) => game.native_chat(&m::where_line(p.x, p.y, p.z, p.dimension)),
None => game.native_chat("Not in a world"),
}
}
@@ -0,0 +1,68 @@
[mod]
id = "coords_hud"
version = "2.0.0"
priority = 100
# The commit it was tested on. It uses only the native hook points (hooks 1 in
# compatibility.json): the launcher builds it on any Horizon that has them.
[compatibility]
horizon_revision = "4baa571566e39bfd6594988d3bb2bc85440c30e7"
[[module]]
id = "runtime"
file = "crates/horizon-client/src/game.rs"
name = "official_coords_hud"
source = "runtime.rs"
[[injection]]
id = "hud"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_hud"
helper = "hud.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "batch", ty = "&mut horizon_client::hud::HudBatch", value = "&mut *batch" },
{ name = "corners", ty = "&mut horizon_client::extras_hud::Corners", value = "&mut *corners" },
]
[[injection]]
id = "command"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_command"
helper = "command.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "command", ty = "&str", value = "command" },
{ name = "ran", ty = "&mut bool", value = "&mut *ran" },
]
[[injection]]
id = "list"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_mods"
helper = "list.rs"
bindings = [
{ name = "mods", ty = "&mut Vec<crate::game::native_hooks::NativeMod>", value = "&mut *mods" },
]
[[injection]]
id = "settings"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_settings"
helper = "settings.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "mod_id", ty = "&str", value = "mod_id" },
{ name = "buttons", ty = "&mut Vec<String>", value = "&mut *buttons" },
]
[[injection]]
id = "click"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_settings_click"
helper = "click.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "mod_id", ty = "&str", value = "mod_id" },
{ name = "button", ty = "usize", value = "button" },
]
@@ -0,0 +1,28 @@
// The box in the top-left corner, drawn as the WebAssembly mods' panels were;
// the debug screen takes that corner.
fn coords_hud(
game: &mut crate::game::Game<'_>,
batch: &mut horizon_client::hud::HudBatch,
corners: &mut horizon_client::extras_hud::Corners,
) {
use crate::game::official_coords_hud as m;
use horizon_client::extras::Corner;
// Turned off in Options > Mods: nothing until the next build leaves it out.
if !game.native_on(m::ID) || !game.native_corner_shown(Corner::TopLeft) {
return;
}
if !m::shown(|| game.native_setting(m::ID, "shown")) {
return;
}
let Some(p) = game.native_player() else {
return;
};
let lines = m::lines(p.x, p.y, p.z, p.yaw);
let w = lines.iter().map(|l| game.native_text_width(l)).fold(0.0, f32::max) + 4.0;
let h = 10.0 * lines.len() as f32 + 2.0;
let (x, y) = corners.place(Corner::TopLeft, w, h);
game.native_fill(batch, [x, y, w, h], m::BACKGROUND);
for (i, line) in lines.iter().enumerate() {
game.native_text(batch, line, [x + 2.0, y + 2.0 + 10.0 * i as f32], m::INK, true);
}
}
@@ -0,0 +1,10 @@
// The mod's row in Options > Mods.
fn official_coords_hud_listed(mods: &mut Vec<crate::game::native_hooks::NativeMod>) {
use crate::game::official_coords_hud as m;
mods.push(crate::game::native_hooks::NativeMod {
id: m::ID.into(),
title: format!("{} {}", m::NAME, m::VERSION),
description: m::DESCRIPTION.into(),
keys: Vec::new(),
});
}
@@ -0,0 +1,100 @@
// Horizon's Coordinates HUD, formerly the WebAssembly SDK's example mod: the
// block position and the direction the player faces in a box in the top-left
// corner, `/where` to put the position in chat and `/coords` to hide or show
// the box. This file is both the mod's Cargo library (its tests) and the
// module the bundle adds to the game.
/// The mod's id (its settings folder), name and version.
pub const ID: &str = "coords_hud";
pub const NAME: &str = "Coordinates HUD";
pub const VERSION: &str = "2.0.0";
pub const DESCRIPTION: &str = "Your coordinates and facing in a corner of the screen. /where puts them in chat, /coords hides or shows the box.";
/// The WebAssembly mods' panel: translucent black, white text with a shadow.
pub const BACKGROUND: u32 = 0x7800_0000;
pub const INK: u32 = 0xFF_FFFF;
/// Direction.fromYRot: yaw 0 faces south, 90 west.
pub fn facing(yaw: f32) -> &'static str {
let i = (yaw / 90.0 + 0.5).floor() as i32 & 3;
["South (+Z)", "West (-X)", "North (-Z)", "East (+X)"][i as usize]
}
/// The box's lines, as the WebAssembly mod wrote them.
pub fn lines(x: f64, y: f64, z: f64, yaw: f32) -> [String; 2] {
[
format!("XYZ {x:.1} / {y:.1} / {z:.1}"),
format!("Facing {}", facing(yaw)),
]
}
/// `/where`'s line: `12, 70, -4 in minecraft:the_nether`.
pub fn where_line(x: f64, y: f64, z: f64, dimension: &str) -> String {
format!(
"{:.0}, {:.0}, {:.0} in {dimension}",
x.floor(),
y.floor(),
z.floor()
)
}
/// The stored `shown` value: shown unless it says `false`.
pub fn read_shown(stored: Option<String>) -> bool {
stored.is_none_or(|v| v.trim() != "false")
}
pub fn shown_label(shown: bool) -> String {
format!("Shown: {}", if shown { "ON" } else { "OFF" })
}
thread_local! {
/// Whether the box shows, read once from its file, then kept.
static SHOWN: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
}
/// Whether the box shows, read once through `get` (the `shown` file).
pub fn shown(get: impl FnOnce() -> Option<String>) -> bool {
SHOWN.with(|s| {
let found = s.get().unwrap_or_else(|| read_shown(get()));
s.set(Some(found));
found
})
}
/// Replaces it (`/coords` or the settings page changed it).
pub fn set_shown(shown: bool) {
SHOWN.with(|s| s.set(Some(shown)));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn facing_follows_vanilla_yaw() {
assert_eq!(facing(0.0), "South (+Z)");
assert_eq!(facing(90.0), "West (-X)");
assert_eq!(facing(-180.0), "North (-Z)");
assert_eq!(facing(-90.0), "East (+X)");
assert_eq!(facing(314.0), "East (+X)");
}
#[test]
fn lines_read_as_the_webassembly_mods() {
assert_eq!(
lines(12.5, 64.0, -3.25, 180.0),
["XYZ 12.5 / 64.0 / -3.2", "Facing North (-Z)"]
);
assert_eq!(
where_line(12.5, 70.2, -3.25, "minecraft:the_nether"),
"12, 70, -4 in minecraft:the_nether"
);
}
#[test]
fn shown_unless_stored_false() {
assert!(read_shown(None));
assert!(read_shown(Some("true".into())));
assert!(!read_shown(Some("false\n".into())));
assert_eq!(shown_label(false), "Shown: OFF");
}
}
@@ -0,0 +1,8 @@
// The mod's settings page: one button, the box shown or hidden.
fn official_coords_hud_page(game: &mut crate::game::Game<'_>, mod_id: &str, buttons: &mut Vec<String>) {
use crate::game::official_coords_hud as m;
if mod_id != m::ID {
return;
}
buttons.push(m::shown_label(m::shown(|| game.native_setting(m::ID, "shown"))));
}
+56
View File
@@ -0,0 +1,56 @@
"""Coordinates HUD in a running modded game: its row and settings page in Options > Mods, the box in the top
left, /coords hiding and showing it (and keeping that), and /where's line in chat (screenshots)."""
import json
import time
from game import Game, difference
DATA = "horizon-mods/.horizon-mod-data/coords_hud"
TOP_LEFT, CHAT = (0.0, 0.0, 0.25, 0.1), (0.0, 0.7, 0.5, 0.95)
def typed(g, line):
"""As the player types it: T opens the chat box, then the line and Enter."""
g.run("key", "t", "tap")
time.sleep(0.5)
g.run("type", line)
g.run("key", "enter", "tap")
time.sleep(1)
def run(ctl, resources, out):
with Game(ctl, resources, out) as g:
g.run("options", "mods")
assert "Coordinates HUD 2.0.0: ON" in g.labels(), g.labels()
g.run("click", g.ids()["Settings"])
page = g.labels()
assert "Shown: ON" in page, page
g.run("click", g.ids()["Shown: ON"])
clicked = (g.home / DATA / "shown").read_text()
g.run("click", g.ids()["Shown: OFF"])
g.play()
g.run("command", "tp @s ~ ~ ~ 0 10")
time.sleep(2)
shown = g.shot("1-shown")
typed(g, "/coords")
hidden_file = (g.home / DATA / "shown").read_text()
time.sleep(4)
hidden = g.shot("2-hidden")
typed(g, "/coords")
typed(g, "/where")
where = g.shot("3-where")
results = {
"page": page,
"shown_file_after_click": clicked,
"shown_file_after_coords": hidden_file,
"shown_file_at_end": (g.home / DATA / "shown").read_text(),
"diff_top_left_hidden": round(difference(shown, hidden, TOP_LEFT), 1),
"diff_top_left_shown_again": round(difference(shown, where, TOP_LEFT), 1),
"diff_chat_where": round(difference(hidden, where, CHAT), 1),
}
print(json.dumps(results, indent=2))
assert clicked == "false" and hidden_file == "false", results
assert results["shown_file_at_end"] == "true", results
assert results["diff_top_left_hidden"] > 4, results
assert results["diff_top_left_shown_again"] < results["diff_top_left_hidden"], results
# native_chat lines are the client's; chat-log reads the server's chat here: see 3-where.png.
print(json.dumps({"proof": "coords_hud", "ok": True, **results}, indent=2))
+1 -1
View File
@@ -2,7 +2,7 @@
"game": "26.3",
"hooks": 2,
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "8f91683323ac1165e7d76b3037e18f30fe647205",
"horizon_revision": "4baa571566e39bfd6594988d3bb2bc85440c30e7",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
}
@@ -6,7 +6,7 @@ priority = 100
# The commit it was tested on. It uses only the native hook points (hooks 2 in
# compatibility.json): the launcher builds it on any Horizon that has them.
[compatibility]
horizon_revision = "8f91683323ac1165e7d76b3037e18f30fe647205"
horizon_revision = "4baa571566e39bfd6594988d3bb2bc85440c30e7"
[[module]]
id = "runtime"
+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.12.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.12.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.
+200
View File
@@ -0,0 +1,200 @@
# 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 server market and its `/shop` menu, player trades, the coinflip, the lottery, places (spawn, homes, warps,
teleport requests, the rules), land claims, and the scoreboard (a sidebar of the richest players, the Tab list's
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\|take\|set <player> <amount>`, `/money ...` | operators of level 3: corrections, logged (`/money` is the same command); the player given money is told |
| `/eco give * <amount>` | operators of level 3: to every player on, one logged change each |
| `/shop` | the market as a chest menu: categories, a click buys one, a shift-click 64, a click on a stack of yours sells it, "Sell all" |
| `/worth [item]` | what the market pays and asks for an item (the one in your hand without a name), and what you may still sell today |
| `/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`) |
| `/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 |
| `/sidebar off`, `/sidebar on`, `/sidebar` | hides the richest players' sidebar for you (remembered), shows it again |
The market trades only plain items (not worn, named or enchanted), and only with players in survival or adventure
mode. Items move first, then money, both logged; a sale the log refuses gives the items back, and a purchase checks the
money and the room before anything moves.
**No way to get rich from the market.** The market makes money only from what players gather, and only so much a day:
- Buying always costs more than selling: an item whose `sell` is not below its `buy` isn't bought (the server log
says so at the start).
- 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.
- 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.
- Amounts are checked: no price above 1,000,000,000,000, no sum that overflows.
- The coinflip and the lottery give the winner the stakes (less `fee_percent`, 0 by default): they move money
between players and never make any.
Default prices (coins, buy / sell; `-` not traded that way):
| Category | Items |
|---|---|
| Farming | wheat 3/1, wheat seeds 2/-, carrot 3/1, potato 3/1, beetroot 3/1, sugar cane 3/1, pumpkin 8/3, melon slice 2/1, cactus 3/1, bone meal 3/- |
| Mining | coal 8/2, copper ingot 6/2, iron ingot 30/8, gold ingot 40/10, redstone 4/1, lapis 8/2, quartz 8/2, emerald 80/20, diamond 400/100 |
| Wood | the eight logs 4/1 |
| Mob drops | rotten flesh -/1, bone 3/1, string 3/1, gunpowder 8/2, spider eye 4/1, slime ball 10/2, blaze rod 30/8, leather 6/2, feather 2/1 |
| Building | cobblestone 1/-, stone 2/-, dirt 1/-, sand 2/-, gravel 2/-, glass 3/-, bricks 4/-, stone bricks 3/- |
| Food | bread 5/-, cooked beef 6/-, cooked porkchop 6/-, cooked chicken 5/-, baked potato 4/-, apple 4/-, golden carrot 40/- |
| Tools | torch 1/-, ladder 2/-, crafting table 4/-, chest 6/-, furnace 6/- |
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.
**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, `fee_percent` 0). A stake is held from the offer, in
`economy.json` with the balances, until the coin falls; an offer cancelled, not taken in time, or whose player
left gives it back, and so does the next start for one the server stopped with. The coin is a fresh random key from
the system, never the world's random. Only the in-game currency is staked and won.
**The lottery.** Part of `gambling` (`ticket_price` 10, `max_tickets` 100 a player a draw, `lottery_seconds` 3600
from a round's first ticket to its draw). Tickets are held like coinflip stakes; at the draw one ticket is picked at
random among all sold (more tickets, more chances), its owner gets the pot (less `fee_percent`), and everyone hears
it. A round with a single player, or one the server stopped with, gives every ticket back.
**Places.** A teleport waits `warmup_seconds` (3) in `config.json`'s `places`; a player who moves or is hurt meanwhile
stays where they are, and `cooldown_seconds` (10) passes between two of one player's teleports. `/tpa` takes the
asker to where the other player is when the warmup ends. Homes grow with the time played on the server since the mod
was on (`homes`: `[hours, homes]`, by default 1 home, 2 from 5 hours, 3 from 20, 5 from 100). `rules` holds `/rules`'
lines. Teleports are vanilla's `tp`, run as the server (`execute in <level> run tp`), so they cross levels and land
as any teleport does.
**Claims.** In a claimed chunk, only its owner, the players they trust and operators of level 3 break blocks or right
click blocks: doors, chests, levers, buttons, placing a block, a bucket or flint on a block. Anyone else's click does
nothing, and the action bar says whose land it is. The chunks a player may hold grow with the time played here
(`claims.chunks`: `[hours, chunks]`, by default 4, 16 from 5 hours, 36 from 20, 64 from 100); `claims.enabled`
turns them off. The world stays out too: no water or lava flows, no fire spreads or burns, and no piston pushes into
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 lists the `board.top` (10) richest players with their balance (`125.4k`,
`3.2M`), then a gap and your own rank and balance. It's rebuilt only when a balance changed, at
most every `board.refresh_seconds` (60), and sent only to the players whose lines changed: nothing runs per tick per
player. The Tab list shows `board.tab_header` and `board.tab_footer` (`{server}`, `{tps}`, `{players}` filled in,
every `board.tab_refresh_seconds`, 5). `board.sidebar` and `board.tab` turn either off; `board.title` and
`board.server_name` name them. The sidebar uses one scoreboard objective of its own (`horizon:native_sidebar`) and
leaves the world's scoreboard alone.
**Presence.** A player's first join is announced to everyone (`presence.first_join`, empty for none) and they read
`presence.welcome` and the rules. Each player gets `presence.daily_reward` (50) once a day (UTC) for being on, logged
as `daily_reward` (0 turns it off). A player who hasn't moved, turned or run a command for `presence.afk_minutes` (5),
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 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`); written with the defaults the first time, and again when a newer version adds keys (your values kept) |
| `economy.json` | balances by player UUID, with the name last seen, and `held`: coinflip stakes and lottery tickets being decided |
| `places.json` | the spawn, the warps, each player's homes, and the seconds each player has played (written once a minute) |
| `market.json` | what each player sold today (coins and items), for the daily limits |
| `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`) |
| `claims.json` | each claimed chunk (`<dimension> <chunk x> <chunk z>`) with its owner, and whom each owner trusts |
| `audit.log` | one JSON line per change, written before the change: `time` (ms), `kind` (`open`, `pay`, `eco_give`...), `amount`, `from`/`to` with their balance before, `by` for an operator; a trade's line (`trade`: both players, their items and money) comes before its swap, `trade_failed` after one that didn't go through |
`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), the server tick
and the world lifecycle. `compatibility.json` names `"hooks": 49`.
| File | What it is |
|---|---|
| `mixins/smp/horizon-mixin.toml` | The bundle: thirteen modules and the HEAD hooks |
| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs`, `smp_recipes.rs`, `smp_shop.rs`, `smp_trade.rs`, `smp_games.rs`, `smp_lottery.rs`, `smp_places.rs`, `smp_claims.rs`, `smp_board.rs`, `smp_presence.rs` | The rules, no game types (a trade's stacks are a type parameter): added to the game as `game::smp_store`, `game::smp_economy`, `game::smp_market`, `game::smp_recipes`, `game::smp_shop`, `game::smp_trade`, `game::smp_games`, `game::smp_lottery`, `game::smp_places`, `game::smp_claims`, `game::smp_board`, `game::smp_presence`; also this crate's modules (`src/lib.rs`), for their tests |
| `mixins/smp/glue.rs` | The state for the open world and the edges to the game's types (`game::smp_glue`) |
| `mixins/smp/{register,command,tick,lifecycle,menu_clicked,menu_closed,destroy_block,right_click_block,block_spread,piston_move,explosion_detonate,fill_bucket,attack_entity}.rs` | One small helper per hook |
| `proof.py` | A dedicated server built with the mod and two real clients, on fresh temporary data |
## 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": 49,
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "5272f3ad64bc68e1f80068a201f38d442ea01fbc",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
}
+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;
}
}
+11
View File
@@ -0,0 +1,11 @@
// 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_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
+233
View File
@@ -0,0 +1,233 @@
[mod]
id = "smp"
version = "0.12.0"
priority = 100
# The commit of horizon main it was tested on. It uses only the native hook points (hooks 49 in
# compatibility.json): it builds on any Horizon that has them.
[compatibility]
horizon_revision = "5272f3ad64bc68e1f80068a201f38d442ea01fbc"
[[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 = "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 = "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 = "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 = "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" },
]
+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();
}
}
+4
View File
@@ -0,0 +1,4 @@
// A click in a menu a native mod opened: the SMP's trade menu, when it is one.
fn smp_menu_clicked(game: &mut crate::game::Game<'_>, uuid: u128, window: i32, slot: i32, mode: i32) {
crate::game::smp_glue::menu_clicked(game, uuid, window, slot, mode);
}
+4
View File
@@ -0,0 +1,4 @@
// A menu a native mod opened closed: a trade's ends, nothing moved.
fn smp_menu_closed(game: &mut crate::game::Game<'_>, uuid: u128, window: i32) {
crate::game::smp_glue::menu_closed(game, uuid, window);
}
+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;
}
}
+4
View File
@@ -0,0 +1,4 @@
// 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);
}
@@ -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);
}
+439
View File
@@ -0,0 +1,439 @@
// The SMP's screens: a sidebar with the richest players and the player's own
// balance and rank, which a player hides with `/sidebar off` (kept in
// `board.json`), and the Tab list's header and footer (the server's name,
// its TPS and the players on). Nothing happens per tick: the ranking is built
// again when a balance changed (at most once each `min_seconds`) or every
// `refresh_seconds`, and a sidebar is sent only to the players whose lines
// changed; the Tab list goes out every `tab_refresh_seconds`, and to a player
// who just joined. No game types. Added to the game as `game::smp_board`;
// 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 lines a sidebar shows (vanilla's 15, less the gap and the
/// player's own line).
const MAX_TOP: usize = 13;
/// `config.json`'s `board` section.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// The sidebar of the richest players (each player may hide it).
pub sidebar: bool,
pub title: String,
/// How many of the richest players it lists (at most 13).
pub top: usize,
/// The ranking is built again 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: "Richest players".into(),
top: 10,
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,
}
}
}
/// `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 {
/// 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 empty line before the player's own.
Gap,
/// The player's rank (0: no account yet) and balance.
Me { rank: usize, amount: String },
}
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,
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,
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 from the ranking.
pub fn lines(&self, uuid: u128) -> Vec<Line> {
let mut lines: Vec<Line> = self
.ranked
.iter()
.take(self.config.top.min(MAX_TOP))
.enumerate()
.map(|(i, (u, name, balance))| Line::Top {
rank: i + 1,
name: name.clone(),
amount: compact(*balance),
me: *u == uuid,
})
.collect();
let rank = self.rank_of.get(&uuid).copied().unwrap_or(0);
let balance = rank.checked_sub(1).map_or(0, |i| self.ranked[i].2);
lines.push(Line::Gap);
lines.push(Line::Me {
rank,
amount: compact(balance),
});
lines
}
/// The sidebars to send now, to the players `on`: those who joined, and
/// after a rebuild those whose lines changed. Players who hid it get
/// nothing; players gone are forgotten (they get it again on return).
pub fn updates(&mut self, on: &[u128]) -> Vec<(u128, Vec<Line>)> {
self.sent.retain(|u, _| on.contains(u));
self.tab_sent.retain(|u| on.contains(u));
if !self.config.sidebar || self.built_at.is_none() {
return Vec::new();
}
let stale = std::mem::take(&mut self.stale);
let mut out = Vec::new();
for &uuid in on {
if self.hidden(uuid) || (!stale && self.sent.contains_key(&uuid)) {
continue;
}
let lines = self.lines(uuid);
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");
}
#[test]
fn the_sidebar_lists_the_richest_and_the_players_own_rank() {
let d = dir();
let mut b = Board::open(&d, Config::default()).unwrap();
assert!(b.due(0, 0));
b.rebuild(accounts(12), 0, 1);
let lines = b.lines(3);
assert_eq!(lines.len(), 12);
assert_eq!(
lines[0],
Line::Top {
rank: 1,
name: "P12".into(),
amount: "1,200".into(),
me: false
}
);
assert_eq!(lines[10], Line::Gap);
assert_eq!(
lines[11],
Line::Me {
rank: 10,
amount: "300".into()
}
);
// A player with no account yet.
assert_eq!(
b.lines(99)[11],
Line::Me {
rank: 0,
amount: "0".into()
}
);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn sidebars_go_out_on_join_and_on_change_only() {
let d = dir();
let mut b = Board::open(&d, Config::default()).unwrap();
b.rebuild(accounts(3), 0, 1);
let sent = b.updates(&[1, 2]);
assert_eq!(sent.len(), 2);
// Nothing new: nothing sent, even when built again.
assert!(b.updates(&[1, 2]).is_empty());
assert!(!b.due(1_000, 1), "no change, not a minute yet");
assert!(!b.due(1_000, 2), "changed, but within min_seconds");
assert!(b.due(5_000, 2));
b.rebuild(accounts(3), 5_000, 2);
assert!(b.updates(&[1, 2]).is_empty(), "same lines");
// Player 1 gets richer: everyone's top changes.
let mut a = accounts(3);
a[0].2 = 1_000;
b.rebuild(a, 10_000, 3);
assert_eq!(b.updates(&[1, 2]).len(), 2);
// A newcomer gets theirs at once; one who left gets it on return.
assert_eq!(b.updates(&[1, 2, 3]).len(), 1);
b.updates(&[2, 3]);
assert_eq!(b.updates(&[1, 2, 3]).len(), 1);
assert!(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(&[1, 2]).len(), 1, "only player 2");
b.save().unwrap();
let mut b = Board::open(&d, Config::default()).unwrap();
assert!(b.hidden(1));
b.rebuild(accounts(3), 0, 1);
assert_eq!(b.updates(&[1]).len(), 0);
// `/sidebar` toggles it back on, and it is sent at the next update.
let (_, show) = b.command(&me(1), "sidebar").unwrap();
assert_eq!(show, Some(true));
assert_eq!(b.updates(&[1]).len(), 1);
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);
}
}
+688
View File
@@ -0,0 +1,688 @@
// 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>,
}
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(),
}
}
}
/// 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(),
})
}
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.
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 => {
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 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);
}
}
+828
View File
@@ -0,0 +1,828 @@
// The SMP's economy: one currency, a balance per player UUID
// (`economy.json`), and the commands `/balance`, `/pay`, `/baltop` and the
// operators' `/eco`. Every change is written to the audit log first, then
// made; a change the log refuses is not made. No game types: the glue
// (glue.rs) hands in the player and the line, and sends back what this
// answers. Added to the game as `game::smp_economy`; also a module of this
// crate, for its tests.
use super::smp_store::{Audit, read_json, uuid_string, write_json};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, path::PathBuf};
/// Amounts above this are refused: far beyond any economy, far below overflow.
pub const MAX_AMOUNT: i64 = 1_000_000_000_000;
/// Lines of `/baltop` a page.
pub const PAGE: usize = 10;
/// `config.json`'s economy settings.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// Shown after amounts: `1,250 coins`.
pub currency: String,
/// What a new account holds.
pub starting_balance: i64,
}
impl Default for Config {
fn default() -> Self {
Self {
currency: "coins".into(),
starting_balance: 0,
}
}
}
/// One player's account.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Account {
/// The name last seen.
pub name: String,
pub balance: i64,
}
/// `economy.json`.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Accounts {
/// By UUID, as `uuid_string` writes it.
pub accounts: BTreeMap<String, Account>,
/// Money held for a game being decided (a coinflip's stake), by UUID:
/// in the same file as the balances, so a crash can't lose it or count
/// it twice. Given back when the economy opens (a restart voids games).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub held: BTreeMap<String, i64>,
}
/// A player as a command sees them.
#[derive(Clone, Debug, PartialEq)]
pub struct Player {
pub uuid: u128,
pub name: String,
/// Operator level, 0 to 4.
pub level: u8,
}
/// What a command answers: its feedback, red when an error, and lines to
/// other players.
#[derive(Clone, Debug, PartialEq)]
pub enum Out {
Reply(String),
Error(String),
Tell(u128, String),
/// A line to every player (and the server's log).
All(String),
}
pub struct Economy {
pub config: Config,
accounts: Accounts,
path: PathBuf,
audit: Audit,
dirty: bool,
/// Counts the changes to the accounts, so the sidebar knows when to
/// look again.
revision: u64,
/// Milliseconds since 1970, for the log (the glue sets it each call).
pub now: i64,
}
impl Economy {
/// The economy kept in `dir` (`world/horizon-smp/`); an error when
/// `economy.json` exists and can't be read: the economy then stays off
/// rather than start from nothing.
pub fn open(dir: &std::path::Path, config: Config) -> Result<Self, String> {
let path = dir.join("economy.json");
let accounts = read_json(&path)?;
let mut economy = Self {
config,
accounts,
path,
audit: Audit::new(dir.join("audit.log")),
dirty: false,
revision: 0,
now: 0,
};
// Games the last run left undecided: their stakes go back.
let held: Vec<(String, i64)> = economy.accounts.held.clone().into_iter().collect();
for (key, amount) in held {
if let Some(uuid) = super::smp_store::parse_uuid(&key) {
economy.release("held_returned", uuid, amount)?;
}
}
Ok(economy)
}
fn touch(&mut self) {
self.dirty = true;
self.revision += 1;
}
/// Changes to the accounts so far (in this run).
pub fn revision(&self) -> u64 {
self.revision
}
/// Every account: UUID, name, balance.
pub fn accounts(&self) -> Vec<(u128, String, i64)> {
self.accounts
.accounts
.iter()
.filter_map(|(k, a)| {
Some((super::smp_store::parse_uuid(k)?, a.name.clone(), a.balance))
})
.collect()
}
/// Money `uuid` has held for a game.
pub fn held(&self, uuid: u128) -> i64 {
self.accounts
.held
.get(&uuid_string(uuid))
.copied()
.unwrap_or(0)
}
/// Moves `amount` of `uuid`'s balance to held, logged as `kind`.
pub fn hold(&mut self, kind: &str, uuid: u128, amount: i64) -> Result<(), String> {
let key = uuid_string(uuid);
let balance = self.balance(uuid);
if amount <= 0 || amount > MAX_AMOUNT {
return Err("The amount must be a positive whole number.".into());
}
if !self.accounts.accounts.contains_key(&key) {
return Err("No such account.".into());
}
if balance < amount {
return Err(format!("You only have {}.", self.money(balance)));
}
let entry = self.entry(kind, Some(uuid), None, amount, "");
self.audit.append(&entry)?;
self.accounts
.accounts
.get_mut(&key)
.expect("checked")
.balance -= amount;
*self.accounts.held.entry(key).or_default() += amount;
self.touch();
Ok(())
}
/// Gives back up to `amount` of what `uuid` has held, logged as `kind`.
pub fn release(&mut self, kind: &str, uuid: u128, amount: i64) -> Result<(), String> {
let key = uuid_string(uuid);
let amount = amount.min(self.held(uuid));
if amount <= 0 {
return Ok(());
}
let entry = self.entry(kind, None, Some(uuid), amount, "");
self.audit.append(&entry)?;
self.take_held(&key, amount);
let account = self.accounts.accounts.entry(key).or_default();
account.balance = account.balance.saturating_add(amount);
self.touch();
Ok(())
}
/// A game decided: each stake leaves held, and `winner` gets `prize`
/// (at most the stakes together), logged as `kind`.
pub fn award(
&mut self,
kind: &str,
stakes: &[(u128, i64)],
winner: u128,
prize: i64,
) -> Result<(), String> {
let total: i64 = stakes.iter().map(|(_, a)| *a).sum();
if stakes.iter().any(|(u, a)| self.held(*u) < *a) || !(0..=total).contains(&prize) {
return Err("The stakes are not held.".into());
}
let mut entry = self.entry(kind, None, Some(winner), prize, "");
entry["stakes"] = stakes
.iter()
.map(|(u, a)| serde_json::json!({ "uuid": uuid_string(*u), "amount": a }))
.collect();
self.audit.append(&entry)?;
for (u, a) in stakes {
self.take_held(&uuid_string(*u), *a);
}
let account = self
.accounts
.accounts
.entry(uuid_string(winner))
.or_default();
account.balance = account.balance.saturating_add(prize);
self.touch();
Ok(())
}
fn take_held(&mut self, key: &str, amount: i64) {
if let Some(h) = self.accounts.held.get_mut(key) {
*h -= amount;
if *h <= 0 {
self.accounts.held.remove(key);
}
}
}
/// Writes `economy.json` when it changed (end of a tick, world closing).
pub fn save(&mut self) -> Result<(), String> {
self.audit.sync();
if !self.dirty {
return Ok(());
}
write_json(&self.path, &self.accounts)?;
self.dirty = false;
Ok(())
}
pub fn balance(&self, uuid: u128) -> i64 {
self.accounts
.accounts
.get(&uuid_string(uuid))
.map_or(0, |a| a.balance)
}
/// The account of a player who is on: opened with the starting balance
/// the first time (logged), its name kept up to date.
pub fn seen(&mut self, uuid: u128, name: &str) {
let key = uuid_string(uuid);
match self.accounts.accounts.get_mut(&key) {
Some(a) if a.name == name => {}
Some(a) => {
a.name = name.to_owned();
self.touch();
}
None => {
let start = self.config.starting_balance.clamp(0, MAX_AMOUNT);
let entry = self.entry("open", None, Some(uuid), start, "");
if self.audit.append(&entry).is_ok() {
self.accounts.accounts.insert(
key,
Account {
name: name.to_owned(),
balance: start,
},
);
self.touch();
}
}
}
}
/// An account by the name last seen, any case.
pub fn find(&self, name: &str) -> Option<(u128, &Account)> {
self.accounts
.accounts
.iter()
.find(|(_, a)| a.name.eq_ignore_ascii_case(name))
.and_then(|(k, a)| Some((super::smp_store::parse_uuid(k)?, a)))
}
/// The name last seen of a player who has an account.
pub fn name_of(&self, uuid: u128) -> Option<&str> {
let a = self
.accounts
.accounts
.get(&super::smp_store::uuid_string(uuid))?;
Some(&a.name)
}
/// `1,250 coins`.
pub fn money(&self, amount: i64) -> String {
let digits = amount.unsigned_abs().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);
}
let sign = if amount < 0 { "-" } else { "" };
format!("{sign}{out} {}", self.config.currency)
}
fn entry(
&self,
kind: &str,
from: Option<u128>,
to: Option<u128>,
amount: i64,
by: &str,
) -> serde_json::Value {
let after = |u: Option<u128>| u.map(|u| self.balance(u));
let mut e = serde_json::json!({
"time": self.now,
"kind": kind,
"amount": amount,
});
if let Some(f) = from {
e["from"] = uuid_string(f).into();
e["from_before"] = after(Some(f)).into();
}
if let Some(t) = to {
e["to"] = uuid_string(t).into();
e["to_before"] = after(Some(t)).into();
}
if !by.is_empty() {
e["by"] = by.into();
}
e
}
/// Moves `amount` from one account to another, logged as `kind`. Both
/// must exist; `from` must hold it.
pub fn transfer(
&mut self,
kind: &str,
from: u128,
to: u128,
amount: i64,
) -> Result<(), String> {
if amount <= 0 || amount > MAX_AMOUNT {
return Err("The amount must be a positive whole number.".into());
}
if from == to {
return Err("You can't pay yourself.".into());
}
let (Some(f), Some(t)) = (
self.accounts.accounts.get(&uuid_string(from)),
self.accounts.accounts.get(&uuid_string(to)),
) else {
return Err("No such account.".into());
};
if f.balance < amount {
return Err(format!("You only have {}.", self.money(f.balance)));
}
let Some(to_after) = t
.balance
.checked_add(amount)
.filter(|b| *b <= MAX_AMOUNT * 1000)
else {
return Err("That account can't hold more.".into());
};
let entry = self.entry(kind, Some(from), Some(to), amount, "");
self.audit.append(&entry)?;
self.accounts
.accounts
.get_mut(&uuid_string(from))
.expect("checked")
.balance -= amount;
self.accounts
.accounts
.get_mut(&uuid_string(to))
.expect("checked")
.balance = to_after;
self.touch();
Ok(())
}
/// Adds (or with a negative `delta`, takes) money from the server: the
/// market, games, operators. The balance never goes below zero.
pub fn adjust(&mut self, kind: &str, uuid: u128, delta: i64, by: &str) -> Result<i64, String> {
let key = uuid_string(uuid);
let Some(a) = self.accounts.accounts.get(&key) else {
return Err("No such account.".into());
};
let after = a.balance.saturating_add(delta);
if after < 0 {
return Err(format!("Only {} there.", self.money(a.balance)));
}
if after > MAX_AMOUNT * 1000 {
return Err("That account can't hold more.".into());
}
let (from, to) = if delta < 0 {
(Some(uuid), None)
} else {
(None, Some(uuid))
};
let entry = self.entry(kind, from, to, delta.abs(), by);
self.audit.append(&entry)?;
self.accounts
.accounts
.get_mut(&key)
.expect("checked")
.balance = after;
self.touch();
Ok(after)
}
/// Appends a line of another kind to the audit log (a trade's items),
/// with the time; the change it records waits for it.
pub fn record(&mut self, mut entry: serde_json::Value) -> Result<(), String> {
entry["time"] = self.now.into();
self.audit.append(&entry)
}
/// The accounts, richest first (then by name).
pub fn top(&self) -> Vec<(&str, i64)> {
let mut all: Vec<_> = self
.accounts
.accounts
.values()
.map(|a| (a.name.as_str(), a.balance))
.collect();
all.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
all
}
/// Runs `/balance`, `/bal`, `/pay`, `/baltop` or the operators' `/eco`
/// (and its other name `/money`); None for another command. `online`:
/// the players on, for `/eco give * <amount>`.
pub fn command(
&mut self,
me: &Player,
online: &[(u128, String)],
line: &str,
) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
let (&name, args) = words.split_first()?;
Some(match name {
"balance" | "bal" => self.balance_command(me, args),
"pay" => self.pay(me, args),
"baltop" => self.baltop(args),
"eco" | "money" => match args {
["give", "*", amount] => self.give_everyone(me, online, amount),
_ => self.eco(me, args),
},
_ => return None,
})
}
/// `/eco give * <amount>`: to every player on, each change logged.
fn give_everyone(&mut self, me: &Player, online: &[(u128, String)], amount: &str) -> Vec<Out> {
if me.level < 3 {
return vec![Out::Error("Only operators of level 3 may use /eco.".into())];
}
let Some(amount) = parse_amount(amount) else {
return vec![Out::Error(
"The amount must be a positive whole number.".into(),
)];
};
let by = uuid_string(me.uuid);
let mut out = Vec::new();
let mut given = 0;
for (uuid, name) in online {
self.seen(*uuid, name);
match self.adjust("eco_give", *uuid, amount, &by) {
Ok(_) => {
given += 1;
if *uuid != me.uuid {
out.push(Out::Tell(
*uuid,
format!("An operator gave you {}.", self.money(amount)),
));
}
}
Err(e) => out.push(Out::Error(format!("{name}: {e}"))),
}
}
out.insert(
0,
Out::Reply(format!(
"Gave {} to {given} player(s) on.",
self.money(amount)
)),
);
out
}
fn balance_command(&mut self, me: &Player, args: &[&str]) -> Vec<Out> {
match args {
[] => vec![Out::Reply(format!(
"Balance: {}",
self.money(self.balance(me.uuid))
))],
[who] => match self.find(who) {
Some((_, a)) => vec![Out::Reply(format!("{}: {}", a.name, self.money(a.balance)))],
None => vec![Out::Error(format!("No player named {who} has an account."))],
},
_ => vec![Out::Error("Usage: /balance [player]".into())],
}
}
fn pay(&mut self, me: &Player, args: &[&str]) -> Vec<Out> {
let [who, amount] = args else {
return vec![Out::Error("Usage: /pay <player> <amount>".into())];
};
let Some(amount) = parse_amount(amount) else {
return vec![Out::Error(
"The amount must be a positive whole number.".into(),
)];
};
let Some((to, account)) = self.find(who) else {
return vec![Out::Error(format!("No player named {who} has an account."))];
};
let to_name = account.name.clone();
match self.transfer("pay", me.uuid, to, amount) {
Ok(()) => vec![
Out::Reply(format!(
"You paid {} to {to_name}. Balance: {}",
self.money(amount),
self.money(self.balance(me.uuid))
)),
Out::Tell(to, format!("{} paid you {}.", me.name, self.money(amount))),
],
Err(e) => vec![Out::Error(e)],
}
}
fn baltop(&mut self, args: &[&str]) -> Vec<Out> {
let page = match args {
[] => 1,
[p] => match p.parse::<usize>() {
Ok(p) if p >= 1 => p,
_ => return vec![Out::Error("Usage: /baltop [page]".into())],
},
_ => return vec![Out::Error("Usage: /baltop [page]".into())],
};
let top = self.top();
let pages = top.len().div_ceil(PAGE).max(1);
let mut out = vec![Out::Reply(format!("Richest players ({page}/{pages}):"))];
for (i, (name, balance)) in top.iter().enumerate().skip((page - 1) * PAGE).take(PAGE) {
out.push(Out::Reply(format!(
"{}. {name}: {}",
i + 1,
self.money(*balance)
)));
}
out
}
fn eco(&mut self, me: &Player, args: &[&str]) -> Vec<Out> {
if me.level < 3 {
return vec![Out::Error("Only operators of level 3 may use /eco.".into())];
}
let [verb, who, amount] = args else {
return vec![Out::Error(
"Usage: /eco give|take|set <player> <amount>, /eco give * <amount>".into(),
)];
};
let amount = match amount.parse::<i64>() {
Ok(a) if (0..=MAX_AMOUNT).contains(&a) => a,
_ => {
return vec![Out::Error(
"The amount must be a whole number from 0.".into(),
)];
}
};
let Some((uuid, account)) = self.find(who) else {
return vec![Out::Error(format!("No player named {who} has an account."))];
};
let name = account.name.clone();
let delta = match *verb {
"give" => amount,
"take" => -amount,
"set" => amount - account.balance,
_ => {
return vec![Out::Error(
"Usage: /eco give|take|set <player> <amount>, /eco give * <amount>".into(),
)];
}
};
let by = uuid_string(me.uuid);
match self.adjust(&format!("eco_{verb}"), uuid, delta, &by) {
Ok(after) => {
let mut out = vec![Out::Reply(format!("{name} now has {}.", self.money(after)))];
if *verb == "give" && uuid != me.uuid && amount > 0 {
out.push(Out::Tell(
uuid,
format!("An operator gave you {}.", self.money(amount)),
));
}
out
}
Err(e) => vec![Out::Error(e)],
}
}
}
/// A positive whole amount, `1250` or `1,250`.
pub fn parse_amount(s: &str) -> Option<i64> {
let digits: String = s.chars().filter(|c| *c != ',').collect();
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
return None;
}
digits
.parse::<i64>()
.ok()
.filter(|a| (1..=MAX_AMOUNT).contains(a))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
const ALEX: u128 = 1;
const STEVE: u128 = 2;
fn player(uuid: u128, name: &str, level: u8) -> Player {
Player {
uuid,
name: name.into(),
level,
}
}
fn economy(name: &str) -> (Economy, PathBuf) {
let dir = std::env::temp_dir().join(format!("smp-economy-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let config = Config {
starting_balance: 100,
..Config::default()
};
let mut e = Economy::open(&dir, config).unwrap();
e.seen(ALEX, "Alex");
e.seen(STEVE, "Steve");
(e, dir)
}
fn log_lines(dir: &std::path::Path) -> Vec<serde_json::Value> {
fs::read_to_string(dir.join("audit.log"))
.unwrap()
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect()
}
#[test]
fn pay_moves_money_and_tells_both() {
let (mut e, dir) = economy("pay");
let out = e
.command(&player(ALEX, "Alex", 0), &[], "pay steve 30")
.unwrap();
assert_eq!(
out,
[
Out::Reply("You paid 30 coins to Steve. Balance: 70 coins".into()),
Out::Tell(STEVE, "Alex paid you 30 coins.".into()),
]
);
assert_eq!((e.balance(ALEX), e.balance(STEVE)), (70, 130));
let log = log_lines(&dir);
assert_eq!(log.len(), 3, "two accounts opened, one payment");
assert_eq!(log[2]["kind"], "pay");
assert_eq!(log[2]["from_before"], 100);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn pay_refuses_what_it_must() {
let (mut e, dir) = economy("refuse");
let alex = player(ALEX, "Alex", 0);
for (line, error) in [
("pay Steve 101", "You only have 100 coins."),
("pay Steve 0", "The amount must be a positive whole number."),
(
"pay Steve -5",
"The amount must be a positive whole number.",
),
(
"pay Steve 1.5",
"The amount must be a positive whole number.",
),
("pay Alex 5", "You can't pay yourself."),
(
"pay Herobrine 5",
"No player named Herobrine has an account.",
),
("pay Steve", "Usage: /pay <player> <amount>"),
] {
assert_eq!(
e.command(&alex, &[], line).unwrap(),
[Out::Error(error.into())],
"{line}"
);
}
assert_eq!((e.balance(ALEX), e.balance(STEVE)), (100, 100));
assert_eq!(
log_lines(&dir).len(),
2,
"nothing logged but the two accounts"
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn balances_survive_a_restart_and_a_damaged_file_stops_the_economy() {
let (mut e, dir) = economy("restart");
e.command(&player(ALEX, "Alex", 0), &[], "pay Steve 1,000")
.unwrap();
e.command(&player(ALEX, "Alex", 0), &[], "pay Steve 25")
.unwrap();
e.save().unwrap();
let again = Economy::open(&dir, Config::default()).unwrap();
assert_eq!((again.balance(ALEX), again.balance(STEVE)), (75, 125));
fs::write(dir.join("economy.json"), "{ half").unwrap();
assert!(Economy::open(&dir, Config::default()).is_err());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn money_is_eco_and_give_star_pays_everyone_on() {
let (mut e, dir) = economy("money");
let op = player(ALEX, "Alex", 3);
assert_eq!(
e.command(&op, &[], "money give Steve 7").unwrap()[0],
Out::Reply("Steve now has 107 coins.".into())
);
let online = [
(ALEX, "Alex".to_owned()),
(STEVE, "Steve".to_owned()),
(99, "Newcomer".to_owned()),
];
let out = e.command(&op, &online, "eco give * 1,000").unwrap();
assert_eq!(
out[0],
Out::Reply("Gave 1,000 coins to 3 player(s) on.".into()),
"{out:?}"
);
assert!(out.contains(&Out::Tell(99, "An operator gave you 1,000 coins.".into())));
assert!(
!out.iter()
.any(|o| matches!(o, Out::Tell(u, _) if *u == ALEX))
);
assert_eq!(
(e.balance(ALEX), e.balance(STEVE), e.balance(99)),
(1100, 1107, 1100)
);
let gives = log_lines(&dir)
.iter()
.filter(|l| l["kind"] == "eco_give")
.count();
assert_eq!(gives, 4);
assert_eq!(
e.command(&player(STEVE, "Steve", 0), &online, "money give * 5")
.unwrap(),
[Out::Error("Only operators of level 3 may use /eco.".into())]
);
assert!(matches!(
&e.command(&op, &online, "eco give * -5").unwrap()[0],
Out::Error(_)
));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn eco_is_for_level_3_and_never_goes_below_zero() {
let (mut e, dir) = economy("eco");
let alex = player(ALEX, "Alex", 2);
assert_eq!(
e.command(&alex, &[], "eco give Steve 5").unwrap(),
[Out::Error("Only operators of level 3 may use /eco.".into())]
);
let op = player(ALEX, "Alex", 3);
assert_eq!(
e.command(&op, &[], "eco give Steve 5").unwrap(),
[
Out::Reply("Steve now has 105 coins.".into()),
Out::Tell(STEVE, "An operator gave you 5 coins.".into())
]
);
assert_eq!(
e.command(&op, &[], "eco take Steve 200").unwrap(),
[Out::Error("Only 105 coins there.".into())]
);
assert_eq!(
e.command(&op, &[], "eco set Steve 1234567").unwrap(),
[Out::Reply("Steve now has 1,234,567 coins.".into())]
);
let log = log_lines(&dir);
assert_eq!(log.last().unwrap()["kind"], "eco_set");
assert_eq!(log.last().unwrap()["by"], uuid_string(ALEX));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn baltop_pages_the_richest_first() {
let (mut e, dir) = economy("baltop");
for i in 0..12u128 {
e.seen(100 + i, &format!("P{i:02}"));
}
e.command(&player(ALEX, "Alex", 0), &[], "pay Steve 50")
.unwrap();
let first = e.command(&player(ALEX, "Alex", 0), &[], "baltop").unwrap();
assert_eq!(first[0], Out::Reply("Richest players (1/2):".into()));
assert_eq!(first[1], Out::Reply("1. Steve: 150 coins".into()));
assert_eq!(first.len(), 11);
let last = e
.command(&player(ALEX, "Alex", 0), &[], "baltop 2")
.unwrap();
assert_eq!(
last.last().unwrap(),
&Out::Reply("14. Alex: 50 coins".into())
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_name_change_keeps_the_account() {
let (mut e, dir) = economy("rename");
e.seen(ALEX, "Alexandra");
assert_eq!(e.balance(ALEX), 100);
assert!(e.find("alexandra").is_some() && e.find("Alex").is_none());
assert_eq!(log_lines(&dir).len(), 2);
fs::remove_dir_all(dir).unwrap();
}
}
+465
View File
@@ -0,0 +1,465 @@
// Games of chance with the in-game currency only (`/coinflip`): a player
// stakes an amount, another matches it, a fair coin decides, the winner
// takes both stakes. Each stake is held in `economy.json` from the moment it
// is offered (`Economy::hold`) until the coin falls or the offer ends, so a
// crash or a restart gives it back and never loses or doubles it. Off by
// default (`gambling.enabled`); limits, a cooldown and the audit log on every
// step. Nothing here is bought with real money or paid out of the game
// (Mojang's usage guidelines). No game types; the coin is the glue's, never
// the world's random. Added to the game as `game::smp_games`; also a module
// of this crate, for its tests.
use super::smp_economy::{Economy, Out, Player, parse_amount};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// `config.json`'s `gambling` section.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// Off until the owner turns it on.
pub enabled: bool,
pub min_bet: i64,
pub max_bet: i64,
/// Between two coinflips of one player (offered or accepted).
pub cooldown_seconds: i64,
/// How long an offer waits before its stake goes back.
pub offer_seconds: i64,
/// What the server keeps of the pot, in percent (0: the winner takes all).
pub fee_percent: i64,
/// The lottery: a ticket's price, the tickets one player may hold in a
/// draw, and the time from a round's first ticket to its draw.
pub ticket_price: i64,
pub max_tickets: i64,
pub lottery_seconds: i64,
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: false,
min_bet: 10,
max_bet: 1000,
cooldown_seconds: 30,
offer_seconds: 60,
fee_percent: 0,
ticket_price: 10,
max_tickets: 100,
lottery_seconds: 3600,
}
}
}
/// An offer waiting for a taker: its stake is held.
#[derive(Clone, Debug, PartialEq)]
pub struct Offer {
pub from: u128,
pub from_name: String,
/// Only this player may take it; anyone when None.
pub to: Option<u128>,
pub amount: i64,
pub at: i64,
}
#[derive(Default)]
pub struct Games {
pub config: Config,
pub offers: Vec<Offer>,
/// When each player last played, for the cooldown.
last: HashMap<u128, i64>,
}
const USAGE: &str = "Usage: /coinflip <amount> [player], /coinflip accept <player>, /coinflip cancel, /coinflip list";
impl Games {
pub fn new(config: Config) -> Self {
Self {
config,
..Default::default()
}
}
/// The milliseconds `uuid` still waits before playing again.
fn cooling(&self, uuid: u128, now: i64) -> i64 {
self.last
.get(&uuid)
.map_or(0, |t| t + self.config.cooldown_seconds * 1000 - now)
.max(0)
}
/// Offers nobody took in time: their stakes go back.
pub fn expire(&mut self, economy: &mut Economy, now: i64) -> Vec<Out> {
let limit = self.config.offer_seconds * 1000;
let (old, keep): (Vec<Offer>, Vec<Offer>) =
self.offers.drain(..).partition(|o| now - o.at >= limit);
self.offers = keep;
let mut out = Vec::new();
for o in old {
// A stake the log refuses to release stays held: given back
// when the economy next opens.
if economy
.release("coinflip_expired", o.from, o.amount)
.is_ok()
{
out.push(Out::Tell(
o.from,
format!(
"Nobody took your coinflip: {} back.",
economy.money(o.amount)
),
));
}
}
out
}
/// The player left: their offer ends and its stake goes back.
pub fn left(&mut self, economy: &mut Economy, uuid: u128) {
if let Some(i) = self.offers.iter().position(|o| o.from == uuid) {
let o = self.offers.remove(i);
// Refused by the log: still held, given back at the next start.
let _ = economy.release("coinflip_cancel", o.from, o.amount);
}
}
/// `/coinflip ...`; None for another command. `online` finds a player who
/// is on by name (uuid, name); `heads` flips the coin.
pub fn command(
&mut self,
economy: &mut Economy,
me: &Player,
online: &dyn Fn(&str) -> Option<(u128, String)>,
now: i64,
heads: &mut dyn FnMut() -> bool,
line: &str,
) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
if words.first() != Some(&"coinflip") {
return None;
}
let err = |s: String| Some(vec![Out::Error(s)]);
if !self.config.enabled {
return err("Games of chance are off on this server.".into());
}
let mut out = self.expire(economy, now);
let result = match words[1..] {
["list"] => {
let open: Vec<String> = self
.offers
.iter()
.filter(|o| o.to.is_none_or(|t| t == me.uuid))
.map(|o| format!("{}: {}", o.from_name, economy.money(o.amount)))
.collect();
if open.is_empty() {
vec![Out::Reply("No coinflip is waiting.".into())]
} else {
vec![Out::Reply(format!(
"Coinflips waiting: {}",
open.join(", ")
))]
}
}
["cancel"] => match self.offers.iter().position(|o| o.from == me.uuid) {
None => vec![Out::Error("You have no coinflip waiting.".into())],
Some(i) => {
let o = self.offers.remove(i);
match economy.release("coinflip_cancel", o.from, o.amount) {
Ok(()) => vec![Out::Reply(format!(
"Coinflip cancelled: {} back.",
economy.money(o.amount)
))],
Err(e) => {
self.offers.insert(i, o);
vec![Out::Error(e)]
}
}
}
},
["accept", name] => self.accept(economy, me, name, now, heads),
[amount] | [amount, _] => {
let to = match words.get(2) {
None => None,
Some(name) => match online(name) {
Some((uuid, _)) if uuid == me.uuid => {
return err("You can't flip against yourself.".into());
}
Some(found) => Some(found),
None => return err(format!("{name} is not here.")),
},
};
match parse_amount(amount) {
None => vec![Out::Error(USAGE.into())],
Some(a) => self.offer(economy, me, to, a, now),
}
}
_ => vec![Out::Error(USAGE.into())],
};
out.extend(result);
Some(out)
}
fn offer(
&mut self,
economy: &mut Economy,
me: &Player,
to: Option<(u128, String)>,
amount: i64,
now: i64,
) -> Vec<Out> {
let c = &self.config;
if !(c.min_bet..=c.max_bet).contains(&amount) {
return vec![Out::Error(format!(
"A coinflip is {} to {}.",
economy.money(c.min_bet),
economy.money(c.max_bet)
))];
}
if self.offers.iter().any(|o| o.from == me.uuid) {
return vec![Out::Error(
"You already have a coinflip waiting (/coinflip cancel).".into(),
)];
}
let wait = self.cooling(me.uuid, now);
if wait > 0 {
return vec![Out::Error(format!(
"Wait {} s before your next coinflip.",
(wait + 999) / 1000
))];
}
if let Err(e) = economy.hold("coinflip_stake", me.uuid, amount) {
return vec![Out::Error(e)];
}
self.offers.push(Offer {
from: me.uuid,
from_name: me.name.clone(),
to: to.as_ref().map(|(u, _)| *u),
amount,
at: now,
});
let money = economy.money(amount);
let secs = self.config.offer_seconds;
match to {
Some((uuid, name)) => vec![
Out::Reply(format!("Coinflip of {money} offered to {name} ({secs} s).")),
Out::Tell(
uuid,
format!(
"{} offers you a coinflip of {money}: /coinflip accept {}",
me.name, me.name
),
),
],
None => vec![Out::Reply(format!(
"Coinflip of {money} offered ({secs} s): /coinflip accept {}",
me.name
))],
}
}
fn accept(
&mut self,
economy: &mut Economy,
me: &Player,
name: &str,
now: i64,
heads: &mut dyn FnMut() -> bool,
) -> Vec<Out> {
let Some(i) = self.offers.iter().position(|o| {
o.from_name.eq_ignore_ascii_case(name) && o.to.is_none_or(|t| t == me.uuid)
}) else {
return vec![Out::Error(format!("{name} has no coinflip for you."))];
};
if self.offers[i].from == me.uuid {
return vec![Out::Error("You can't flip against yourself.".into())];
}
let wait = self.cooling(me.uuid, now);
if wait > 0 {
return vec![Out::Error(format!(
"Wait {} s before your next coinflip.",
(wait + 999) / 1000
))];
}
let amount = self.offers[i].amount;
if let Err(e) = economy.hold("coinflip_stake", me.uuid, amount) {
return vec![Out::Error(e)];
}
let o = self.offers.remove(i);
let pot = amount * 2;
let prize = pot - pot * self.config.fee_percent.clamp(0, 100) / 100;
let (winner, loser, winner_name, loser_name) = if heads() {
(o.from, me.uuid, o.from_name.as_str(), me.name.as_str())
} else {
(me.uuid, o.from, me.name.as_str(), o.from_name.as_str())
};
let stakes = [(o.from, amount), (me.uuid, amount)];
if let Err(e) = economy.award("coinflip_win", &stakes, winner, prize) {
// Not decided: both stakes go back (or, failing that, at the next start).
for (u, a) in stakes {
let _ = economy.release("coinflip_cancel", u, a);
}
return vec![Out::Error(e)];
}
self.last.insert(o.from, now);
self.last.insert(me.uuid, now);
let won = economy.money(prize);
vec![
Out::Tell(
winner,
format!("Coinflip against {loser_name}: you won {won}!"),
),
Out::Tell(
loser,
format!(
"Coinflip against {winner_name}: you lost {}.",
economy.money(amount)
),
),
]
}
}
#[cfg(test)]
mod tests {
use super::*;
const A: u128 = 1;
const B: u128 = 2;
fn setup(name: &str) -> (Economy, Games, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("smp-games-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut e = Economy::open(&dir, Default::default()).unwrap();
e.seen(A, "Alex");
e.seen(B, "Steve");
e.adjust("eco_give", A, 500, "test").unwrap();
e.adjust("eco_give", B, 500, "test").unwrap();
let g = Games::new(Config {
enabled: true,
..Default::default()
});
(e, g, dir)
}
fn player(uuid: u128) -> Player {
Player {
uuid,
name: if uuid == A { "Alex" } else { "Steve" }.into(),
level: 0,
}
}
fn online(name: &str) -> Option<(u128, String)> {
match name.to_ascii_lowercase().as_str() {
"alex" => Some((A, "Alex".into())),
"steve" => Some((B, "Steve".into())),
_ => None,
}
}
fn run(
e: &mut Economy,
g: &mut Games,
who: u128,
now: i64,
heads: bool,
line: &str,
) -> Vec<Out> {
g.command(e, &player(who), &online, now, &mut || heads, line)
.unwrap()
}
fn total(e: &Economy) -> i64 {
e.balance(A) + e.balance(B) + e.held(A) + e.held(B)
}
#[test]
fn the_stake_is_held_then_the_winner_takes_both() {
let (mut e, mut g, dir) = setup("win");
run(&mut e, &mut g, A, 0, true, "coinflip 100");
assert_eq!((e.balance(A), e.held(A)), (400, 100));
let out = run(&mut e, &mut g, B, 1000, true, "coinflip accept alex");
assert!(
matches!(&out[0], Out::Tell(A, s) if s.contains("you won 200 coins")),
"{out:?}"
);
assert_eq!(
(e.balance(A), e.balance(B), e.held(A), e.held(B)),
(600, 400, 0, 0)
);
// Tails: the taker wins.
run(&mut e, &mut g, A, 100_000, false, "coinflip 50 steve");
run(&mut e, &mut g, B, 101_000, false, "coinflip accept Alex");
assert_eq!((e.balance(A), e.balance(B)), (550, 450));
assert_eq!(total(&e), 1000, "nothing made or lost");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn limits_cooldown_and_refusals() {
let (mut e, mut g, dir) = setup("limits");
for line in [
"coinflip 5",
"coinflip 1001",
"coinflip 100 alex",
"coinflip 100 nobody",
"coinflip x",
] {
assert!(
matches!(&run(&mut e, &mut g, A, 0, true, line)[0], Out::Error(_)),
"{line}"
);
}
assert_eq!(total(&e), 1000);
assert_eq!(e.held(A), 0);
// More than the balance.
e.adjust("eco_take", B, -480, "test").unwrap();
run(&mut e, &mut g, A, 0, true, "coinflip 100");
assert!(matches!(
&run(&mut e, &mut g, B, 0, true, "coinflip accept alex")[0],
Out::Error(_)
));
assert_eq!(e.held(A), 100, "the offer still waits");
// Only the named player may take a named offer.
run(&mut e, &mut g, A, 0, true, "coinflip cancel");
e.adjust("eco_give", B, 480, "test").unwrap();
// Cooldown after a game.
run(&mut e, &mut g, A, 0, true, "coinflip 10");
run(&mut e, &mut g, B, 0, true, "coinflip accept alex");
assert!(
matches!(&run(&mut e, &mut g, A, 10_000, true, "coinflip 10")[0], Out::Error(s) if s.contains("Wait 20 s"))
);
assert!(matches!(
&run(&mut e, &mut g, A, 30_000, true, "coinflip 10")[0],
Out::Reply(_)
));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn off_by_default() {
let (mut e, _, dir) = setup("off");
let mut g = Games::default();
assert!(
matches!(&run(&mut e, &mut g, A, 0, true, "coinflip 100")[0], Out::Error(s) if s.contains("off"))
);
assert_eq!(e.held(A), 0);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn every_way_out_gives_the_stake_back() {
let (mut e, mut g, dir) = setup("out");
run(&mut e, &mut g, A, 0, true, "coinflip 100");
run(&mut e, &mut g, A, 0, true, "coinflip cancel");
assert_eq!((e.balance(A), e.held(A)), (500, 0));
run(&mut e, &mut g, A, 0, true, "coinflip 100");
let out = g.expire(&mut e, 60_000);
assert!(matches!(&out[0], Out::Tell(A, _)));
assert_eq!((e.balance(A), e.held(A)), (500, 0));
run(&mut e, &mut g, A, 0, true, "coinflip 100");
g.left(&mut e, A);
assert_eq!((e.balance(A), e.held(A)), (500, 0));
// A crash with a stake held: saved, then opened again, it goes back.
run(&mut e, &mut g, A, 0, true, "coinflip 100");
e.save().unwrap();
let again = Economy::open(&dir, Default::default()).unwrap();
assert_eq!((again.balance(A), again.held(A)), (500, 0));
std::fs::remove_dir_all(dir).unwrap();
}
}
+258
View File
@@ -0,0 +1,258 @@
// The lottery (`/lottery`): players buy tickets with the in-game currency;
// once `lottery_seconds` have passed since a round's first ticket, one ticket
// is drawn at random and its owner takes the pot. The tickets' money is held
// in `economy.json` (`Economy::hold`) until the draw, so a crash or a restart
// gives every ticket back and never loses or doubles the pot. A round with a
// single player is given back too: there is nobody to win from. Part of
// `gambling` (off by default); nothing here is bought with real money or
// paid out of the game. No game types; the draw is the glue's random, never
// the world's. Added to the game as `game::smp_lottery`; also a module of
// this crate, for its tests.
use super::smp_economy::{Economy, Out, Player, parse_amount};
use super::smp_games::Config;
/// The round being sold.
#[derive(Default)]
pub struct Lottery {
/// Tickets by player, in the order they first bought: (uuid, name, tickets).
pub tickets: Vec<(u128, String, i64)>,
/// When the round draws (ms); None until its first ticket.
pub draw_at: Option<i64>,
}
impl Lottery {
fn pot(&self, price: i64) -> i64 {
self.tickets.iter().map(|t| t.2).sum::<i64>() * price
}
/// `/lottery` and `/lottery buy <tickets>`; None for another command.
pub fn command(
&mut self,
config: &Config,
economy: &mut Economy,
me: &Player,
now: i64,
line: &str,
) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
if words.first() != Some(&"lottery") {
return None;
}
if !config.enabled {
return Some(vec![Out::Error(
"Games of chance are off on this server.".into(),
)]);
}
let price = config.ticket_price.max(1);
let mine = self
.tickets
.iter()
.find(|t| t.0 == me.uuid)
.map_or(0, |t| t.2);
Some(match words[1..] {
[] => {
let when = match self.draw_at {
Some(t) => format!("draws in {} s", ((t - now).max(0) + 999) / 1000),
None => "draws once tickets are bought".into(),
};
vec![Out::Reply(format!(
"Lottery: pot {}, {} ticket(s) of yours, {when}. A ticket costs {}: /lottery buy <tickets>",
economy.money(self.pot(price)),
mine,
economy.money(price)
))]
}
["buy", n] => match parse_amount(n) {
None => vec![Out::Error("Usage: /lottery buy <tickets>".into())],
Some(n) if mine + n > config.max_tickets => vec![Out::Error(format!(
"{} tickets a draw at most; you have {mine}.",
config.max_tickets
))],
Some(n) => match economy.hold("lottery_ticket", me.uuid, n * price) {
Err(e) => vec![Out::Error(e)],
Ok(()) => {
match self.tickets.iter_mut().find(|t| t.0 == me.uuid) {
Some(t) => t.2 += n,
None => self.tickets.push((me.uuid, me.name.clone(), n)),
}
let draw = *self
.draw_at
.get_or_insert(now + config.lottery_seconds * 1000);
vec![Out::Reply(format!(
"You bought {n} ticket(s) for {}; the pot is {}, drawn in {} s.",
economy.money(n * price),
economy.money(self.pot(price)),
((draw - now).max(0) + 999) / 1000
))]
}
},
},
_ => vec![Out::Error("Usage: /lottery, /lottery buy <tickets>".into())],
})
}
/// Draws the round when its time has come: `pick(n)` is a random number
/// below `n`. Nothing before then.
pub fn tick(
&mut self,
config: &Config,
economy: &mut Economy,
now: i64,
pick: &mut dyn FnMut(u64) -> u64,
) -> Vec<Out> {
if self.draw_at.is_none_or(|t| now < t) {
return Vec::new();
}
let price = config.ticket_price.max(1);
let round = std::mem::take(self);
let stakes: Vec<(u128, i64)> = round.tickets.iter().map(|t| (t.0, t.2 * price)).collect();
if round.tickets.len() < 2 {
// Nobody to win from: the tickets go back.
for (uuid, amount) in &stakes {
let _ = economy.release("lottery_refund", *uuid, *amount);
}
return round
.tickets
.iter()
.map(|t| {
Out::Tell(
t.0,
"The lottery had no other player: your tickets were given back.".into(),
)
})
.collect();
}
let total: i64 = round.tickets.iter().map(|t| t.2).sum();
let mut ticket = pick(total as u64) as i64;
let winner = round
.tickets
.iter()
.find(|t| {
ticket -= t.2;
ticket < 0
})
.expect("the ticket is below the total");
let pot = total * price;
let prize = pot - pot * config.fee_percent.clamp(0, 100) / 100;
match economy.award("lottery_win", &stakes, winner.0, prize) {
Ok(()) => vec![Out::All(format!(
"Lottery: {} won {} with {} of {total} tickets!",
winner.1,
economy.money(prize),
winner.2
))],
Err(_) => {
// Not drawn: the tickets go back (or at the next start).
for (uuid, amount) in &stakes {
let _ = economy.release("lottery_refund", *uuid, *amount);
}
vec![Out::All(
"The lottery couldn't be drawn: every ticket was given back.".into(),
)]
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const A: u128 = 1;
const B: u128 = 2;
fn setup(name: &str) -> (Economy, Config, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("smp-lottery-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut e = Economy::open(&dir, Default::default()).unwrap();
e.seen(A, "Alex");
e.seen(B, "Steve");
e.adjust("eco_give", A, 500, "test").unwrap();
e.adjust("eco_give", B, 500, "test").unwrap();
let c = Config {
enabled: true,
..Default::default()
};
(e, c, dir)
}
fn player(uuid: u128) -> Player {
Player {
uuid,
name: if uuid == A { "Alex" } else { "Steve" }.into(),
level: 0,
}
}
#[test]
fn tickets_are_held_and_one_is_drawn_for_the_pot() {
let (mut e, c, dir) = setup("draw");
let mut l = Lottery::default();
l.command(&c, &mut e, &player(A), 0, "lottery buy 3")
.unwrap();
l.command(&c, &mut e, &player(B), 1000, "lottery buy 1")
.unwrap();
assert_eq!(
(e.balance(A), e.held(A), e.balance(B), e.held(B)),
(470, 30, 490, 10)
);
// Not yet: the round draws an hour after its first ticket.
assert!(l.tick(&c, &mut e, 3_599_999, &mut |_| 0).is_empty());
// Ticket 3 (the fourth) is Steve's.
let out = l.tick(&c, &mut e, 3_600_000, &mut |n| {
assert_eq!(n, 4);
3
});
assert!(
matches!(&out[0], Out::All(s) if s.contains("Steve won 40 coins with 1 of 4")),
"{out:?}"
);
assert_eq!(
(e.balance(A), e.balance(B), e.held(A), e.held(B)),
(470, 530, 0, 0)
);
assert!(l.tickets.is_empty() && l.draw_at.is_none(), "a new round");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_round_of_one_player_is_given_back() {
let (mut e, c, dir) = setup("alone");
let mut l = Lottery::default();
l.command(&c, &mut e, &player(A), 0, "lottery buy 5")
.unwrap();
l.tick(&c, &mut e, 3_600_000, &mut |_| 0);
assert_eq!((e.balance(A), e.held(A)), (500, 0));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn limits_and_off_by_default() {
let (mut e, c, dir) = setup("limits");
let mut l = Lottery::default();
let out = l
.command(&c, &mut e, &player(A), 0, "lottery buy 101")
.unwrap();
assert!(matches!(&out[0], Out::Error(s) if s.contains("100 tickets")));
let out = l
.command(&c, &mut e, &player(A), 0, "lottery buy 51")
.unwrap();
assert!(
matches!(&out[0], Out::Error(_)),
"510 coins is more than 500"
);
assert_eq!(e.held(A), 0);
let off = Config::default();
let out = l
.command(&off, &mut e, &player(A), 0, "lottery buy 1")
.unwrap();
assert!(matches!(&out[0], Out::Error(s) if s.contains("off")));
// A restart gives held tickets back.
l.command(&c, &mut e, &player(A), 0, "lottery buy 2")
.unwrap();
e.save().unwrap();
let again = Economy::open(&dir, Default::default()).unwrap();
assert_eq!((again.balance(A), again.held(A)), (500, 0));
std::fs::remove_dir_all(dir).unwrap();
}
}
File diff suppressed because it is too large. Load diff
+784
View File
@@ -0,0 +1,784 @@
// Places: the server's spawn (`/spawn`, `/setspawn`), each player's homes
// (`/home`, `/sethome`, `/delhome`, `/homes`), warps (`/warp`, `/warps`,
// `/setwarp`, `/delwarp`), teleport requests between players (`/tpa`,
// `/tpaccept`, `/tpdeny`), `/back` (where the player died, or was before
// their last teleport), `/playtime` and `/rules`. A teleport waits `warmup_seconds`,
// and a player who moves or is hurt meanwhile stays where they are; the glue
// moves them with a vanilla `tp` run as the server. The number of homes grows
// with the time a player has played here, counted by the mod. Everything is
// in `places.json`, written atomically. No game types. Added to the game as
// `game::smp_places`; 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::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
/// `config.json`'s `places` section.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// How long a player stands still before a teleport (0: at once).
pub warmup_seconds: i64,
/// How long a `/tpa` request waits for its answer.
pub request_seconds: i64,
/// Between two teleports of one player.
pub cooldown_seconds: i64,
/// Homes a player may set, by hours played here: `[hours, homes]`, the
/// last line reached counts.
pub homes: Vec<[i64; 2]>,
/// `/rules`, a line each.
pub rules: Vec<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
warmup_seconds: 3,
request_seconds: 60,
cooldown_seconds: 10,
homes: vec![[0, 1], [5, 2], [20, 3], [100, 5]],
rules: vec![
"Be respectful to everyone.".into(),
"No griefing, no stealing, no cheats.".into(),
"The currency is earned in game only: never bought or sold for real money.".into(),
],
}
}
}
/// A place in a level.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Place {
/// `minecraft:overworld`, `minecraft:the_nether`, `minecraft:the_end`.
pub dimension: String,
pub x: f64,
pub y: f64,
pub z: f64,
}
/// A player on the server, as the places see them.
#[derive(Clone, Debug, PartialEq)]
pub struct Body {
pub uuid: u128,
pub name: String,
pub at: Place,
pub health: f32,
}
/// `places.json`.
#[derive(Default, Serialize, Deserialize)]
#[serde(default)]
struct Data {
spawn: Option<Place>,
warps: BTreeMap<String, Place>,
/// Homes by player UUID, then by name.
homes: BTreeMap<String, BTreeMap<String, Place>>,
/// Seconds played here, by player UUID.
played: BTreeMap<String, i64>,
}
/// Where a teleport goes.
#[derive(Clone, Debug, PartialEq)]
enum To {
/// A place, and what it is (`home "base"`).
Place(Place, String),
/// A player, wherever they are when the warmup ends.
Player(u128, String),
}
/// A teleport waiting for its warmup.
#[derive(Clone, Debug, PartialEq)]
pub struct Pending {
pub uuid: u128,
to: To,
from: Place,
health: f32,
due: i64,
}
/// A `/tpa` request: `from` asks to go to `to`.
struct Request {
from: u128,
from_name: String,
to: u128,
at: i64,
}
/// A teleport to make now: the player's name and where.
#[derive(Clone, Debug, PartialEq)]
pub struct Teleport {
pub uuid: u128,
pub name: String,
pub to: Place,
/// What the player is told once there.
pub done: String,
}
/// The places' commands.
pub const COMMANDS: [&str; 16] = [
"spawn", "setspawn", "home", "sethome", "delhome", "homes", "warp", "warps", "setwarp",
"delwarp", "tpa", "tpaccept", "tpdeny", "rules", "back", "playtime",
];
pub struct Places {
pub config: Config,
path: PathBuf,
data: Data,
/// Homes, warps or the spawn changed: written at the next save.
changed: bool,
/// Time played changed: written once a minute.
played_at: Option<i64>,
pub pending: Vec<Pending>,
requests: Vec<Request>,
last: HashMap<u128, i64>,
/// Where `/back` goes: the place and whether they died there. Not kept
/// over a restart.
back: HashMap<u128, (Place, bool)>,
/// The players seen dead (health 0) and not respawned yet.
dead: std::collections::HashSet<u128>,
}
/// `2 h 05 min`, `12 min`.
fn duration(seconds: i64) -> String {
let (h, m) = (seconds / 3600, seconds % 3600 / 60);
if h > 0 {
format!("{h} h {m:02} min")
} else {
format!("{m} min")
}
}
/// A home's or a warp's name: 1 to 16 of `a-z 0-9 _ -`.
fn name_ok(name: &str) -> bool {
(1..=16).contains(&name.len())
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "_-".contains(c))
}
fn moved(a: &Place, b: &Place) -> bool {
let d = (a.x - b.x).powi(2) + (a.y - b.y).powi(2) + (a.z - b.z).powi(2);
a.dimension != b.dimension || d > 0.25
}
impl Places {
/// `places.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("places.json");
let data = smp_store::read_json(&path)?;
Ok(Self {
config,
path,
data,
changed: false,
played_at: None,
pending: Vec::new(),
requests: Vec::new(),
last: HashMap::new(),
back: HashMap::new(),
dead: Default::default(),
})
}
/// Writes `places.json` when homes, warps or the spawn changed, or once
/// a minute when only the time played did.
pub fn save(&mut self, now: i64) -> Result<(), String> {
let played = self.played_at.is_some_and(|t| now - t >= 60_000);
if !self.changed && !played {
return Ok(());
}
smp_store::write_json(&self.path, &self.data)?;
self.changed = false;
self.played_at = None;
Ok(())
}
/// Counts a second played for each of these players.
pub fn played(&mut self, uuids: &[u128], now: i64) {
for &u in uuids {
*self
.data
.played
.entry(smp_store::uuid_string(u))
.or_default() += 1;
}
if !uuids.is_empty() {
self.played_at.get_or_insert(now);
}
}
/// The seconds a player has played here.
pub fn played_seconds(&self, uuid: u128) -> i64 {
self.data
.played
.get(&smp_store::uuid_string(uuid))
.copied()
.unwrap_or(0)
}
/// How many homes a player may have now.
pub fn home_limit(&self, uuid: u128) -> usize {
let hours = self
.data
.played
.get(&smp_store::uuid_string(uuid))
.copied()
.unwrap_or(0)
/ 3600;
self.config
.homes
.iter()
.filter(|[h, _]| hours >= *h)
.map(|[_, n]| (*n).max(0) as usize)
.next_back()
.unwrap_or(0)
}
/// Starts a teleport's warmup, or says why not.
fn start(&mut self, me: &Body, to: To, now: i64) -> Vec<Out> {
if let Some(&t) = self.last.get(&me.uuid) {
let wait = t + self.config.cooldown_seconds * 1000 - now;
if wait > 0 {
return vec![Out::Error(format!(
"Wait {} s before your next teleport.",
(wait + 999) / 1000
))];
}
}
self.pending.retain(|p| p.uuid != me.uuid);
self.pending.push(Pending {
uuid: me.uuid,
to,
from: me.at.clone(),
health: me.health,
due: now + self.config.warmup_seconds.max(0) * 1000,
});
if self.config.warmup_seconds > 0 {
vec![Out::Reply(format!(
"Teleporting in {} s: don't move.",
self.config.warmup_seconds
))]
} else {
Vec::new()
}
}
/// The places' commands; None for another command. `bodies` are the
/// players on, `me` among them.
pub fn command(
&mut self,
me: &Player,
bodies: &[Body],
now: i64,
line: &str,
) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
let command = *words.first()?;
if !COMMANDS.contains(&command) {
return None;
}
let Some(body) = bodies.iter().find(|b| b.uuid == me.uuid) else {
return Some(vec![Out::Error("You are not in the world.".into())]);
};
let key = smp_store::uuid_string(me.uuid);
let named = |n: &str| bodies.iter().find(|b| b.name.eq_ignore_ascii_case(n));
Some(match (command, &words[1..]) {
("back", []) => match self.back.get(&me.uuid).cloned() {
Some((p, died)) => {
let what = if died {
"where you died"
} else {
"where you were"
};
self.start(body, To::Place(p, what.into()), now)
}
None => vec![Out::Error(
"Nowhere to go back to yet: /back follows a death or a teleport.".into(),
)],
},
("playtime", []) => vec![Out::Reply(format!(
"You have played here for {}.",
duration(self.played_seconds(me.uuid))
))],
("playtime", [name]) => match named(name) {
Some(b) => vec![Out::Reply(format!(
"{} has played here for {}.",
b.name,
duration(self.played_seconds(b.uuid))
))],
None => vec![Out::Error(format!("{name} isn't on."))],
},
("rules", []) => {
let mut out = vec![Out::Reply("Rules of this server:".into())];
for (i, r) in self.config.rules.iter().enumerate() {
out.push(Out::Reply(format!("{}. {r}", i + 1)));
}
out
}
("spawn", []) => match self.data.spawn.clone() {
Some(p) => self.start(body, To::Place(p, "the spawn".into()), now),
None => vec![Out::Error(
"No spawn yet: an operator sets it with /setspawn.".into(),
)],
},
("setspawn", []) if me.level >= 3 => {
self.data.spawn = Some(body.at.clone());
self.changed = true;
vec![Out::Reply("The spawn is here now.".into())]
}
("homes", []) => {
let homes = self.data.homes.get(&key);
let names: Vec<&str> = homes
.into_iter()
.flat_map(|h| h.keys())
.map(String::as_str)
.collect();
vec![Out::Reply(format!(
"Your homes ({} of {}): {}",
names.len(),
self.home_limit(me.uuid),
if names.is_empty() {
"none yet, /sethome [name]".into()
} else {
names.join(", ")
}
))]
}
("home", rest) | ("sethome", rest) | ("delhome", rest) if rest.len() <= 1 => {
let name = rest
.first()
.map_or("home".to_owned(), |n| n.to_ascii_lowercase());
if !name_ok(&name) {
return Some(vec![Out::Error(
"A home's name: up to 16 of a-z, 0-9, _ and -.".into(),
)]);
}
let homes = self.data.homes.get(&key);
let has = homes.is_some_and(|h| h.contains_key(&name));
match command {
"home" => match homes.and_then(|h| h.get(&name)).cloned() {
Some(p) => self.start(body, To::Place(p, format!("home \"{name}\"")), now),
None => vec![Out::Error(format!(
"No home \"{name}\": /homes lists yours."
))],
},
"sethome" => {
let count = homes.map_or(0, |h| h.len());
let limit = self.home_limit(me.uuid);
if !has && count >= limit {
vec![Out::Error(format!(
"You have {count} of {limit} homes: play longer for more, or move one (/sethome <name> over it)."
))]
} else {
self.data
.homes
.entry(key)
.or_default()
.insert(name.clone(), body.at.clone());
self.changed = true;
vec![Out::Reply(format!("Home \"{name}\" set here."))]
}
}
_ if has => {
if let Some(h) = self.data.homes.get_mut(&key) {
h.remove(&name);
if h.is_empty() {
self.data.homes.remove(&key);
}
}
self.changed = true;
vec![Out::Reply(format!("Home \"{name}\" deleted."))]
}
_ => vec![Out::Error(format!("No home \"{name}\"."))],
}
}
("warps", []) => {
let names: Vec<&str> = self.data.warps.keys().map(String::as_str).collect();
vec![Out::Reply(if names.is_empty() {
"No warps yet.".into()
} else {
format!("Warps: {}", names.join(", "))
})]
}
("warp", [name]) => match self.data.warps.get(&name.to_ascii_lowercase()).cloned() {
Some(p) => self.start(
body,
To::Place(p, format!("warp \"{}\"", name.to_ascii_lowercase())),
now,
),
None => vec![Out::Error(format!(
"No warp \"{name}\": /warps lists them."
))],
},
("setwarp", [name]) if me.level >= 3 => {
let name = name.to_ascii_lowercase();
if !name_ok(&name) {
return Some(vec![Out::Error(
"A warp's name: up to 16 of a-z, 0-9, _ and -.".into(),
)]);
}
self.data.warps.insert(name.clone(), body.at.clone());
self.changed = true;
vec![Out::Reply(format!("Warp \"{name}\" set here."))]
}
("delwarp", [name]) if me.level >= 3 => {
if self.data.warps.remove(&name.to_ascii_lowercase()).is_some() {
self.changed = true;
vec![Out::Reply(format!("Warp \"{name}\" deleted."))]
} else {
vec![Out::Error(format!("No warp \"{name}\"."))]
}
}
("tpa", [name]) => match named(name) {
None => vec![Out::Error(format!("{name} is not on."))],
Some(t) if t.uuid == me.uuid => vec![Out::Error("That's you.".into())],
Some(t) => {
self.requests.retain(|r| r.from != me.uuid);
self.requests.push(Request {
from: me.uuid,
from_name: me.name.clone(),
to: t.uuid,
at: now,
});
vec![
Out::Reply(format!(
"Asked {} to teleport to them ({} s to answer).",
t.name, self.config.request_seconds
)),
Out::Tell(
t.uuid,
format!(
"{} asks to teleport to you: /tpaccept {0} or /tpdeny {0}.",
me.name
),
),
]
}
},
("tpaccept", rest) | ("tpdeny", rest) if rest.len() <= 1 => {
let found = self.requests.iter().rposition(|r| {
r.to == me.uuid
&& rest
.first()
.is_none_or(|n| r.from_name.eq_ignore_ascii_case(n))
});
let Some(i) = found else {
return Some(vec![Out::Error("No teleport request to answer.".into())]);
};
let r = self.requests.remove(i);
if command == "tpdeny" {
return Some(vec![
Out::Reply(format!("You turned down {}'s request.", r.from_name)),
Out::Tell(r.from, format!("{} turned down your request.", me.name)),
]);
}
let Some(from) = bodies.iter().find(|b| b.uuid == r.from) else {
return Some(vec![Out::Error(format!("{} is gone.", r.from_name))]);
};
let mut out = vec![Out::Reply(format!("{} is on the way.", r.from_name))];
let start = self.start(from, To::Player(me.uuid, me.name.clone()), now);
// The warmup's lines are for the one who moves.
out.extend(start.into_iter().map(|o| match o {
Out::Reply(s) | Out::Error(s) => Out::Tell(r.from, s),
o => o,
}));
out
}
_ => vec![Out::Error(
match command {
"home" | "sethome" | "delhome" => {
"Usage: /home, /sethome, /delhome [name]; /homes"
}
"warp" | "setwarp" | "delwarp" => "Usage: /warp <name>; /warps",
"tpa" => "Usage: /tpa <player>",
"tpaccept" | "tpdeny" => "Usage: /tpaccept [player], /tpdeny [player]",
_ => "You may not use this command.",
}
.into(),
)],
})
}
/// Each tick: warmups that ended become teleports, those of players who
/// moved, were hurt or left are called off; requests nobody answered
/// expire.
pub fn tick(&mut self, bodies: &[Body], now: i64) -> (Vec<Out>, Vec<Teleport>) {
let mut out = Vec::new();
let mut go = Vec::new();
let wait = self.config.request_seconds * 1000;
self.requests.retain(|r| {
let keep = now - r.at < wait;
if !keep {
out.push(Out::Tell(r.from, "Your teleport request expired.".into()));
}
keep
});
// A death, seen once a second: /back goes there.
for b in bodies {
if b.health <= 0.0 {
if self.dead.insert(b.uuid) {
self.back.insert(b.uuid, (b.at.clone(), true));
}
} else {
self.dead.remove(&b.uuid);
}
}
for p in std::mem::take(&mut self.pending) {
let Some(me) = bodies.iter().find(|b| b.uuid == p.uuid) else {
continue;
};
if moved(&p.from, &me.at) {
out.push(Out::Tell(p.uuid, "Teleport called off: you moved.".into()));
} else if me.health < p.health || me.health <= 0.0 {
out.push(Out::Tell(
p.uuid,
"Teleport called off: you were hurt.".into(),
));
} else if now < p.due {
self.pending.push(p);
} else {
let (to, what) = match &p.to {
To::Place(place, what) => (Some(place.clone()), what.clone()),
To::Player(u, name) => (
bodies.iter().find(|b| b.uuid == *u).map(|b| b.at.clone()),
name.clone(),
),
};
match to {
Some(to) => {
self.last.insert(p.uuid, now);
self.back.insert(p.uuid, (p.from.clone(), false));
go.push(Teleport {
uuid: p.uuid,
name: me.name.clone(),
to,
done: format!("Teleported to {what}."),
});
}
None => out.push(Out::Tell(
p.uuid,
format!("Teleport called off: {what} is gone."),
)),
}
}
}
(out, go)
}
}
#[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-places-{}-{n}", std::process::id()))
}
fn place(x: f64) -> Place {
Place {
dimension: "minecraft:overworld".into(),
x,
y: 64.0,
z: 0.0,
}
}
fn body(uuid: u128, x: f64) -> Body {
Body {
uuid,
name: if uuid == A { "Alex" } else { "Steve" }.into(),
at: place(x),
health: 20.0,
}
}
fn player(uuid: u128, level: u8) -> Player {
Player {
uuid,
name: if uuid == A { "Alex" } else { "Steve" }.into(),
level,
}
}
#[test]
fn a_home_is_reached_after_the_warmup_unless_the_player_moves() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
let here = [body(A, 10.0)];
let out = p.command(&player(A, 0), &here, 0, "sethome base").unwrap();
assert_eq!(out, vec![Out::Reply("Home \"base\" set here.".into())]);
let there = [body(A, 500.0)];
p.command(&player(A, 0), &there, 0, "home base").unwrap();
assert!(p.tick(&there, 2_999).1.is_empty(), "the warmup");
let (_, go) = p.tick(&there, 3_000);
assert_eq!(go[0].to, place(10.0));
assert_eq!(go[0].name, "Alex");
// The cooldown, then a move calls the next one off.
let out = p
.command(&player(A, 0), &there, 5_000, "home base")
.unwrap();
assert!(
matches!(&out[0], Out::Error(s) if s.contains("Wait")),
"{out:?}"
);
p.command(&player(A, 0), &there, 13_000, "home base")
.unwrap();
let (out, go) = p.tick(&[body(A, 501.0)], 14_000);
assert!(go.is_empty());
assert_eq!(
out,
vec![Out::Tell(A, "Teleport called off: you moved.".into())]
);
// Saved and read back.
p.save(0).unwrap();
let again = Places::open(&d, Config::default()).unwrap();
assert_eq!(
again.data.homes[&smp_store::uuid_string(A)]["base"],
place(10.0)
);
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn back_goes_where_the_player_died_or_was_before_a_teleport() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
let out = p
.command(&player(A, 0), &[body(A, 0.0)], 0, "back")
.unwrap();
assert!(matches!(&out[0], Out::Error(s) if s.contains("Nowhere")));
// Dead at x 40: seen by the next tick, once.
let mut dead = body(A, 40.0);
dead.health = 0.0;
p.tick(&[dead.clone()], 1_000);
dead.at = place(41.0);
p.tick(&[dead], 2_000);
let alive = [body(A, 0.0)];
p.tick(&alive, 3_000);
p.command(&player(A, 0), &alive, 3_000, "back").unwrap();
let (_, go) = p.tick(&alive, 6_000);
assert_eq!(go[0].to, place(40.0));
assert_eq!(go[0].done, "Teleported to where you died.");
// Then /back goes to where that teleport started.
let there = [body(A, 40.0)];
p.command(&player(A, 0), &there, 20_000, "back").unwrap();
let (_, go) = p.tick(&there, 23_000);
assert_eq!(go[0].to, place(0.0));
assert_eq!(go[0].done, "Teleported to where you were.");
p.played(&[A], 0);
let out = p.command(&player(A, 0), &there, 0, "playtime").unwrap();
assert_eq!(
out,
vec![Out::Reply("You have played here for 0 min.".into())]
);
assert_eq!(duration(7_500), "2 h 05 min");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn homes_grow_with_time_played() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
let here = [body(A, 0.0)];
p.command(&player(A, 0), &here, 0, "sethome").unwrap();
let out = p.command(&player(A, 0), &here, 0, "sethome two").unwrap();
assert!(
matches!(&out[0], Out::Error(s) if s.contains("1 of 1")),
"{out:?}"
);
// Moving a home is always allowed.
let out = p.command(&player(A, 0), &here, 0, "sethome home").unwrap();
assert!(matches!(&out[0], Out::Reply(_)));
p.save(0).unwrap();
for _ in 0..5 * 3600 {
p.played(&[A], 0);
}
assert_eq!(p.home_limit(A), 2);
// Time played is written once a minute.
p.save(59_999).unwrap();
assert_eq!(
Places::open(&d, Config::default()).unwrap().home_limit(A),
1
);
p.save(60_000).unwrap();
let out = p.command(&player(A, 0), &here, 0, "sethome two").unwrap();
assert!(matches!(&out[0], Out::Reply(_)), "{out:?}");
let again = Places::open(&d, Config::default()).unwrap();
assert_eq!(again.home_limit(A), 2);
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn a_request_moves_the_asker_to_the_one_who_accepts() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
let both = [body(A, 0.0), body(B, 100.0)];
let out = p.command(&player(A, 0), &both, 0, "tpa steve").unwrap();
assert!(
matches!(&out[1], Out::Tell(B, s) if s.contains("/tpaccept Alex")),
"{out:?}"
);
let out = p.command(&player(B, 0), &both, 1_000, "tpaccept").unwrap();
assert_eq!(
out[1],
Out::Tell(A, "Teleporting in 3 s: don't move.".into())
);
// Steve walked on meanwhile: Alex lands where Steve is then.
let later = [body(A, 0.0), body(B, 120.0)];
let (_, go) = p.tick(&later, 4_000);
assert_eq!((go[0].uuid, go[0].to.x), (A, 120.0));
// A hurt player stays; an unanswered request expires.
p.command(&player(A, 0), &later, 20_000, "tpa steve")
.unwrap();
p.command(&player(B, 0), &later, 20_000, "tpaccept alex")
.unwrap();
let mut hurt = later.clone();
hurt[0].health = 15.0;
let (out, go) = p.tick(&hurt, 21_000);
assert!(go.is_empty());
assert_eq!(
out,
vec![Out::Tell(A, "Teleport called off: you were hurt.".into())]
);
p.command(&player(A, 0), &later, 30_000, "tpa steve")
.unwrap();
let (out, _) = p.tick(&later, 90_000);
assert_eq!(
out,
vec![Out::Tell(A, "Your teleport request expired.".into())]
);
let out = p.command(&player(B, 0), &later, 90_000, "tpdeny").unwrap();
assert!(matches!(&out[0], Out::Error(_)));
}
#[test]
fn spawn_and_warps_are_set_by_operators() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
let here = [body(A, 7.0)];
let out = p.command(&player(A, 0), &here, 0, "spawn").unwrap();
assert!(matches!(&out[0], Out::Error(s) if s.contains("/setspawn")));
let out = p.command(&player(A, 0), &here, 0, "setspawn").unwrap();
assert!(matches!(&out[0], Out::Error(_)), "level 0");
p.command(&player(A, 3), &here, 0, "setspawn").unwrap();
p.command(&player(A, 3), &here, 0, "setwarp Shop").unwrap();
let out = p.command(&player(A, 0), &here, 0, "warps").unwrap();
assert_eq!(out, vec![Out::Reply("Warps: shop".into())]);
p.command(&player(A, 0), &[body(A, 50.0)], 0, "spawn")
.unwrap();
let (_, go) = p.tick(&[body(A, 50.0)], 3_000);
assert_eq!(
(go[0].to.x, go[0].done.as_str()),
(7.0, "Teleported to the spawn.")
);
let out = p.command(&player(A, 0), &here, 0, "rules").unwrap();
assert_eq!(out.len(), 4);
let _ = std::fs::remove_dir_all(d);
}
}
+382
View File
@@ -0,0 +1,382 @@
// Who is on, and how: a welcome for a player's first join (a line to
// everyone, the welcome and the rules to the newcomer), a reward once a day
// for being on, AFK players (no move, no turn and no command for
// `afk_minutes`, or `/afk`) left out of the sleep count, and a limit on how
// fast one player runs the SMP's commands. Looked at once a second, never
// per tick. No game types. Added to the game as `game::smp_presence`; 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::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
pub const COMMANDS: [&str; 1] = ["afk"];
/// `config.json`'s `presence` section.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// The line everyone sees when a player joins for the first time
/// (`{player}` filled in); empty: none.
pub first_join: String,
/// What the newcomer reads, before the rules.
pub welcome: Vec<String>,
/// Paid once a day (UTC) to each player who is on; 0: none.
pub daily_reward: i64,
/// Minutes without a move, a turn or a command before a player is AFK;
/// 0: only `/afk`.
pub afk_minutes: i64,
/// Whether everyone hears that a player is AFK (else only them).
pub afk_announce: bool,
/// At most this many SMP commands in `spam_seconds` a player (operators
/// of level 3 aside); 0: no limit.
pub spam_commands: usize,
pub spam_seconds: i64,
}
impl Default for Config {
fn default() -> Self {
Self {
first_join: "{player} joined for the first time: welcome!".into(),
welcome: vec![
"Welcome to the server, {player}!".into(),
"Set a home with /sethome, claim your land with /claim, see the richest with /baltop.".into(),
],
daily_reward: 50,
afk_minutes: 5,
afk_announce: false,
spam_commands: 8,
spam_seconds: 5,
}
}
}
/// `presence.json`.
#[derive(Default, Serialize, Deserialize)]
#[serde(default)]
struct Data {
/// The players who have joined, by UUID.
known: BTreeSet<String>,
/// The last day (UTC, `yyyy-mm-dd`) each player got the daily reward.
rewarded: BTreeMap<String, String>,
}
/// A player on, as the presence sees them: where they are and look.
pub struct Seen {
pub uuid: u128,
pub name: String,
/// x, y, z, yaw, pitch.
pub pose: [f64; 5],
}
/// What the game does for the presence.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Act {
/// Pays the daily reward (logged as `daily_reward`).
Reward(u128),
/// Leaves the player out of the sleep count, or counts them again.
Sleep(u128, bool),
}
pub struct Presence {
pub config: Config,
path: PathBuf,
data: Data,
dirty: bool,
/// Each player on: their pose a second ago, and when they last moved,
/// turned or ran a command (ms).
on: HashMap<u128, ([f64; 5], i64)>,
afk: HashSet<u128>,
/// Each player's last commands (ms), for the limit.
recent: HashMap<u128, VecDeque<i64>>,
}
impl Presence {
/// Opens `presence.json`. Without it, the players `known` already (the
/// economy's accounts) aren't welcomed as newcomers.
pub fn open(
dir: &Path,
config: Config,
known: impl IntoIterator<Item = u128>,
) -> Result<Self, String> {
let path = dir.join("presence.json");
let fresh = !path.exists();
let mut data: Data = smp_store::read_json(&path)?;
if fresh {
data.known = known.into_iter().map(smp_store::uuid_string).collect();
}
Ok(Self {
config,
path,
data,
dirty: fresh,
on: HashMap::new(),
afk: HashSet::new(),
recent: HashMap::new(),
})
}
pub fn save(&mut self) -> Result<(), String> {
if self.dirty {
smp_store::write_json(&self.path, &self.data)?;
self.dirty = false;
}
Ok(())
}
/// Once a second: who joined, who left, who moved; `today` is the UTC
/// date, `rules` `/rules`' lines.
pub fn tick(
&mut self,
players: &[Seen],
today: &str,
now: i64,
rules: &[String],
) -> (Vec<Out>, Vec<Act>) {
let (mut out, mut acts) = (Vec::new(), Vec::new());
let gone: Vec<u128> = self
.on
.keys()
.filter(|u| !players.iter().any(|p| p.uuid == **u))
.copied()
.collect();
for uuid in gone {
self.on.remove(&uuid);
self.recent.remove(&uuid);
if self.afk.remove(&uuid) {
acts.push(Act::Sleep(uuid, false));
}
}
for p in players {
let key = smp_store::uuid_string(p.uuid);
let Some((pose, active)) = self.on.get_mut(&p.uuid) else {
// Joined.
self.on.insert(p.uuid, (p.pose, now));
if self.data.known.insert(key.clone()) {
self.dirty = true;
let fill = |l: &str| l.replace("{player}", &p.name);
if !self.config.first_join.is_empty() {
out.push(Out::All(fill(&self.config.first_join)));
}
out.extend(
self.config
.welcome
.iter()
.map(|l| Out::Tell(p.uuid, fill(l))),
);
if !rules.is_empty() {
out.push(Out::Tell(p.uuid, "The rules:".into()));
out.extend(
rules
.iter()
.enumerate()
.map(|(i, r)| Out::Tell(p.uuid, format!("{}. {r}", i + 1))),
);
}
}
continue;
};
let moved = pose.iter().zip(&p.pose).enumerate().any(|(i, (a, b))| {
// A hundredth of a block, half a degree.
(a - b).abs() > if i < 3 { 0.01 } else { 0.5 }
});
*pose = p.pose;
if moved {
*active = now;
if self.afk.remove(&p.uuid) {
out.extend(self.back(p.uuid, &p.name, &mut acts));
}
} else if self.config.afk_minutes > 0
&& now - *active >= self.config.afk_minutes * 60_000
&& self.afk.insert(p.uuid)
{
out.push(self.away(p.uuid, &p.name, &mut acts));
}
}
// Each player's first second on today, or the day turning while on.
if self.config.daily_reward > 0 {
for p in players {
let key = smp_store::uuid_string(p.uuid);
if self.data.rewarded.get(&key).map(String::as_str) != Some(today) {
self.data.rewarded.insert(key, today.to_owned());
self.dirty = true;
acts.push(Act::Reward(p.uuid));
}
}
}
(out, acts)
}
fn away(&mut self, uuid: u128, name: &str, acts: &mut Vec<Act>) -> Out {
acts.push(Act::Sleep(uuid, true));
if self.config.afk_announce {
Out::All(format!("{name} is AFK."))
} else {
Out::Tell(
uuid,
"You are AFK: you don't count for sleeping until you move.".into(),
)
}
}
fn back(&mut self, uuid: u128, name: &str, acts: &mut Vec<Act>) -> Vec<Out> {
acts.push(Act::Sleep(uuid, false));
vec![if self.config.afk_announce {
Out::All(format!("{name} is back."))
} else {
Out::Tell(uuid, "You are back.".into())
}]
}
/// Before each of a player's SMP commands: counts it as activity, and
/// refuses it past the limit.
pub fn allow(&mut self, me: &Player, now: i64) -> Result<(Vec<Out>, Vec<Act>), Out> {
let (limit, window) = (self.config.spam_commands, self.config.spam_seconds * 1000);
if limit > 0 && me.level < 3 {
let recent = self.recent.entry(me.uuid).or_default();
while recent.front().is_some_and(|t| now - t >= window) {
recent.pop_front();
}
if recent.len() >= limit {
return Err(Out::Error(format!(
"Slow down: at most {limit} commands every {} s.",
self.config.spam_seconds
)));
}
recent.push_back(now);
}
if let Some((_, active)) = self.on.get_mut(&me.uuid) {
*active = now;
}
let mut acts = Vec::new();
let out = if self.afk.remove(&me.uuid) {
self.back(me.uuid, &me.name, &mut acts)
} else {
Vec::new()
};
Ok((out, acts))
}
/// `/afk`: AFK until the player moves, turns or runs a command.
pub fn command(&mut self, me: &Player, line: &str) -> Option<(Vec<Out>, Vec<Act>)> {
if line.trim() != "afk" {
return None;
}
let mut acts = Vec::new();
if !self.afk.insert(me.uuid) {
return Some((vec![Out::Reply("You are already AFK.".into())], acts));
}
let out = self.away(me.uuid, &me.name, &mut acts);
Some((vec![out], acts))
}
}
#[cfg(test)]
mod tests {
use super::*;
const A: u128 = 1;
const B: u128 = 2;
fn seen(uuid: u128, x: f64) -> Seen {
Seen {
uuid,
name: if uuid == A { "Alex" } else { "Steve" }.into(),
pose: [x, 64.0, 0.0, 0.0, 0.0],
}
}
fn me(uuid: u128) -> Player {
Player {
uuid,
name: "Alex".into(),
level: 0,
}
}
fn open(known: Vec<u128>) -> (Presence, PathBuf) {
let dir = std::env::temp_dir().join(format!(
"smp-presence-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
(Presence::open(&dir, Config::default(), known).unwrap(), dir)
}
#[test]
fn a_first_join_is_welcomed_once_and_known_players_are_not() {
let (mut p, dir) = open(vec![B]);
let rules = vec!["Be kind.".to_owned()];
let (out, _) = p.tick(&[seen(A, 0.0), seen(B, 0.0)], "2026-10-31", 0, &rules);
let all: Vec<_> = out.iter().filter(|o| matches!(o, Out::All(_))).collect();
assert_eq!(all.len(), 1, "{out:?}");
assert!(matches!(all[0], Out::All(l) if l.starts_with("Alex joined for the first time")));
assert!(
out.iter()
.any(|o| matches!(o, Out::Tell(A, l) if l == "1. Be kind."))
);
assert!(!out.iter().any(|o| matches!(o, Out::Tell(B, _))));
p.save().unwrap();
// Back after leaving, or after a restart: no welcome.
p.tick(&[], "2026-10-31", 1_000, &rules);
let (out, _) = p.tick(&[seen(A, 0.0)], "2026-10-31", 2_000, &rules);
assert!(out.is_empty(), "{out:?}");
let mut again = Presence::open(&dir, Config::default(), []).unwrap();
let (out, _) = again.tick(&[seen(A, 0.0)], "2026-10-31", 0, &rules);
assert!(out.is_empty(), "{out:?}");
}
#[test]
fn the_daily_reward_comes_once_a_day() {
let (mut p, _) = open(vec![]);
let (_, acts) = p.tick(&[seen(A, 0.0)], "2026-10-31", 0, &[]);
assert_eq!(acts, vec![Act::Reward(A)]);
p.tick(&[], "2026-10-31", 1_000, &[]);
let (_, acts) = p.tick(&[seen(A, 0.0)], "2026-10-31", 2_000, &[]);
assert!(acts.is_empty(), "{acts:?}");
// Midnight with the player on.
let (_, acts) = p.tick(&[seen(A, 0.0)], "2026-11-01", 3_000, &[]);
assert_eq!(acts, vec![Act::Reward(A)]);
}
#[test]
fn a_still_player_goes_afk_and_comes_back_by_moving_or_a_command() {
let (mut p, _) = open(vec![A]);
p.config.daily_reward = 0;
p.tick(&[seen(A, 0.0)], "d", 0, &[]);
let (_, acts) = p.tick(&[seen(A, 0.0)], "d", 299_000, &[]);
assert!(acts.is_empty());
let (out, acts) = p.tick(&[seen(A, 0.0)], "d", 300_000, &[]);
assert_eq!(acts, vec![Act::Sleep(A, true)]);
assert!(matches!(&out[..], [Out::Tell(A, _)]));
let (_, acts) = p.tick(&[seen(A, 0.0)], "d", 301_000, &[]);
assert!(acts.is_empty(), "once");
let (_, acts) = p.tick(&[seen(A, 1.0)], "d", 302_000, &[]);
assert_eq!(acts, vec![Act::Sleep(A, false)]);
// /afk, then a command.
let (_, acts) = p.command(&me(A), "afk").unwrap();
assert_eq!(acts, vec![Act::Sleep(A, true)]);
let (_, acts) = p.allow(&me(A), 303_000).unwrap();
assert_eq!(acts, vec![Act::Sleep(A, false)]);
// Leaving while AFK counts them again.
p.command(&me(A), "afk");
let (_, acts) = p.tick(&[], "d", 304_000, &[]);
assert_eq!(acts, vec![Act::Sleep(A, false)]);
}
#[test]
fn commands_past_the_limit_wait() {
let (mut p, _) = open(vec![A]);
for i in 0..8 {
assert!(p.allow(&me(A), i * 100).is_ok());
}
assert!(p.allow(&me(A), 900).is_err());
assert!(p.allow(&me(A), 5_000).is_ok(), "the first left the window");
let op = Player { level: 3, ..me(A) };
assert!(p.allow(&op, 5_001).is_ok());
}
}
+454
View File
@@ -0,0 +1,454 @@
// Whether the market's prices let anyone get rich by crafting: buying items,
// crafting or smelting them into others, and selling those for more than
// they cost. The cheapest way to get each item from the market is found over
// the recipes (Bellman-Ford: a recipe lowers its result's cost to what its
// inputs cost, until nothing changes); an item the market buys for more than
// that cost is a loop. Fuel is left out, so a loop is found even where the
// fuel would eat the profit. The vanilla recipes that matter for the default
// prices are written here as facts (`CHAINS`); the tests also walk every
// recipe of the game's own data when it is there. No game types. Added to
// the game as `game::smp_recipes`; also a module of this crate, for its
// tests.
use super::smp_market::Price;
use std::collections::BTreeMap;
/// A recipe: `count` of `result` from the ingredients, each a choice of
/// items (any one of them) and how many of it.
#[derive(Clone, Debug, PartialEq)]
pub struct Recipe {
pub result: String,
pub count: u32,
pub inputs: Vec<(Vec<String>, u32)>,
}
/// An item the market buys for more than it costs to make from what the
/// market sells.
#[derive(Clone, Debug, PartialEq)]
pub struct Loop {
pub item: String,
/// What the market pays for one.
pub sell: i64,
/// The least one costs, bought or crafted.
pub cost: f64,
}
/// A written recipe: result, count, [(ingredient, how many)].
type Chain = (&'static str, u32, &'static [(&'static str, u32)]);
/// The compression and food chains around the default prices.
const CHAINS: &[Chain] = &[
("iron_block", 1, &[("iron_ingot", 9)]),
("iron_ingot", 9, &[("iron_block", 1)]),
("iron_nugget", 9, &[("iron_ingot", 1)]),
("iron_ingot", 1, &[("iron_nugget", 9)]),
("iron_ingot", 1, &[("raw_iron", 1)]),
("raw_iron_block", 1, &[("raw_iron", 9)]),
("raw_iron", 9, &[("raw_iron_block", 1)]),
("gold_block", 1, &[("gold_ingot", 9)]),
("gold_ingot", 9, &[("gold_block", 1)]),
("gold_nugget", 9, &[("gold_ingot", 1)]),
("gold_ingot", 1, &[("gold_nugget", 9)]),
("gold_ingot", 1, &[("raw_gold", 1)]),
("copper_block", 1, &[("copper_ingot", 9)]),
("copper_ingot", 9, &[("copper_block", 1)]),
("copper_ingot", 1, &[("raw_copper", 1)]),
("diamond_block", 1, &[("diamond", 9)]),
("diamond", 9, &[("diamond_block", 1)]),
("emerald_block", 1, &[("emerald", 9)]),
("emerald", 9, &[("emerald_block", 1)]),
("coal_block", 1, &[("coal", 9)]),
("coal", 9, &[("coal_block", 1)]),
("redstone_block", 1, &[("redstone", 9)]),
("redstone", 9, &[("redstone_block", 1)]),
("lapis_block", 1, &[("lapis_lazuli", 9)]),
("lapis_lazuli", 9, &[("lapis_block", 1)]),
("quartz_block", 1, &[("quartz", 4)]),
("hay_block", 1, &[("wheat", 9)]),
("wheat", 9, &[("hay_block", 1)]),
("bread", 1, &[("wheat", 3)]),
("melon", 1, &[("melon_slice", 9)]),
("melon_seeds", 1, &[("melon_slice", 1)]),
("pumpkin_seeds", 4, &[("pumpkin", 1)]),
("sugar", 1, &[("sugar_cane", 1)]),
("paper", 3, &[("sugar_cane", 3)]),
("bone_meal", 3, &[("bone", 1)]),
("bone_block", 1, &[("bone_meal", 9)]),
("bone_meal", 9, &[("bone_block", 1)]),
("slime_block", 1, &[("slime_ball", 9)]),
("slime_ball", 9, &[("slime_block", 1)]),
("white_wool", 1, &[("string", 4)]),
("dried_kelp_block", 1, &[("dried_kelp", 9)]),
("oak_planks", 4, &[("oak_log", 1)]),
("spruce_planks", 4, &[("spruce_log", 1)]),
("birch_planks", 4, &[("birch_log", 1)]),
("stick", 4, &[("oak_planks", 2)]),
("torch", 4, &[("coal", 1), ("stick", 1)]),
("charcoal", 1, &[("oak_log", 1)]),
("stone", 1, &[("cobblestone", 1)]),
("stone_bricks", 4, &[("stone", 4)]),
("glass", 1, &[("sand", 1)]),
("cooked_beef", 1, &[("beef", 1)]),
("cooked_porkchop", 1, &[("porkchop", 1)]),
("cooked_chicken", 1, &[("chicken", 1)]),
("baked_potato", 1, &[("potato", 1)]),
("golden_carrot", 1, &[("carrot", 1), ("gold_nugget", 8)]),
("blaze_powder", 2, &[("blaze_rod", 1)]),
("leather", 1, &[("rabbit_hide", 4)]),
("tnt", 1, &[("gunpowder", 5), ("sand", 4)]),
];
/// `CHAINS` as recipes.
pub fn chains() -> Vec<Recipe> {
CHAINS
.iter()
.map(|(result, count, inputs)| Recipe {
result: format!("minecraft:{result}"),
count: *count,
inputs: inputs
.iter()
.map(|(i, n)| (vec![format!("minecraft:{i}")], *n))
.collect(),
})
.collect()
}
/// The least each item costs from the market: bought, or crafted from what
/// the market sells (absent: no way at all).
pub fn costs(prices: &BTreeMap<String, Price>, recipes: &[Recipe]) -> BTreeMap<String, f64> {
let mut cost: BTreeMap<String, f64> = prices
.iter()
.filter(|(_, p)| p.buy > 0)
.map(|(id, p)| (id.clone(), p.buy as f64))
.collect();
// Each round can only lower a cost; as many rounds as recipes is
// Bellman-Ford's bound, and the loop stops as soon as nothing changes.
for _ in 0..=recipes.len() {
let mut changed = false;
for r in recipes {
if r.count == 0 {
continue;
}
let mut total = 0.0;
let mut makeable = true;
for (choices, n) in &r.inputs {
let best = choices
.iter()
.filter_map(|c| cost.get(c))
.fold(f64::INFINITY, |a, &b| a.min(b));
if !best.is_finite() {
makeable = false;
break;
}
total += best * f64::from(*n);
}
if !makeable {
continue;
}
let each = total / f64::from(r.count);
let old = cost.get(&r.result).copied().unwrap_or(f64::INFINITY);
if each < old - 1e-9 {
cost.insert(r.result.clone(), each);
changed = true;
}
}
if !changed {
break;
}
}
cost
}
/// The items the market buys for more than they cost to get from it.
pub fn loops(prices: &BTreeMap<String, Price>, recipes: &[Recipe]) -> Vec<Loop> {
let cost = costs(prices, recipes);
prices
.iter()
.filter(|(_, p)| p.sell > 0)
.filter_map(|(id, p)| {
let c = *cost.get(id)?;
(p.sell as f64 > c + 1e-9).then(|| Loop {
item: id.clone(),
sell: p.sell,
cost: c,
})
})
.collect()
}
#[cfg(test)]
/// An ingredient of a recipe file: an item, a `#tag`, or a list of either,
/// as the items it allows (`tags` maps a tag to its items, tags in tags
/// resolved).
fn choices(v: &serde_json::Value, tags: &BTreeMap<String, Vec<String>>) -> Vec<String> {
match v {
serde_json::Value::String(s) => match s.strip_prefix('#') {
Some(tag) => tags.get(tag).cloned().unwrap_or_default(),
None => vec![s.clone()],
},
serde_json::Value::Array(a) => a.iter().flat_map(|x| choices(x, tags)).collect(),
serde_json::Value::Object(o) => o
.get("item")
.or_else(|| o.get("tag"))
.map(|x| choices(x, tags))
.unwrap_or_default(),
_ => Vec::new(),
}
}
#[cfg(test)]
/// A vanilla recipe file (`data/minecraft/recipe/*.json`) as a recipe, for
/// the kinds that turn items into items: shaped and shapeless crafting,
/// transmuting, the furnaces and the campfire, the stonecutter and the
/// smithing table. None for the others (dyeing, fireworks...).
pub fn parse(json: &serde_json::Value, tags: &BTreeMap<String, Vec<String>>) -> Option<Recipe> {
let kind = json.get("type")?.as_str()?;
let result = json.get("result")?;
let (result, count) = match result {
serde_json::Value::String(s) => (s.clone(), 1),
r => (
r.get("id")?.as_str()?.to_owned(),
r.get("count").and_then(|c| c.as_u64()).unwrap_or(1) as u32,
),
};
let one = |key: &str| json.get(key).map(|v| (choices(v, tags), 1));
let inputs: Vec<(Vec<String>, u32)> = match kind.strip_prefix("minecraft:")? {
"crafting_shaped" => {
let key = json.get("key")?.as_object()?;
let mut counts: BTreeMap<char, u32> = BTreeMap::new();
for row in json.get("pattern")?.as_array()? {
for c in row.as_str()?.chars().filter(|c| *c != ' ') {
*counts.entry(c).or_default() += 1;
}
}
counts
.into_iter()
.map(|(c, n)| Some((choices(key.get(&c.to_string())?, tags), n)))
.collect::<Option<_>>()?
}
"crafting_shapeless" => json
.get("ingredients")?
.as_array()?
.iter()
.map(|i| (choices(i, tags), 1))
.collect(),
"crafting_transmute" => vec![one("input")?, one("material")?],
"smelting" | "blasting" | "smoking" | "campfire_cooking" | "stonecutting" => {
vec![one("ingredient")?]
}
"smithing_transform" => vec![one("template")?, one("base")?, one("addition")?],
_ => return None,
};
// An ingredient nothing can fill makes the recipe useless here.
if inputs.iter().any(|(c, _)| c.is_empty()) {
return None;
}
Some(Recipe {
result,
count,
inputs,
})
}
#[cfg(test)]
/// Item tags (`data/minecraft/tags/item/*.json`, by name: `logs`) resolved
/// into items, as `minecraft:logs` → items.
pub fn resolve_tags(raw: &BTreeMap<String, Vec<String>>) -> BTreeMap<String, Vec<String>> {
fn expand(name: &str, raw: &BTreeMap<String, Vec<String>>, depth: u32, out: &mut Vec<String>) {
let Some(values) = raw.get(name) else {
return;
};
for v in values {
match v.strip_prefix('#') {
Some(t) if depth < 16 => expand(t, raw, depth + 1, out),
Some(_) => {}
None => out.push(v.clone()),
}
}
}
raw.keys()
.map(|name| {
let mut out = Vec::new();
expand(name, raw, 0, &mut out);
(name.clone(), out)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::super::smp_market::Config;
use super::*;
use std::path::PathBuf;
fn prices(list: &[(&str, i64, i64)]) -> BTreeMap<String, Price> {
list.iter()
.map(|(id, buy, sell)| {
(
format!("minecraft:{id}"),
Price {
buy: *buy,
sell: *sell,
},
)
})
.collect()
}
#[test]
fn a_block_sold_dearer_than_its_ingots_is_a_loop() {
// 9 ingots at 30 make a block the market would buy for 300.
let p = prices(&[("iron_ingot", 30, 8), ("iron_block", 0, 300)]);
let found = loops(&p, &chains());
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].item, "minecraft:iron_block");
assert!((found[0].cost - 270.0).abs() < 1e-9);
// The other way: a cheap block uncrafted into ingots the market buys.
let p = prices(&[("iron_ingot", 30, 8), ("iron_block", 60, 0)]);
let found = loops(&p, &chains());
assert_eq!(found[0].item, "minecraft:iron_ingot", "{found:?}");
}
#[test]
fn bread_from_cheap_wheat_and_planks_from_cheap_logs_are_loops() {
let p = prices(&[("wheat", 1, 0), ("bread", 5, 4)]);
assert_eq!(loops(&p, &chains())[0].item, "minecraft:bread");
let p = prices(&[("oak_log", 4, 1), ("oak_planks", 0, 2)]);
assert_eq!(loops(&p, &chains())[0].item, "minecraft:oak_planks");
// Through two recipes: logs → planks → sticks.
let p = prices(&[("oak_log", 4, 1), ("stick", 0, 1)]);
assert_eq!(loops(&p, &chains())[0].item, "minecraft:stick");
}
#[test]
fn the_default_prices_have_no_loop() {
let config = Config::default();
assert_eq!(loops(&config.items, &chains()), []);
for (id, p) in &config.items {
assert!(
p.buy == 0 || p.sell < p.buy,
"{id} sells for {} and buys for {}",
p.sell,
p.buy
);
}
}
#[test]
fn recipe_files_read_as_recipes() {
let tags = resolve_tags(
&[
(
"minecraft:planks",
vec!["minecraft:oak_planks", "#minecraft:x"],
),
("minecraft:x", vec![]),
(
"minecraft:oak_logs",
vec!["minecraft:oak_log", "minecraft:oak_wood"],
),
]
.into_iter()
.map(|(k, v)| (k.to_owned(), v.into_iter().map(str::to_owned).collect()))
.collect(),
);
let shaped: serde_json::Value = serde_json::from_str(
r##"{"type":"minecraft:crafting_shaped","key":{"#":"minecraft:iron_ingot","S":"#minecraft:planks"},
"pattern":["# #","#S#"," # "],"result":{"id":"minecraft:bucket"}}"##,
)
.unwrap();
let r = parse(&shaped, &tags).unwrap();
assert_eq!(r.result, "minecraft:bucket");
assert_eq!(
r.inputs,
[
(vec!["minecraft:iron_ingot".to_owned()], 5),
(vec!["minecraft:oak_planks".to_owned()], 1)
]
);
let shapeless: serde_json::Value = serde_json::from_str(
r##"{"type":"minecraft:crafting_shapeless","ingredients":["#minecraft:oak_logs"],"result":{"count":4,"id":"minecraft:oak_planks"}}"##,
)
.unwrap();
let r = parse(&shapeless, &tags).unwrap();
assert_eq!((r.count, r.inputs[0].0.len()), (4, 2));
let smelt: serde_json::Value = serde_json::from_str(
r#"{"type":"minecraft:smelting","ingredient":["minecraft:a","minecraft:b"],"result":{"id":"minecraft:c"}}"#,
)
.unwrap();
assert_eq!(parse(&smelt, &tags).unwrap().inputs[0].0.len(), 2);
let special: serde_json::Value =
serde_json::from_str(r#"{"type":"minecraft:crafting_special_repairitem"}"#).unwrap();
assert_eq!(parse(&special, &tags), None);
}
/// The game's own recipe and tag files: `SMP_VANILLA_DATA` (a
/// `data/minecraft` folder), or a Horizon checkout's prepared resources
/// beside this repository. None when neither is there (CI).
fn vanilla_data() -> Option<PathBuf> {
if let Some(p) = std::env::var_os("SMP_VANILLA_DATA") {
return Some(PathBuf::from(p));
}
let home = PathBuf::from(std::env::var_os("HOME")?);
let p = home.join("code/horizon/.local/horizon-client/26.3/data/minecraft");
p.join("recipe").is_dir().then_some(p)
}
#[test]
fn the_default_prices_have_no_loop_through_any_vanilla_recipe() {
let Some(data) = vanilla_data() else {
eprintln!("no vanilla data (SMP_VANILLA_DATA): only the written chains are checked");
return;
};
let read = |p: &std::path::Path| -> serde_json::Value {
serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
};
// Tags in folders too: `tags/item/enchantable/sword.json` is
// `minecraft:enchantable/sword`.
fn walk(dir: &std::path::Path, out: &mut Vec<PathBuf>) {
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
walk(&path, out);
} else if path.extension().is_some_and(|e| e == "json") {
out.push(path);
}
}
}
let root = data.join("tags/item");
let mut files = Vec::new();
walk(&root, &mut files);
let mut raw = BTreeMap::new();
for path in files {
let name = path.strip_prefix(&root).unwrap().with_extension("");
let values = read(&path)["values"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| {
v.as_str()
.or_else(|| v.get("id")?.as_str())
.map(str::to_owned)
})
.collect();
raw.insert(format!("minecraft:{}", name.to_string_lossy()), values);
}
let tags = resolve_tags(&raw);
let mut recipes = Vec::new();
for entry in std::fs::read_dir(data.join("recipe")).unwrap() {
let path = entry.unwrap().path();
if path.extension().is_some_and(|e| e == "json")
&& let Some(r) = parse(&read(&path), &tags)
{
recipes.push(r);
}
}
assert!(recipes.len() > 1000, "{} recipes", recipes.len());
let config = Config::default();
assert_eq!(loops(&config.items, &recipes), []);
// The checker does find a loop through the real recipes.
let mut cheap = config.items.clone();
cheap.insert("minecraft:iron_block".into(), Price { buy: 0, sell: 300 });
assert_eq!(loops(&cheap, &recipes)[0].item, "minecraft:iron_block");
}
}
+489
View File
@@ -0,0 +1,489 @@
// `/shop`: the market (smp_market) as a chest menu. The first page shows the
// categories; a category's page its items, with what the market asks and
// pays (in its name). A click buys one, a shift-click a stack of 64; a click on a stack in
// your own inventory sells it; "Sell all" sells everything the market buys.
// Buying and selling go through the market's own rules (its prices, today's
// limits, items first then money, logged). This file only lays the pages out
// and says what a click means; the glue opens the menu and moves the items.
// No game types. Added to the game as `game::smp_shop`; also a module of this
// crate, for its tests.
use super::smp_market::{Config as MarketConfig, Price, item_name};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Rows of the menu, and its slots.
pub const ROWS: u8 = 6;
pub const SIZE: usize = 54;
/// A page's item slots (the rows above the bottom one).
pub const PER_PAGE: usize = 45;
/// What a shift-click buys.
pub const STACK: u32 = 64;
/// The bottom row's buttons.
const BACK: usize = 45;
const HELP: usize = 46;
const PREV: usize = 47;
const BALANCE: usize = 48;
const SELL_ALL: usize = 49;
const LIMITS: usize = 50;
const NEXT: usize = 51;
const CLOSE: usize = 53;
/// Where the first page puts the categories: the middle of rows 2 to 4.
const CATEGORY_SLOTS: [usize; 21] = [
10, 11, 12, 13, 14, 15, 16, 19, 20, 21, 22, 23, 24, 25, 28, 29, 30, 31, 32, 33, 34,
];
/// A category: its name, the item its button shows, its items.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Category {
pub name: String,
pub icon: String,
pub items: Vec<String>,
}
/// `config.json`'s `shop`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub enabled: bool,
pub title: String,
/// The market's items not in any category go to an "Other" one.
pub categories: Vec<Category>,
}
impl Default for Config {
fn default() -> Self {
let cat = |name: &str, icon: &str, items: &[&str]| Category {
name: name.into(),
icon: format!("minecraft:{icon}"),
items: items.iter().map(|i| format!("minecraft:{i}")).collect(),
};
Self {
enabled: true,
title: "Shop".into(),
categories: vec![
cat(
"Farming",
"wheat",
&[
"wheat",
"wheat_seeds",
"carrot",
"potato",
"beetroot",
"sugar_cane",
"pumpkin",
"melon_slice",
"cactus",
"bone_meal",
],
),
cat(
"Mining",
"diamond",
&[
"coal",
"copper_ingot",
"iron_ingot",
"gold_ingot",
"redstone",
"lapis_lazuli",
"quartz",
"emerald",
"diamond",
],
),
cat(
"Wood",
"oak_log",
&[
"oak_log",
"spruce_log",
"birch_log",
"jungle_log",
"acacia_log",
"dark_oak_log",
"mangrove_log",
"cherry_log",
],
),
cat(
"Mob drops",
"bone",
&[
"rotten_flesh",
"bone",
"string",
"gunpowder",
"spider_eye",
"slime_ball",
"blaze_rod",
"leather",
"feather",
],
),
cat(
"Building",
"bricks",
&[
"cobblestone",
"stone",
"dirt",
"sand",
"gravel",
"glass",
"bricks",
"stone_bricks",
],
),
cat(
"Food",
"bread",
&[
"bread",
"cooked_beef",
"cooked_porkchop",
"cooked_chicken",
"baked_potato",
"apple",
"golden_carrot",
],
),
cat(
"Tools",
"torch",
&["torch", "ladder", "crafting_table", "chest", "furnace"],
),
],
}
}
}
/// Which page a player sees.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Page {
Main,
/// A category (by index in `categories`) and its page from 0.
Category(usize, usize),
}
/// One slot of the menu.
#[derive(Clone, Debug, PartialEq)]
pub enum Cell {
Empty,
/// The bottom row's background.
Filler,
/// A category's button: its icon, name, and how many items it has.
Category(String, String, usize),
/// An item and its price.
Item(String, Price),
Back,
Prev,
Next,
Balance,
SellAll,
Limits,
Help,
Close,
}
/// What a click asks for.
#[derive(Clone, Debug, PartialEq)]
pub enum Click {
/// Nothing (an empty slot, a filler).
Nothing,
/// Show this page.
Show(Page),
/// Buy this many of the item.
Buy(String, u32),
/// Sell everything the market buys.
SellAll,
Close,
}
/// The categories as shown: each with only the items the market trades,
/// those it trades in none of them under "Other", the empty ones left out.
pub fn categories(config: &Config, market: &MarketConfig) -> Vec<Category> {
let traded = |id: &String| {
market
.items
.get(id)
.is_some_and(|p| p.buy > 0 || p.sell > 0)
};
let mut out: Vec<Category> = config
.categories
.iter()
.map(|c| Category {
items: c.items.iter().filter(|i| traded(i)).cloned().collect(),
..c.clone()
})
.collect();
let other: Vec<String> = market
.items
.keys()
.filter(|id| traded(id) && !out.iter().any(|c| c.items.contains(id)))
.cloned()
.collect();
if !other.is_empty() {
out.push(Category {
name: "Other".into(),
icon: "minecraft:chest".into(),
items: other,
});
}
out.retain(|c| !c.items.is_empty());
out
}
/// The players' open shop menus: their window and page.
#[derive(Default)]
pub struct Shop {
pub views: HashMap<u128, (Option<i32>, Page)>,
}
impl Shop {
/// The page `uuid` sees, as cells.
pub fn layout(&self, config: &Config, market: &MarketConfig, uuid: u128) -> Vec<Cell> {
let page = self.views.get(&uuid).map_or(Page::Main, |v| v.1);
let cats = categories(config, market);
let mut cells = vec![Cell::Empty; SIZE];
for c in &mut cells[PER_PAGE..] {
*c = Cell::Filler;
}
cells[HELP] = Cell::Help;
cells[BALANCE] = Cell::Balance;
cells[SELL_ALL] = Cell::SellAll;
cells[LIMITS] = Cell::Limits;
cells[CLOSE] = Cell::Close;
match page {
Page::Main => {
for (slot, c) in CATEGORY_SLOTS.iter().zip(&cats) {
cells[*slot] = Cell::Category(c.icon.clone(), c.name.clone(), c.items.len());
}
}
Page::Category(i, p) => {
cells[BACK] = Cell::Back;
let items = cats.get(i).map(|c| c.items.as_slice()).unwrap_or_default();
for (slot, id) in items.iter().skip(p * PER_PAGE).take(PER_PAGE).enumerate() {
cells[slot] = Cell::Item(
id.clone(),
market.items.get(id).copied().unwrap_or_default(),
);
}
if p > 0 {
cells[PREV] = Cell::Prev;
}
if (p + 1) * PER_PAGE < items.len() {
cells[NEXT] = Cell::Next;
}
}
}
cells
}
/// What a click on the menu's `slot` means (`shift`: a shift-click); a
/// new page is kept as the player's.
pub fn click(
&mut self,
config: &Config,
market: &MarketConfig,
uuid: u128,
slot: usize,
shift: bool,
) -> Click {
let cells = self.layout(config, market, uuid);
let page = self.views.get(&uuid).map_or(Page::Main, |v| v.1);
let click = match (cells.get(slot), page) {
(Some(Cell::Category(_, name, _)), _) => {
let i = categories(config, market)
.iter()
.position(|c| &c.name == name);
i.map_or(Click::Nothing, |i| Click::Show(Page::Category(i, 0)))
}
(Some(Cell::Item(id, price)), _) if price.buy > 0 => {
Click::Buy(id.clone(), if shift { STACK } else { 1 })
}
(Some(Cell::Back), _) => Click::Show(Page::Main),
(Some(Cell::Prev), Page::Category(i, p)) => {
Click::Show(Page::Category(i, p.saturating_sub(1)))
}
(Some(Cell::Next), Page::Category(i, p)) => Click::Show(Page::Category(i, p + 1)),
(Some(Cell::SellAll), _) => Click::SellAll,
(Some(Cell::Close), _) => Click::Close,
_ => Click::Nothing,
};
if let (Click::Show(p), Some(v)) = (&click, self.views.get_mut(&uuid)) {
v.1 = *p;
}
click
}
}
/// An item's button: its name and what the market asks and pays.
pub fn item_label(id: &str, price: Price, money: &dyn Fn(i64) -> String) -> String {
let mut parts = Vec::new();
if price.buy > 0 {
parts.push(format!("buy {}", money(price.buy)));
}
if price.sell > 0 {
parts.push(format!("sell {}", money(price.sell)));
}
format!("{}: {}", title(id), parts.join(", "))
}
/// The help button's text.
pub const HELP_TEXT: &str = "Click: buy 1 · Shift-click: buy 64 · Click a stack of yours: sell it";
/// `minecraft:iron_ingot` as `Iron Ingot`, for a button.
pub fn title(id: &str) -> String {
item_name(id)
.split(' ')
.map(|w| {
let mut c = w.chars();
c.next()
.map(|f| f.to_uppercase().chain(c).collect())
.unwrap_or_default()
})
.collect::<Vec<String>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
const ALEX: u128 = 1;
fn open(shop: &mut Shop) {
shop.views.insert(ALEX, (Some(3), Page::Main));
}
#[test]
fn the_first_page_shows_the_categories_with_the_market_s_items() {
let (config, mut market) = (Config::default(), MarketConfig::default());
// An item in no category: under "Other". One the market doesn't
// trade: nowhere.
market
.items
.insert("minecraft:sponge".into(), Price { buy: 50, sell: 0 });
market.items.remove("minecraft:torch");
let cats = categories(&config, &market);
assert_eq!(cats.last().unwrap().name, "Other");
assert_eq!(cats.last().unwrap().items, ["minecraft:sponge"]);
assert!(
cats.iter()
.all(|c| !c.items.contains(&"minecraft:torch".to_owned()))
);
// Every default item is in a category of its own.
let all = categories(&config, &MarketConfig::default());
assert!(all.iter().all(|c| c.name != "Other"), "{all:?}");
let mut shop = Shop::default();
open(&mut shop);
let cells = shop.layout(&config, &market, ALEX);
assert_eq!(
cells[10],
Cell::Category("minecraft:wheat".into(), "Farming".into(), 10)
);
assert_eq!(
(cells[BALANCE].clone(), cells[CLOSE].clone()),
(Cell::Balance, Cell::Close)
);
assert_eq!(cells[BACK], Cell::Filler);
}
#[test]
fn clicks_open_a_category_buy_one_or_a_stack_and_go_back() {
let (config, market) = (Config::default(), MarketConfig::default());
let mut shop = Shop::default();
open(&mut shop);
// Mining: the second category.
assert_eq!(
shop.click(&config, &market, ALEX, 11, false),
Click::Show(Page::Category(1, 0))
);
let cells = shop.layout(&config, &market, ALEX);
assert_eq!(
cells[0],
Cell::Item("minecraft:coal".into(), Price { buy: 8, sell: 2 })
);
assert_eq!(cells[BACK], Cell::Back);
assert_eq!(
shop.click(&config, &market, ALEX, 0, false),
Click::Buy("minecraft:coal".into(), 1)
);
assert_eq!(
shop.click(&config, &market, ALEX, 0, true),
Click::Buy("minecraft:coal".into(), 64)
);
assert_eq!(
shop.click(&config, &market, ALEX, 40, false),
Click::Nothing
);
assert_eq!(
shop.click(&config, &market, ALEX, SELL_ALL, false),
Click::SellAll
);
assert_eq!(
shop.click(&config, &market, ALEX, BACK, false),
Click::Show(Page::Main)
);
assert_eq!(shop.views[&ALEX].1, Page::Main);
assert_eq!(
shop.click(&config, &market, ALEX, CLOSE, false),
Click::Close
);
}
#[test]
fn an_item_the_market_only_buys_is_not_sold_and_long_categories_have_pages() {
let mut market = MarketConfig::default();
let mut config = Config::default();
// Rotten flesh: the market buys it, never sells it.
let mut shop = Shop::default();
open(&mut shop);
shop.click(&config, &market, ALEX, 13, false);
assert_eq!(shop.views[&ALEX].1, Page::Category(3, 0));
assert_eq!(shop.click(&config, &market, ALEX, 0, false), Click::Nothing);
// 50 items in one category: two pages.
let many: Vec<String> = (0..50).map(|i| format!("minecraft:item_{i}")).collect();
for id in &many {
market.items.insert(id.clone(), Price { buy: 2, sell: 1 });
}
config.categories = vec![Category {
name: "All".into(),
icon: "minecraft:chest".into(),
items: many,
}];
shop.views.insert(ALEX, (Some(3), Page::Category(0, 0)));
let cells = shop.layout(&config, &market, ALEX);
assert_eq!(
(cells[PREV].clone(), cells[NEXT].clone()),
(Cell::Filler, Cell::Next)
);
assert_eq!(
shop.click(&config, &market, ALEX, NEXT, false),
Click::Show(Page::Category(0, 1))
);
let cells = shop.layout(&config, &market, ALEX);
assert_eq!(
cells[0],
Cell::Item("minecraft:item_45".into(), Price { buy: 2, sell: 1 })
);
assert_eq!(cells[5], Cell::Empty);
assert_eq!(cells[PREV], Cell::Prev);
}
#[test]
fn labels_read_well() {
let money = |n: i64| format!("{n} coins");
assert_eq!(
item_label("minecraft:iron_ingot", Price { buy: 30, sell: 8 }, &money),
"Iron Ingot: buy 30 coins, sell 8 coins"
);
assert_eq!(
item_label("minecraft:bone", Price { buy: 0, sell: 1 }, &money),
"Bone: sell 1 coins"
);
}
}
+190
View File
@@ -0,0 +1,190 @@
// The SMP's files under the world's folder (`world/horizon-smp/`): JSON
// documents written whole and atomically (a temporary file, flushed, renamed
// over the old one), and the audit log, one JSON line per transaction,
// appended before the change it records. Added to the game as
// `game::smp_store`; also a module of this crate, for its tests.
use std::{
fs::{self, File, OpenOptions},
io::{self, Write},
path::{Path, PathBuf},
};
/// The folder of the SMP's files in a world's folder.
pub const DIR: &str = "horizon-smp";
/// Writes `bytes` to `path` so that a crash leaves either the old file or the
/// new one: a temporary file beside it, flushed to disk, then renamed over it.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
let dir = path.parent().unwrap_or(Path::new("."));
fs::create_dir_all(dir)?;
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
let temp = dir.join(format!(".{name}.tmp"));
{
let mut file = File::create(&temp)?;
file.write_all(bytes)?;
file.sync_all()?;
}
fs::rename(&temp, path)?;
// The rename itself reaches the disk with the folder's entry (Unix).
if let Ok(d) = File::open(dir) {
let _ = d.sync_all();
}
Ok(())
}
/// A JSON document: its default when the file doesn't exist, an error when it
/// exists but can't be read, so that a damaged file is never replaced by an
/// empty one.
pub fn read_json<T: serde::de::DeserializeOwned + Default>(path: &Path) -> Result<T, String> {
match fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display())),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(T::default()),
Err(e) => Err(format!("{}: {e}", path.display())),
}
}
/// Writes a JSON document atomically, pretty-printed.
pub fn write_json<T: serde::Serialize>(path: &Path, value: &T) -> Result<(), String> {
let mut bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
bytes.push(b'\n');
write_atomic(path, &bytes).map_err(|e| format!("{}: {e}", path.display()))
}
/// The audit log: `audit.log`, one JSON object a line.
pub struct Audit {
path: PathBuf,
file: Option<File>,
/// Lines appended since the last sync.
unsynced: bool,
}
impl Audit {
pub fn new(path: PathBuf) -> Self {
Self {
path,
file: None,
unsynced: false,
}
}
/// Appends one line; the change it records waits for it.
pub fn append(&mut self, entry: &serde_json::Value) -> Result<(), String> {
let mut line = entry.to_string();
line.push('\n');
let path = &self.path;
if self.file.is_none() {
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
let file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|e| format!("{}: {e}", path.display()))?;
self.file = Some(file);
}
let file = self.file.as_mut().expect("opened above");
file.write_all(line.as_bytes())
.map_err(|e| format!("{}: {e}", path.display()))?;
self.unsynced = true;
Ok(())
}
/// Flushes what was appended to disk (at the end of a tick that appended,
/// and when the world closes); nothing when nothing was appended, so a
/// quiet tick makes no system call.
pub fn sync(&mut self) {
if self.unsynced
&& let Some(f) = &self.file
{
let _ = f.sync_data();
self.unsynced = false;
}
}
}
/// `UUID.toString()`: 8-4-4-4-12 lowercase hex digits.
pub fn uuid_string(u: u128) -> String {
let h = format!("{u:032x}");
format!(
"{}-{}-{}-{}-{}",
&h[0..8],
&h[8..12],
&h[12..16],
&h[16..20],
&h[20..]
)
}
/// The UUID a [`uuid_string`] wrote.
pub fn parse_uuid(s: &str) -> Option<u128> {
let hex: String = s.chars().filter(|c| *c != '-').collect();
(hex.len() == 32)
.then(|| u128::from_str_radix(&hex, 16).ok())
.flatten()
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("smp-store-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
dir
}
#[test]
fn atomic_writes_replace_the_file_and_leave_no_temporary() {
let dir = scratch("atomic");
let path = dir.join("economy.json");
write_atomic(&path, b"one").unwrap();
write_atomic(&path, b"two").unwrap();
assert_eq!(fs::read(&path).unwrap(), b"two");
let names: Vec<_> = fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(names, ["economy.json"]);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_damaged_document_is_an_error_not_an_empty_one() {
let dir = scratch("damaged");
let path = dir.join("economy.json");
let missing: Vec<u32> = read_json(&path).unwrap();
assert!(missing.is_empty());
write_atomic(&path, b"{ not json").unwrap();
assert!(read_json::<Vec<u32>>(&path).is_err());
write_json(&path, &vec![1u32, 2]).unwrap();
assert_eq!(read_json::<Vec<u32>>(&path).unwrap(), [1, 2]);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn the_audit_log_appends_lines() {
let dir = scratch("audit");
let mut audit = Audit::new(dir.join("audit.log"));
audit
.append(&serde_json::json!({"kind": "pay", "amount": 5}))
.unwrap();
audit
.append(&serde_json::json!({"kind": "pay", "amount": 6}))
.unwrap();
audit.sync();
let text = fs::read_to_string(dir.join("audit.log")).unwrap();
assert_eq!(
text,
"{\"amount\":5,\"kind\":\"pay\"}\n{\"amount\":6,\"kind\":\"pay\"}\n"
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn uuids_round_trip() {
let u = 0x0000_0000_0000_4000_8000_0000_0000_0001u128;
assert_eq!(uuid_string(u), "00000000-0000-4000-8000-000000000001");
assert_eq!(parse_uuid(&uuid_string(u)), Some(u));
assert_eq!(parse_uuid("nope"), None);
}
}
+609
View File
@@ -0,0 +1,609 @@
// Player trades (`/trade`): the requests, both sides' offers and
// confirmations, and the trade menu's layout, with no game types (`S` is the
// game's item stack). The menu is display only and the offered stacks stay
// in their owners' inventories: the glue swaps them in one checked step once
// both have confirmed (`native_exchange_items`), so every way out of a trade
// (closing, `/trade cancel`, leaving, dying) moves nothing. Added to the game
// as `game::smp_trade`; also a module of this crate, for its tests.
use super::smp_economy::{Economy, MAX_AMOUNT, Out, Player, parse_amount};
/// How long a request waits for an answer.
pub const REQUEST_MS: i64 = 60_000;
/// Stacks one side may offer (the menu's 4 by 4 squares).
pub const OFFER_SLOTS: usize = 16;
/// The menu's rows: a 6-row chest.
pub const ROWS: u8 = 6;
pub const SIZE: usize = ROWS as usize * 9;
/// The own side's buttons, and the other side's status.
pub const MONEY: usize = 37;
pub const OTHER_MONEY: usize = 43;
pub const CONFIRM: usize = 45;
pub const CANCEL: usize = 48;
pub const OTHER_STATUS: usize = 52;
/// The menu slot of the `i`th square of an offer: the own side on the left
/// (columns 0 to 3), the other's on the right (5 to 8), four rows.
pub fn offer_slot(i: usize, own: bool) -> usize {
(i / 4) * 9 + i % 4 + if own { 0 } else { 5 }
}
/// Which square of the own offer a menu slot is.
fn own_square(slot: usize) -> Option<usize> {
let (row, col) = (slot / 9, slot % 9);
(row < 4 && col < 4).then_some(row * 4 + col)
}
/// One side of a trade.
#[derive(Clone, Debug, PartialEq)]
pub struct Side<S> {
pub uuid: u128,
pub name: String,
/// Inventory slots offered, with the stack each held when offered: the
/// stack it must still hold at the swap.
pub offer: Vec<(usize, S)>,
pub money: i64,
pub confirmed: bool,
/// The menu's window, once open.
pub window: Option<i32>,
}
impl<S> Side<S> {
fn new(uuid: u128, name: &str) -> Self {
Self {
uuid,
name: name.to_owned(),
offer: Vec::new(),
money: 0,
confirmed: false,
window: None,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Trade<S> {
pub sides: [Side<S>; 2],
}
impl<S> Trade<S> {
/// The side of `uuid` (0 or 1).
pub fn side_of(&self, uuid: u128) -> Option<usize> {
self.sides.iter().position(|s| s.uuid == uuid)
}
fn unconfirm(&mut self) {
for s in &mut self.sides {
s.confirmed = false;
}
}
}
struct Request {
from: u128,
from_name: String,
to: u128,
at: i64,
}
/// Finds a player who is on by name: their uuid, name, and whether they
/// may trade (survival or adventure).
pub type Online<'a> = dyn Fn(&str) -> Option<(u128, String, bool)> + 'a;
/// What the glue does for the trades.
#[derive(Clone, Debug, PartialEq)]
pub enum Act {
/// Open (or show again) the trade menu of the trade `uuid` is in.
Show(u128),
/// Close the player's trade menu (their trade ended).
Close(u128),
/// Both confirmed: swap the trade `uuid` is in.
Settle(u128),
}
/// What a menu cell shows; the glue makes the item.
#[derive(Clone, Debug, PartialEq)]
pub enum Cell<S> {
Empty,
Stack(S),
Divider,
/// A side's money: whether it is the viewer's, the amount.
Money(bool, i64),
/// The viewer's confirm button: confirmed or not.
Confirm(bool),
Cancel,
/// The other side: their name, confirmed or not.
Other(String, bool),
}
/// The open trades and the requests waiting.
pub struct Trades<S> {
requests: Vec<Request>,
pub open: Vec<Trade<S>>,
}
impl<S> Default for Trades<S> {
fn default() -> Self {
Self {
requests: Vec::new(),
open: Vec::new(),
}
}
}
impl<S: Clone + PartialEq> Trades<S> {
pub fn trade_of(&self, uuid: u128) -> Option<&Trade<S>> {
self.open.iter().find(|t| t.side_of(uuid).is_some())
}
fn index_of(&self, uuid: u128) -> Option<usize> {
self.open.iter().position(|t| t.side_of(uuid).is_some())
}
/// Ends the trade `uuid` is in, nothing moved: its players' menus close
/// and both are told why.
pub fn end(&mut self, uuid: u128, why: &str) -> (Vec<Out>, Vec<Act>) {
let Some(i) = self.index_of(uuid) else {
return (Vec::new(), Vec::new());
};
let t = self.open.remove(i);
let mut out = Vec::new();
let mut acts = Vec::new();
for s in &t.sides {
out.push(Out::Tell(
s.uuid,
format!("Trade cancelled: {why}. Nothing was exchanged."),
));
acts.push(Act::Close(s.uuid));
}
(out, acts)
}
/// `/trade <player>`, `/trade accept|deny [player]`, `/trade cancel`,
/// `/trade money <amount>`; None for another command. `online` finds a
/// player who is on by name (uuid, name, may trade).
pub fn command(
&mut self,
economy: &Economy,
me: &Player,
may_trade: bool,
online: &Online,
now: i64,
line: &str,
) -> Option<(Vec<Out>, Vec<Act>)> {
let mut words = line.split_whitespace();
if words.next()? != "trade" {
return None;
}
self.requests.retain(|r| now - r.at < REQUEST_MS);
let err = |s: &str| Some((vec![Out::Error(s.to_owned())], Vec::new()));
let usage = "Usage: /trade <player>, /trade accept|deny [player], /trade cancel, /trade money <amount>";
let arg = words.next();
let rest = words.next();
match arg {
None => err(usage),
Some("cancel") => {
if self.index_of(me.uuid).is_some() {
return Some(self.end(me.uuid, &format!("{} cancelled", me.name)));
}
let before = self.requests.len();
self.requests.retain(|r| r.from != me.uuid);
if self.requests.len() < before {
Some((
vec![Out::Reply("Your trade request is withdrawn.".into())],
Vec::new(),
))
} else {
err("You have no trade to cancel.")
}
}
Some("money") => {
let Some(i) = self.index_of(me.uuid) else {
return err("You are not in a trade.");
};
let Some(amount) = rest
.and_then(parse_amount)
.or(rest.filter(|r| *r == "0").map(|_| 0))
else {
return err("Usage: /trade money <amount> (0 to offer none)");
};
if amount > MAX_AMOUNT || amount > economy.balance(me.uuid) {
return err(&format!(
"You only have {}.",
economy.money(economy.balance(me.uuid))
));
}
let t = &mut self.open[i];
let side = t.side_of(me.uuid).expect("found");
t.sides[side].money = amount;
t.unconfirm();
let other = t.sides[1 - side].uuid;
Some((
vec![
Out::Reply(format!("You offer {}.", economy.money(amount))),
Out::Tell(
other,
format!("{} offers {}.", me.name, economy.money(amount)),
),
],
vec![Act::Show(me.uuid), Act::Show(other)],
))
}
Some(verb @ ("accept" | "deny")) => {
let pick = |r: &&Request| {
r.to == me.uuid && rest.is_none_or(|n| r.from_name.eq_ignore_ascii_case(n))
};
let Some(r) = self.requests.iter().rfind(pick) else {
return err("You have no trade request to answer.");
};
let (from, from_name) = (r.from, r.from_name.clone());
self.requests
.retain(|r| !(r.from == from && r.to == me.uuid));
if verb == "deny" {
return Some((
vec![
Out::Reply(format!("You refused {from_name}'s trade.")),
Out::Tell(from, format!("{} refused your trade.", me.name)),
],
Vec::new(),
));
}
if !may_trade {
return err("You can trade only in survival or adventure mode.");
}
let Some((_, _, they_may)) = online(&from_name).filter(|(u, _, _)| *u == from)
else {
return err(&format!("{from_name} is no longer here."));
};
if !they_may {
return err(&format!("{from_name} can't trade in their game mode."));
}
if self.index_of(me.uuid).is_some() || self.index_of(from).is_some() {
return err("One of you is already in a trade.");
}
self.requests
.retain(|r| r.from != from && r.from != me.uuid);
self.open.push(Trade {
sides: [Side::new(from, &from_name), Side::new(me.uuid, &me.name)],
});
Some((Vec::new(), vec![Act::Show(from), Act::Show(me.uuid)]))
}
Some(name) => {
if rest.is_some() {
return err(usage);
}
if !may_trade {
return err("You can trade only in survival or adventure mode.");
}
let Some((to, to_name, they_may)) = online(name) else {
return err(&format!("{name} is not here."));
};
if to == me.uuid {
return err("You can't trade with yourself.");
}
if !they_may {
return err(&format!("{to_name} can't trade in their game mode."));
}
if self.index_of(me.uuid).is_some() || self.index_of(to).is_some() {
return err("One of you is already in a trade.");
}
// A request back is an answer.
if self
.requests
.iter()
.any(|r| r.from == to && r.to == me.uuid)
{
return self.command(
economy,
me,
may_trade,
online,
now,
&format!("trade accept {to_name}"),
);
}
self.requests.retain(|r| r.from != me.uuid);
self.requests.push(Request {
from: me.uuid,
from_name: me.name.clone(),
to,
at: now,
});
Some((
vec![
Out::Reply(format!("Trade request sent to {to_name}.")),
Out::Tell(
to,
format!(
"{} wants to trade with you: /trade accept {} or /trade deny {} (60 s).",
me.name, me.name, me.name
),
),
],
Vec::new(),
))
}
}
}
/// A click in `uuid`'s trade menu: on their inventory's slot `inventory`
/// holding `stack` (offered whole), or on the menu's slot `menu`.
pub fn click(
&mut self,
uuid: u128,
menu: Option<usize>,
inventory: Option<(usize, S)>,
) -> (Vec<Out>, Vec<Act>) {
let Some(i) = self.index_of(uuid) else {
return (Vec::new(), Vec::new());
};
let t = &mut self.open[i];
let me = t.side_of(uuid).expect("found");
let other = t.sides[1 - me].uuid;
let shown = vec![Act::Show(uuid), Act::Show(other)];
if let Some((slot, stack)) = inventory {
let side = &mut t.sides[me];
if side.offer.iter().any(|(s, _)| *s == slot) {
return (Vec::new(), Vec::new());
}
if side.offer.len() >= OFFER_SLOTS {
return (
vec![Out::Tell(
uuid,
format!("You can offer {OFFER_SLOTS} stacks at most."),
)],
Vec::new(),
);
}
side.offer.push((slot, stack));
t.unconfirm();
return (Vec::new(), shown);
}
match menu {
Some(CANCEL) => {
let why = format!("{} cancelled", t.sides[me].name);
self.end(uuid, &why)
}
Some(CONFIRM) => {
t.sides[me].confirmed = !t.sides[me].confirmed;
if t.sides.iter().all(|s| s.confirmed) {
(Vec::new(), vec![Act::Settle(uuid)])
} else {
(Vec::new(), shown)
}
}
Some(slot) => match own_square(slot).filter(|&q| q < t.sides[me].offer.len()) {
Some(q) => {
t.sides[me].offer.remove(q);
t.unconfirm();
(Vec::new(), shown)
}
None => (Vec::new(), Vec::new()),
},
None => (Vec::new(), Vec::new()),
}
}
/// Checks `uuid`'s offer against their inventory now (`stacks`, by
/// slot): a stack that changed (used, dropped, grown by a pickup) leaves
/// the offer and both confirmations are cleared. Whether anything left.
pub fn refresh(&mut self, uuid: u128, stacks: &[Option<S>]) -> bool {
let Some(i) = self.index_of(uuid) else {
return false;
};
let t = &mut self.open[i];
let me = t.side_of(uuid).expect("found");
let before = t.sides[me].offer.len();
t.sides[me]
.offer
.retain(|(slot, s)| stacks.get(*slot).and_then(|x| x.as_ref()) == Some(s));
let changed = t.sides[me].offer.len() < before;
if changed {
t.unconfirm();
}
changed
}
/// What `uuid`'s trade menu shows, slot by slot.
pub fn layout(&self, uuid: u128) -> Vec<Cell<S>> {
let mut cells = vec![Cell::Empty; SIZE];
let Some(t) = self.trade_of(uuid) else {
return cells;
};
let me = t.side_of(uuid).expect("found");
let (mine, theirs) = (&t.sides[me], &t.sides[1 - me]);
for row in 0..ROWS as usize {
cells[row * 9 + 4] = Cell::Divider;
}
for (i, (_, s)) in mine.offer.iter().enumerate() {
cells[offer_slot(i, true)] = Cell::Stack(s.clone());
}
for (i, (_, s)) in theirs.offer.iter().enumerate() {
cells[offer_slot(i, false)] = Cell::Stack(s.clone());
}
cells[MONEY] = Cell::Money(true, mine.money);
cells[OTHER_MONEY] = Cell::Money(false, theirs.money);
cells[CONFIRM] = Cell::Confirm(mine.confirmed);
cells[CANCEL] = Cell::Cancel;
cells[OTHER_STATUS] = Cell::Other(theirs.name.clone(), theirs.confirmed);
cells
}
}
#[cfg(test)]
mod tests {
use super::*;
const A: u128 = 1;
const B: u128 = 2;
type S = (&'static str, u32);
fn economy() -> Economy {
// One folder per call: the tests run side by side.
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("smp-trade-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut e = Economy::open(&dir, Default::default()).unwrap();
e.seen(A, "Alex");
e.seen(B, "Steve");
e.adjust("eco_give", A, 100, "test").unwrap();
e
}
fn player(uuid: u128) -> Player {
Player {
uuid,
name: if uuid == A { "Alex" } else { "Steve" }.into(),
level: 0,
}
}
fn online(name: &str) -> Option<(u128, String, bool)> {
match name.to_ascii_lowercase().as_str() {
"alex" => Some((A, "Alex".into(), true)),
"steve" => Some((B, "Steve".into(), true)),
_ => None,
}
}
fn open() -> (Economy, Trades<S>) {
let e = economy();
let mut t = Trades::default();
t.command(&e, &player(A), true, &online, 0, "trade Steve")
.unwrap();
let (_, acts) = t
.command(&e, &player(B), true, &online, 1_000, "trade accept")
.unwrap();
assert_eq!(acts, [Act::Show(A), Act::Show(B)]);
(e, t)
}
#[test]
fn a_request_opens_a_trade_once_accepted() {
let (_, t) = open();
assert_eq!(t.open.len(), 1);
let cells = t.layout(B);
assert_eq!(cells[OTHER_STATUS], Cell::Other("Alex".into(), false));
assert_eq!(cells[CONFIRM], Cell::Confirm(false));
}
#[test]
fn requests_expire_and_can_be_refused() {
let e = economy();
let mut t: Trades<S> = Trades::default();
t.command(&e, &player(A), true, &online, 0, "trade Steve")
.unwrap();
let (out, _) = t
.command(&e, &player(B), true, &online, REQUEST_MS, "trade accept")
.unwrap();
assert!(matches!(&out[0], Out::Error(_)));
t.command(&e, &player(A), true, &online, 0, "trade Steve")
.unwrap();
t.command(&e, &player(B), true, &online, 10, "trade deny Alex")
.unwrap();
let (out, _) = t
.command(&e, &player(B), true, &online, 20, "trade accept")
.unwrap();
assert!(matches!(&out[0], Out::Error(_)));
// Creative players don't trade; nor does a player with themselves.
let (out, _) = t
.command(&e, &player(A), false, &online, 0, "trade Steve")
.unwrap();
assert!(matches!(&out[0], Out::Error(_)));
let (out, _) = t
.command(&e, &player(A), true, &online, 0, "trade Alex")
.unwrap();
assert!(matches!(&out[0], Out::Error(_)));
assert!(t.open.is_empty());
}
#[test]
fn any_change_clears_both_confirmations() {
let (e, mut t) = open();
t.click(A, None, Some((3, ("minecraft:diamond", 5))));
t.click(A, Some(CONFIRM), None);
t.click(B, Some(CONFIRM), None);
// Both confirmed: settled; a click after the settle's failure, or a
// change, needs both again.
assert_eq!(t.open[0].sides.iter().filter(|s| s.confirmed).count(), 2);
t.click(B, None, Some((0, ("minecraft:emerald", 1))));
assert!(t.open[0].sides.iter().all(|s| !s.confirmed));
t.click(A, Some(CONFIRM), None);
t.command(&e, &player(B), true, &online, 0, "trade money 0")
.unwrap();
assert!(t.open[0].sides.iter().all(|s| !s.confirmed));
// Taking a stack back out of the offer.
t.click(A, Some(CONFIRM), None);
t.click(A, Some(offer_slot(0, true)), None);
assert!(t.open[0].sides[0].offer.is_empty());
assert!(!t.open[0].sides[0].confirmed);
}
#[test]
fn the_second_confirmation_settles() {
let (_, mut t) = open();
t.click(A, None, Some((3, ("minecraft:diamond", 5))));
assert_eq!(
t.click(A, Some(CONFIRM), None).1,
[Act::Show(A), Act::Show(B)]
);
assert_eq!(t.click(B, Some(CONFIRM), None).1, [Act::Settle(B)]);
}
#[test]
fn an_offer_is_one_slot_once_and_sixteen_at_most() {
let (_, mut t) = open();
t.click(A, None, Some((3, ("minecraft:diamond", 5))));
t.click(A, None, Some((3, ("minecraft:diamond", 5))));
assert_eq!(t.open[0].sides[0].offer.len(), 1);
for slot in 10..30 {
t.click(A, None, Some((slot, ("minecraft:dirt", 1))));
}
assert_eq!(t.open[0].sides[0].offer.len(), OFFER_SLOTS);
assert_eq!(
t.layout(B)[offer_slot(0, false)],
Cell::Stack(("minecraft:diamond", 5))
);
}
#[test]
fn a_stack_that_changed_leaves_the_offer() {
let (_, mut t) = open();
t.click(A, None, Some((3, ("minecraft:diamond", 5))));
t.click(A, None, Some((4, ("minecraft:dirt", 1))));
t.click(A, Some(CONFIRM), None);
let mut inv: Vec<Option<S>> = vec![None; 36];
inv[3] = Some(("minecraft:diamond", 5));
inv[4] = Some(("minecraft:dirt", 1));
assert!(!t.refresh(A, &inv));
assert!(t.open[0].sides[0].confirmed);
// A pickup grew the diamonds.
inv[3] = Some(("minecraft:diamond", 6));
assert!(t.refresh(A, &inv));
assert_eq!(t.open[0].sides[0].offer, [(4, ("minecraft:dirt", 1))]);
assert!(!t.open[0].sides[0].confirmed);
}
#[test]
fn every_way_out_ends_the_trade_with_nothing_moved() {
// Cancel button, /trade cancel; closing and leaving go through
// `end` from the glue.
let (e, mut t) = open();
let (out, acts) = t.click(B, Some(CANCEL), None);
assert_eq!(acts, [Act::Close(A), Act::Close(B)]);
assert!(matches!(&out[0], Out::Tell(A, s) if s.contains("Nothing was exchanged")));
assert!(t.open.is_empty());
let (_, mut t) = open();
t.command(&e, &player(A), true, &online, 0, "trade cancel")
.unwrap();
assert!(t.open.is_empty());
let (_, mut t) = open();
t.end(B, "Steve left");
assert!(t.open.is_empty());
// Money beyond the balance is refused when offered.
let (_, mut t) = open();
let (out, _) = t
.command(&e, &player(A), true, &online, 0, "trade money 101")
.unwrap();
assert!(matches!(&out[0], Out::Error(_)));
t.command(&e, &player(A), true, &online, 0, "trade money 100")
.unwrap();
assert_eq!(t.open[0].sides[0].money, 100);
}
}
+12
View File
@@ -0,0 +1,12 @@
// The end of each server tick: the SMP's accounts and files.
fn smp_server_tick(
game: &mut crate::game::Game<'_>,
phase: crate::game::native_lifecycle::ServerTickPhase,
) {
if matches!(
phase,
crate::game::native_lifecycle::ServerTickPhase::ServerTickEnd { .. }
) {
crate::game::smp_glue::tick(game);
}
}
+789
View File
@@ -0,0 +1,789 @@
"""The SMP on a real dedicated server: the built game runs `--dedicated` with the mod, and two real clients (Alex and
Steve, `horizon-ctl --offscreen`) join it. Checked from the clients' own chat and from the files the server wrote:
- the economy opens an account for each player (config.json written with its defaults, then economy.json);
- /balance, /pay (the payer's answer and the payee's line), refused payments (too much, to oneself, unknown name);
- /baltop; /eco is refused below level 3 and works for an operator (made one from the server's console);
- the commands are in each player's command tree (the chat box's suggestions);
- the market (Steve in survival, items given from the console): /worth, /sell <item>, /sell hand, /buy, with the
items gone from or in his inventory as his client sees it; a creative player can't sell;
- trades (`/trade`, the server's chest menu clicked from each client): closing the menu, a full inventory, the
cancel button, dying and leaving each end or refuse a trade with nothing moved (each player's items counted from
their own client); a change of offer clears the other's confirmation; a confirmed trade swaps the items and money;
- coinflips (turned on in config.json before the first start, the other settings filled in): the stake is held from
the offer, the winner gets both, the limits and the cooldown refuse, and a stake left waiting when the server stops
comes back; the money in the game stays the same throughout;
- the lottery: two players' tickets held, one drawn after `lottery_seconds`, the winner announced to everyone and paid
the pot;
- after the server stops cleanly and starts again, the balances are the same, and audit.log holds every change;
- /money and /eco give * for an operator, then the /shop menu: a category, one item bought by a click and a stack by
a shift-click, a stack of the player's own sold by clicking it, "Sell all" stopped by the daily limit (the rest
kept, the limit shown in the menu and in /worth, the day's sales in market.json), the close button.
Everything runs in fresh temporary folders (the server's, each client's home, launcher and XDG folders), no audio.
"""
import json
import math
import os
import shutil
import socket
import subprocess
import tempfile
import threading
import time
from pathlib import Path
PROPERTIES = {
"online-mode": "false", "white-list": "false", "enforce-secure-profile": "false", "level-type": "minecraft\\:flat", "spawn-protection": "0", "max-players": "4",
"view-distance": "4", "simulation-distance": "4", "generate-structures": "false", "enable-command-block": "false",
"difficulty": "peaceful", "spawn-monsters": "false",
}
def free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def scratch_env(root):
env = dict(os.environ, HORIZON_NO_AUDIO="1", HORIZON_LAUNCHER_NO_SELF_UPDATE="1")
for key in ("HORIZON_HOME", "HORIZON_LAUNCHER_HOME", "XDG_DATA_HOME", "XDG_CONFIG_HOME"):
path = root / key.lower()
path.mkdir(parents=True, exist_ok=True)
env[key] = str(path)
return env
class Server:
"""`horizon-client --dedicated --nogui` in a scratch server folder, its console on stdin."""
def __init__(self, game, resources, folder, port):
self.folder, self.lines, self.game, self.resources, self.port = folder, [], game, resources, port
folder.mkdir(parents=True, exist_ok=True)
props = dict(PROPERTIES, **{"server-port": str(port)})
(folder / "server.properties").write_text("".join(f"{k}={v}\n" for k, v in props.items()))
(folder / "eula.txt").write_text("eula=true\n")
self.env = scratch_env(folder.parent / "server-env")
def start(self):
self.lines = []
self.process = subprocess.Popen(
[str(self.game), "--resources", str(self.resources), "--dedicated", "--nogui"], cwd=self.folder,
env=self.env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
threading.Thread(target=self._read, daemon=True).start()
self.wait_for("Done (", 600)
def _read(self):
for line in self.process.stdout:
self.lines.append(line.rstrip("\n"))
def wait_for(self, text, timeout=120):
end = time.time() + timeout
while time.time() < end:
if any(text in line for line in self.lines):
return
if self.process.poll() is not None:
break
time.sleep(0.2)
raise AssertionError(f"server: no {text!r} in {timeout} s; last lines: {self.lines[-30:]}")
def console(self, line):
self.process.stdin.write(line + "\n")
self.process.stdin.flush()
def stop(self):
if self.process.poll() is None:
self.console("stop")
try:
self.process.wait(120)
except subprocess.TimeoutExpired:
self.process.kill()
return self.lines
def new_lines(before, after):
"""The lines `after` adds to `before`: the client keeps only its last 100 chat lines, so the oldest may be gone."""
for n in range(min(len(before), len(after)), -1, -1):
if before[len(before) - n:] == after[:n]:
return after[n:]
return after
class Client:
"""A real client under horizon-ctl, joined to the server as `name`."""
def __init__(self, ctl, resources, name, root):
self.ctl, self.name, self.resources = ctl, name, resources
self.home = root / name
self.env = scratch_env(self.home)
self.socket = str(self.home / "ctl.sock")
def run(self, *args, check=True):
done = subprocess.run([str(self.ctl), "--socket", self.socket, *map(str, args)], env=self.env,
cwd=self.home, capture_output=True, text=True, timeout=600)
if check and done.returncode:
raise AssertionError(f"{self.name}: {args}: {done.stdout[-400:]} {done.stderr[-400:]}")
try:
return json.loads(done.stdout.strip().splitlines()[-1])
except (json.JSONDecodeError, IndexError):
return done.stdout
def join(self, port):
self.run("launch", "--offscreen", "--resources", self.resources)
self.run("connect", f"127.0.0.1:{port}", self.name)
self.run("wait-for", "loaded", "--timeout", 300)
time.sleep(2)
def say(self, line):
"""A command typed in chat; returns the chat lines it brought."""
before = self.chat()
self.run("chat", line)
# The answer, however busy the machine: up to 10 s for a first line,
# then a moment for the rest of it.
for _ in range(40):
time.sleep(0.25)
if self.chat() != before:
break
time.sleep(1)
return new_lines(before, self.chat())
def chat(self):
"""Every chat line so far, oldest first (`messages` answers newest first)."""
reply = self.run("messages", 500)
return list(reversed(reply.get("chat", [])))
def quit(self):
self.run("quit", check=False)
def screen(self):
return self.run("screen")
def menu(self):
"""The open server menu's items by its own slot (horizon-ctl numbers them from 300)."""
return {e["slot"] - 300: e for e in self.screen().get("slots", []) if e.get("item") and e["slot"] >= 300}
def slot_of(self, item):
"""The inventory slot (0..36) holding `item`, as the open screen shows it."""
return next(e["slot"] for e in self.screen().get("slots", []) if e.get("item") == item and e["slot"] < 36)
def count(self, item):
counts = self.run("inventory").get("counts", {})
return counts.get(item, counts.get(item.removeprefix("minecraft:"), 0))
def click(self, slot):
self.run("click-slot", slot)
time.sleep(1)
CONFIRM, CANCEL, THEIR_FIRST = 300 + 45, 300 + 48, 5
def open_trade(alex, steve):
assert any("Trade request sent to Steve." in l for l in alex.say("/trade Steve"))
assert any("Alex wants to trade with you" in l for l in steve.chat()[-3:])
steve.say("/trade accept")
time.sleep(1)
for c, other in ((alex, "Steve"), (steve, "Alex")):
menu = c.menu()
assert menu.get(45, {}).get("item") == "minecraft:lime_stained_glass_pane", (c.name, menu)
title = "".join(s["text"] for s in c.screen().get("title", []))
assert title == f"Trade with {other}", title
def coinflips(alex, steve):
"""Alex 415, Steve 400. Returns what was seen and whether Alex won."""
results = {}
results["limits"] = alex.say("/coinflip 5")
assert any("A coinflip is 10 coins to 1,000 coins." in l for l in results["limits"]), results
results["offer"] = alex.say("/coinflip 100 Steve")
assert any("Coinflip of 100 coins offered to Steve" in l for l in results["offer"]), results
assert any("Balance: 315 coins" in l for l in alex.say("/balance")), "the stake is held"
assert any("Alex offers you a coinflip of 100 coins" in l for l in steve.chat()[-3:])
steve.say("/coinflip accept Alex")
time.sleep(1)
results["alex_saw"] = alex.chat()[-1:]
alex_wins = any("you won 200 coins" in l for l in results["alex_saw"])
assert alex_wins or any("you lost 100 coins" in l for l in results["alex_saw"]), results
steve_saw = steve.chat()[-2:]
assert any(("you lost 100 coins" if alex_wins else "you won 200 coins") in l for l in steve_saw), steve_saw
assert any(f"Balance: {415 + (100 if alex_wins else -100)} coins" in l for l in alex.say("/balance"))
assert any(f"Balance: {400 + (-100 if alex_wins else 100)} coins" in l for l in steve.say("/balance"))
results["cooldown"] = steve.say("/coinflip 50")
assert any("before your next coinflip" in l for l in results["cooldown"]), results
time.sleep(20)
assert any("Coinflip of 50 coins offered" in l for l in steve.say("/coinflip 50")), "after the cooldown"
return results, alex_wins
def lottery(alex, steve, alex_wins):
"""Alex buys 3 tickets, Steve 2; the round draws 30 s after the first. Returns what was seen, whether Alex won."""
results = {}
alex_had = 415 + (100 if alex_wins else -100)
steve_had = 400 + (-100 if alex_wins else 100) - 50 # his coinflip of 50 is still held
results["buy"] = alex.say("/lottery buy 3")
assert any("You bought 3 ticket(s) for 30 coins; the pot is 30 coins" in l for l in results["buy"]), results
assert any("the pot is 50 coins" in l for l in steve.say("/lottery buy 2"))
assert any(f"Balance: {alex_had - 30} coins" in l for l in alex.say("/balance")), "the tickets are held"
results["status"] = steve.say("/lottery")
assert any("Lottery: pot 50 coins, 2 ticket(s) of yours" in l for l in results["status"]), results
time.sleep(32)
results["draw"] = [l for l in alex.chat()[-3:] if l.startswith("Lottery:")]
assert len(results["draw"]) == 1 and "won 50 coins" in results["draw"][0], results
assert results["draw"][0] in steve.chat()[-3:], "everyone hears the draw"
alex_lotto = results["draw"][0].startswith("Lottery: Alex won")
results["alex_after"] = alex.say("/balance")
assert any(f"Balance: {alex_had - 30 + (50 if alex_lotto else 0)} coins" in l for l in results["alex_after"]), (
results, alex_had, alex.chat()[-12:])
results["steve_after"] = steve.say("/balance")
assert any(f"Balance: {steve_had - 20 + (0 if alex_lotto else 50)} coins" in l for l in results["steve_after"]), (
results, steve_had, steve.chat()[-12:])
return results, alex_lotto
def where(c):
"""The client's dimension, position and health."""
s = c.run("state")
return s.get("dimension"), s["player"]["position"], s["player"]["health"]
def near(a, b):
return sum((x - y) ** 2 for x, y in zip(a, b)) < 1.5 ** 2
def places(server, alex, steve):
"""Spawn, homes, warps, /tpa and /rules; teleports across levels, called off by a move or a hit."""
r = {}
r["setspawn"] = alex.say("/setspawn")
assert any("The spawn is here now." in l for l in r["setspawn"]), r
r["sethome"] = steve.say("/sethome base")
assert any('Home "base" set here.' in l for l in r["sethome"]), r
_, home, _ = where(steve)
r["limit"] = steve.say("/sethome two")
assert any("You have 1 of 1 homes" in l for l in r["limit"]), r
# To the Nether's bedrock roof, then home, across levels.
server.console("execute in minecraft:the_nether run tp Steve 0.5 128 0.5")
time.sleep(5)
assert where(steve)[0] == "minecraft:the_nether", where(steve)
r["home"] = steve.say("/home base")
assert any("Teleporting in 2 s: don't move." in l for l in r["home"]), r
time.sleep(5)
dim, at, _ = where(steve)
r["home_reached"] = [dim, at]
assert dim == "minecraft:overworld" and near(at, home), (r, home)
assert any('Teleported to home "base".' in l for l in steve.chat()[-4:]), steve.chat()[-4:]
# A move or a hit during the warmup calls it off.
steve.say("/spawn")
steve.run("move", "forward", 1)
time.sleep(2)
r["moved"] = steve.chat()[-3:]
assert any("Teleport called off: you moved." in l for l in r["moved"]), r
steve.say("/spawn")
server.console("damage Steve 2")
time.sleep(3)
r["hurt"] = steve.chat()[-3:]
assert any("Teleport called off: you were hurt." in l for l in r["hurt"]), r
# A request: Alex goes to Steve once Steve accepts.
alex.say("/tpa Steve")
assert any("Alex asks to teleport to you" in l for l in steve.chat()[-3:]), steve.chat()[-3:]
steve.say("/tpaccept")
time.sleep(4)
r["tpa"] = [where(alex)[1], where(steve)[1]]
assert near(*r["tpa"]), r
alex.say("/setwarp shop")
r["warps"] = steve.say("/warps")
assert any("Warps: shop" in l for l in r["warps"]), r
r["warp_refused"] = steve.say("/setwarp mine")
assert not any("set here" in l for l in r["warp_refused"]), r
r["rules"] = steve.say("/rules")
assert any("1. Be respectful to everyone." in l for l in r["rules"]), r
return r
def claims(server, alex, steve):
"""Alex claims a chunk: Steve can't break, use or place there until trusted."""
r = {}
_, (ax, ay, az), _ = where(alex)
cx, cz, y = math.floor(ax) >> 4, math.floor(az) >> 4, math.floor(ay)
r["claim"] = alex.say("/claim")
assert any("Claimed this chunk" in l for l in r["claim"]), r
lever, dirt, free = (cx * 16 + 8, y, cz * 16 + 8), (cx * 16 + 10, y, cz * 16 + 8), (cx * 16 + 8, y, cz * 16 + 10)
server.console("setblock %d %d %d minecraft:lever[face=floor]" % lever)
server.console("setblock %d %d %d minecraft:dirt" % dirt)
server.console(f"tp Steve {lever[0] + 0.5} {y} {lever[2] + 3.5}")
server.console("give Steve minecraft:oak_planks 4")
time.sleep(3)
block = lambda pos: json.dumps(steve.run("block", *pos))
steve.run("interact", *lever)
time.sleep(0.5)
# The action bar fades after 3 s: read right after the click (a busy machine may take that long to break).
said = json.dumps(steve.run("messages", 5))
steve.run("break", *dirt, "--timeout", 4, check=False)
steve.run("place", *free, "--item", "minecraft:oak_planks", check=False)
time.sleep(1.5)
r["refused"] = [block(lever), block(dirt), block(free), said + json.dumps(steve.run("messages", 5))]
# Not broken (grass may spread onto the dirt meanwhile).
assert "powered=true" not in r["refused"][0] and "minecraft:air" not in r["refused"][1], r
assert "oak_planks" not in r["refused"][2], r
assert "This land is claimed by Alex." in r["refused"][3], r
r["trust"] = alex.say("/trust Steve")
assert any("Steve may now build" in l for l in r["trust"]), r
steve.run("interact", *lever)
steve.run("break", *dirt, "--timeout", 10, check=False)
time.sleep(1.5)
r["trusted"] = [block(lever), block(dirt)]
assert "powered=true" in r["trusted"][0] and '"block": "minecraft:air"' in r["trusted"][1], r
r["claims"] = alex.say("/claims")
assert any("Your chunks (1 of 4)" in l for l in r["claims"]) and any("Trusted: Steve" in l for l in r["claims"]), r
return r
def world_guard(server, alex, steve):
"""Nothing reaches Alex's chunk from outside: water, fire, a piston, TNT, Steve's bucket and fists."""
r = {}
_, (ax, ay, az), _ = where(alex)
x0, z0, y = (math.floor(ax) >> 4) * 16, (math.floor(az) >> 4) * 16, math.floor(ay)
r["untrust"] = alex.say("/untrust Steve")
block = lambda x, yy, z: json.dumps(steve.run("block", x, yy, z))
for line in ("weather clear", f"tp Alex {x0 + 8.5} {y} {z0 - 12.5}", f"tp Steve {x0 + 4.5} {y} {z0 - 1.5}",
# Water two blocks east of the chunk; fire on netherrack against planks on both sides of its
# west edge (more planks outside, so some burn soon).
f"setblock {x0 + 17} {y} {z0 + 8} minecraft:water",
f"setblock {x0 - 1} {y - 1} {z0 + 8} minecraft:netherrack",
f"setblock {x0} {y} {z0 + 8} minecraft:oak_planks",
f"setblock {x0 - 2} {y} {z0 + 8} minecraft:oak_planks",
f"setblock {x0 - 1} {y} {z0 + 7} minecraft:oak_planks",
f"setblock {x0 - 1} {y} {z0 + 9} minecraft:oak_planks",
f"setblock {x0 - 1} {y + 1} {z0 + 8} minecraft:oak_planks",
f"setblock {x0 - 1} {y} {z0 + 8} minecraft:fire",
# North of the chunk: a piston pushing dirt into it, another pushing away from it.
f"setblock {x0 + 8} {y} {z0 - 2} minecraft:piston[facing=south]",
f"setblock {x0 + 8} {y} {z0 - 1} minecraft:dirt",
f"setblock {x0 + 12} {y} {z0 - 2} minecraft:piston[facing=north]",
f"setblock {x0 + 12} {y} {z0 - 3} minecraft:dirt",
f"setblock {x0 + 8} {y} {z0 - 3} minecraft:redstone_block",
f"setblock {x0 + 12} {y} {z0 - 1} minecraft:redstone_block",
"give Steve minecraft:water_bucket",
f"summon minecraft:cow {x0 + 5.5} {y} {z0 + 0.5} {{NoAI:1b}}"):
server.console(line)
time.sleep(4)
r["water"] = [block(x0 + 16, y, z0 + 8), block(x0 + 15, y, z0 + 8)]
assert "water" in r["water"][0] and "water" not in r["water"][1], r
r["pistons"] = [block(x0 + 8, y, z0 - 2), block(x0 + 8, y, z0 - 1), block(x0 + 12, y, z0 - 2)]
assert "extended=false" in r["pistons"][0] and ("dirt" in r["pistons"][1] or "grass_block" in r["pistons"][1]), r
assert "extended=true" in r["pistons"][2], r
# Steve's bucket on the chunk's ground: no water; then on the ground outside it.
steve.run("look-at-block", x0 + 3, y - 1, z0 + 1, "up")
steve.run("use-item")
time.sleep(1.5)
r["bucket"] = [block(x0 + 3, y, z0 + 1), json.dumps(steve.run("messages", 5))]
assert "water" not in r["bucket"][0] and "This land is claimed by Alex." in r["bucket"][1], r
# Steve's fist on Alex's cow: nothing, until Alex trusts him.
cow = steve.run("entities", 8, "cow")["entities"][0]
steve.run("attack", cow["id"])
time.sleep(1.5)
r["cow"] = [steve.run("entities", 8, "cow")["entities"][0]["health"]]
r["trust"] = alex.say("/trust Steve")
steve.run("attack", cow["id"])
time.sleep(1.5)
r["cow"].append(steve.run("entities", 8, "cow")["entities"][0]["health"])
assert r["cow"][0] == 10 and r["cow"][1] < 10, r
# Fire: the planks outside burn, those inside never.
for _ in range(90):
outside = [block(x0 - 2, y, z0 + 8), block(x0 - 1, y, z0 + 7), block(x0 - 1, y, z0 + 9),
block(x0 - 1, y + 1, z0 + 8)]
if sum("oak_planks" not in b for b in outside) >= 2:
break
time.sleep(1)
r["fire"] = outside + [block(x0, y, z0 + 8)]
assert sum("oak_planks" not in b for b in outside) >= 1 and "oak_planks" in r["fire"][-1], r
# TNT two blocks south of the chunk: a crater outside, not one block of the chunk broken.
inside = lambda: json.dumps(steve.run("blocks", x0 + 4, y - 4, z0 + 12, x0 + 12, y + 2, z0 + 15)["counts"])
r["before_tnt"] = inside()
server.console(f"summon minecraft:tnt {x0 + 8.5} {y} {z0 + 17.5} {{fuse:1}}")
for _ in range(20):
time.sleep(1)
if "grass" not in block(x0 + 8, y - 1, z0 + 17):
break
time.sleep(1)
r["after_tnt"] = [inside(), block(x0 + 8, y - 1, z0 + 17)]
assert r["after_tnt"][0] == r["before_tnt"] and "grass" not in r["after_tnt"][1], r
return r
def board(alex, steve, out):
"""The sidebar of the richest players and the Tab list; /sidebar off hides it, on shows it again."""
r = {}
side = lambda c: c.run("status").get("sidebar")
for _ in range(20):
r["sidebar"] = side(steve)
if r["sidebar"]:
break
time.sleep(0.5)
assert r["sidebar"]["objective"] == "horizon:native_sidebar" and r["sidebar"]["title"] == "Richest players", r
# Alex and Steve, a gap, then Steve's own line.
assert len(r["sidebar"]["scores"]) == 4, r
# Holders starting with '#' would be hidden from the sidebar (only the title would show).
assert not any(o.startswith("#") for o in r["sidebar"]["scores"]), r
steve.run("key", "tab", "down")
time.sleep(1)
steve.run("screenshot", str(Path(out).resolve() / "sidebar-and-tab.png"))
steve.run("key", "tab", "up")
r["off"] = steve.say("/sidebar off")
assert any("The sidebar is off" in l for l in r["off"]), r
time.sleep(1)
assert not side(steve), side(steve)
assert side(alex), "only Steve's is gone"
r["on"] = steve.say("/sidebar")
assert any("The sidebar is on" in l for l in r["on"]), r
for _ in range(10):
if side(steve):
break
time.sleep(0.5)
assert side(steve), "back within a second or two"
steve.say("/sidebar off")
return r
def polish(server, alex, steve, out):
"""/back after a death, /playtime, /claim show."""
r = {}
_, died_at, _ = where(steve)
server.console("kill Steve")
time.sleep(2.5)
steve.run("respawn")
time.sleep(2)
r["died_at"], r["respawned_at"] = died_at, where(steve)[1]
r["back"] = steve.say("/back")
assert any("Teleporting in" in l for l in r["back"]), r
time.sleep(4)
r["back_reached"] = where(steve)[1]
assert near(r["back_reached"], died_at), r
assert any("where you died" in l for l in steve.chat()[-4:]), steve.chat()[-4:]
r["playtime"] = steve.say("/playtime")
assert any("You have played here for" in l for l in r["playtime"]), r
r["playtime_alex"] = steve.say("/playtime Alex")
assert any("Alex has played here for" in l for l in r["playtime_alex"]), r
r["show"] = alex.say("/claim show")
assert any("This chunk's border, for 10 s" in l for l in r["show"]), r
time.sleep(2)
alex.run("screenshot", str(Path(out).resolve() / "claim-show.png"))
return r
def presence(server, alex, steve):
"""/afk keeps Steve out of the sleep count (Alex alone in bed passes the night); moving brings him back; too many
commands at once wait."""
r = {}
r["afk"] = steve.say("/afk")
assert any("You are AFK" in l for l in r["afk"]), r
_, (ax, ay, az), _ = where(alex)
x, y, z = math.floor(ax) + 2, math.floor(ay), math.floor(az)
for line in (f"setblock {x} {y} {z} minecraft:red_bed[part=foot,facing=south]",
f"setblock {x} {y} {z + 1} minecraft:red_bed[part=head,facing=south]", "time set 18000"):
server.console(line)
time.sleep(2)
alex.run("interact", x, y, z)
# Five seconds asleep, then the morning; Steve, AFK and awake, doesn't hold it back.
for _ in range(40):
r["time_of_day"] = alex.run("status")["day_time"] % 24000
if r["time_of_day"] < 12000:
break
time.sleep(0.5)
assert r["time_of_day"] < 12000, r
steve.run("move", "forward", 1)
time.sleep(2.5)
assert any("You are back." in l for l in steve.chat()[-4:]), steve.chat()[-4:]
for _ in range(9):
steve.run("chat", "/bal")
time.sleep(2)
r["spam"] = steve.chat()[-12:]
assert any("Slow down: at most 8 commands every 5 s." in l for l in r["spam"]), r
return r
def shop(server, alex):
"""Alex alone, op, in survival: /money and /eco give *, then the /shop menu: a category, buying one and a stack,
selling a stack of hers, "Sell all" up to the daily limit, closing. Returns the coins it added, and the lines."""
results = {}
# Only what the step gives her: "Sell all" would sell the rest too.
server.console("clear Alex")
time.sleep(1)
results["money"] = alex.say("/money give Alex 2000")
assert any("Alex now has" in l for l in results["money"]), results
results["everyone"] = alex.say("/eco give * 10")
assert any("Gave 10 coins to 1 player(s) on." in l for l in results["everyone"]), results
assert alex.count("minecraft:iron_ingot") == 0
alex.say("/shop")
menu = alex.menu()
assert menu[10]["item"] == "minecraft:wheat" and menu[48]["item"] == "minecraft:gold_ingot", menu
alex.click(300 + 11) # Mining
menu = alex.menu()
assert [menu[i]["item"] for i in range(3)] == ["minecraft:coal", "minecraft:copper_ingot",
"minecraft:iron_ingot"], menu
alex.click(300 + 2) # one iron ingot
assert alex.count("minecraft:iron_ingot") == 1, alex.run("inventory")
results["bought"] = alex.chat()[-2:]
assert any("Bought 1 iron ingot for 30 coins." in l for l in results["bought"]), results
alex.click(300 + 45) # back
alex.click(300 + 14) # Building
alex.run("click-slot", 300 + 0, "shift") # a stack of cobblestone
time.sleep(1)
assert alex.count("minecraft:cobblestone") == 64, alex.run("inventory")
# Her own stack of iron, clicked: sold.
alex.click(alex.slot_of("minecraft:iron_ingot"))
results["sold"] = alex.chat()[-2:]
assert any("Sold 1 iron ingot for 8 coins." in l for l in results["sold"]), results
assert alex.count("minecraft:iron_ingot") == 0
# Sell all, up to the 2,000 coins a day: 19 of 30 diamonds (8 coins earned already).
server.console("give Alex minecraft:diamond 30")
time.sleep(2)
alex.click(300 + 49)
results["sell_all"] = alex.chat()[-3:]
assert any("Sold 19 diamond for 1,900 coins." in l for l in results["sell_all"]), results
assert any("11 diamond kept: today's selling limit." in l for l in results["sell_all"]), results
assert alex.count("minecraft:diamond") == 11, alex.run("inventory")
limits = alex.menu()[50]
results["limits"] = limits
assert "92 coins left of your 2,000 coins a day" in json.dumps(limits), limits
alex.click(300 + 53) # close
assert not alex.menu()
results["worth"] = alex.say("/worth diamond")
assert any("You may sell 621 more diamond today." in l for l in results["worth"]), results
return 2000 + 10 - 30 - 64 + 8 + 1900, results
def trades(server, alex, steve, port, ctl, resources, root):
"""Every way out of a trade, then one that goes through. Alex: 10 diamonds; Steve: 5 emeralds, 5 torches."""
results = {}
items = lambda: (alex.count("minecraft:diamond"), alex.count("minecraft:emerald"),
steve.count("minecraft:diamond"), steve.count("minecraft:emerald"))
assert items() == (10, 0, 0, 5), items()
# Closing the menu.
open_trade(alex, steve)
alex.click(alex.slot_of("minecraft:diamond"))
shown = steve.menu().get(THEIR_FIRST, {})
assert (shown.get("item"), shown.get("count")) == ("minecraft:diamond", 10), shown
assert alex.count("minecraft:diamond") == 10, "offered stacks stay in the inventory"
alex.run("close")
time.sleep(1.5)
results["closed"] = steve.chat()[-2:]
assert any("Trade cancelled: Alex closed the trade. Nothing was exchanged." in l for l in results["closed"])
assert not steve.menu(), "the other's menu closes too"
assert items() == (10, 0, 0, 5), items()
# A full inventory: Steve can't take the diamonds; nothing moves, the trade stays open, both must confirm again.
server.console("give Steve minecraft:dirt 2176")
time.sleep(2)
open_trade(alex, steve)
alex.click(alex.slot_of("minecraft:diamond"))
alex.click(CONFIRM)
steve.click(CONFIRM)
results["full"] = alex.chat()[-2:]
assert any("The trade didn't go through: the second player has no room." in l for l in results["full"]), results
assert alex.menu().get(45, {}).get("item") == "minecraft:lime_stained_glass_pane", "confirmations cleared"
assert items() == (10, 0, 0, 5), items()
# The cancel button.
steve.click(CANCEL)
assert any("Trade cancelled: Steve cancelled." in l for l in alex.chat()[-2:])
server.console("clear Steve minecraft:dirt")
time.sleep(1)
# Through: Alex's diamonds for Steve's emeralds and 35 coins. Steve's money offer clears Alex's confirmation.
open_trade(alex, steve)
alex.click(alex.slot_of("minecraft:diamond"))
steve.click(steve.slot_of("minecraft:emerald"))
alex.click(CONFIRM)
assert alex.menu()[45]["item"] == "minecraft:lime_concrete"
assert steve.menu()[52]["item"] == "minecraft:lime_concrete", "Steve sees Alex confirmed"
steve.say("/trade money 35")
assert alex.menu()[45]["item"] == "minecraft:lime_stained_glass_pane", "a change clears the confirmation"
alex.click(CONFIRM)
steve.click(CONFIRM)
time.sleep(1)
results["done"] = alex.chat()[-2:]
assert any("Trade with Steve done" in l for l in results["done"]), results
assert items() == (0, 5, 10, 0), items()
assert any("Balance: 415 coins" in l for l in alex.say("/balance"))
assert any("Balance: 400 coins" in l for l in steve.say("/balance"))
# Dying (inventories kept, so a duplicate would show): Alex's death ends it, nothing moved.
server.console("gamerule keep_inventory true")
time.sleep(1)
open_trade(alex, steve)
alex.click(alex.slot_of("minecraft:emerald"))
server.console("kill Alex")
time.sleep(2)
results["death"] = steve.chat()[-2:]
assert any("Trade cancelled" in l for l in results["death"]), results
alex.run("respawn")
time.sleep(2)
assert items() == (0, 5, 10, 0), items()
# Leaving: Steve quits mid-trade; nothing moved, his items are his when he comes back.
open_trade(alex, steve)
steve.click(steve.slot_of("minecraft:diamond"))
steve.quit()
time.sleep(3)
results["left"] = alex.chat()[-2:]
assert any("Trade cancelled" in l for l in results["left"]), results
assert not alex.menu()
steve = Client(ctl, resources, "Steve2", root)
steve.name = "Steve"
steve.join(port)
assert items() == (0, 5, 10, 0), items()
return results, steve
def run(ctl, resources, out):
out = Path(out)
out.mkdir(parents=True, exist_ok=True)
game = Path(ctl).with_name("horizon-client")
root = Path(tempfile.mkdtemp(prefix="smp-proof-"))
port = free_port()
server = Server(game, resources, root / "server", port)
alex = Client(ctl, resources, "Alex", root)
steve = Client(ctl, resources, "Steve", root)
results = {}
try:
data = server.folder / "world" / "horizon-smp"
data.mkdir(parents=True)
(data / "config.json").write_text(json.dumps({"gambling": {"enabled": True, "cooldown_seconds": 20,
"lottery_seconds": 30},
"places": {"warmup_seconds": 2, "cooldown_seconds": 0},
# Paid once, after the restart, not to change the sums.
"presence": {"daily_reward": 0}}))
server.start()
# No monster may end a step early: a slime once killed Steve on the flat world.
for line in ("difficulty peaceful", "gamerule spawn_monsters false", "kill @e[type=minecraft:slime]"):
server.console(line)
alex.join(port)
steve.join(port)
time.sleep(2)
config = json.loads((data / "config.json").read_text())
results["config_keys"] = sorted(config)
assert results["config_keys"] == ["board", "claims", "currency", "gambling", "market", "places", "presence",
"shop", "starting_balance"], config
results["welcome"] = steve.chat()
assert any("Welcome to the server, Steve!" in l for l in results["welcome"]), results["welcome"]
assert any("1. Be respectful to everyone." in l for l in results["welcome"]), results["welcome"]
assert any("Steve joined for the first time" in l for l in alex.chat()), alex.chat()
assert config["gambling"] == {"enabled": True, "min_bet": 10, "max_bet": 1000, "cooldown_seconds": 20,
"offer_seconds": 60, "fee_percent": 0, "ticket_price": 10,
"max_tickets": 100, "lottery_seconds": 30}, config
assert config["market"]["items"]["minecraft:diamond"] == {"buy": 400, "sell": 100}, config
server.console("op Alex")
time.sleep(1)
alex.say("/eco give Alex 500")
results["balance"] = alex.say("/balance")
assert any("Balance: 500 coins" in l for l in results["balance"]), results
results["pay"] = alex.say("/pay Steve 120")
assert any("You paid 120 coins to Steve. Balance: 380 coins" in l for l in results["pay"]), results
results["paid"] = steve.chat()[-3:]
assert any("Alex paid you 120 coins." in l for l in results["paid"]), results
results["refused"] = [alex.say(l) for l in ("/pay Steve 9999", "/pay Alex 1", "/pay Nobody 1")]
for lines, error in zip(results["refused"], ("You only have 380 coins.", "You can't pay yourself.",
"No player named Nobody has an account.")):
assert any(error in l for l in lines), results["refused"]
results["eco_refused"] = steve.say("/eco give Steve 1000")
assert not any("now has" in l for l in results["eco_refused"]), results
results["baltop"] = steve.say("/baltop")
assert any("1. Alex: 380 coins" in l for l in results["baltop"]), results
assert any("2. Steve: 120 coins" in l for l in results["baltop"]), results
server.console("give Steve minecraft:diamond 3")
server.console("give Steve minecraft:wheat 20")
time.sleep(2)
results["worth"] = steve.say("/worth diamond")
assert any("diamond: the market pays 100 coins each, sells it for 400 coins each." in l
for l in results["worth"]), results
results["sell_wheat"] = steve.say("/sell wheat")
assert any("Sold 20 wheat for 20 coins." in l for l in results["sell_wheat"]), results
results["sell_hand"] = steve.say("/sell hand")
assert any("Sold 3 diamond for 300 coins." in l for l in results["sell_hand"]), results
results["buy"] = steve.say("/buy torch 5")
assert any("Bought 5 torch for 5 coins." in l for l in results["buy"]), results
time.sleep(1)
counts = steve.run("inventory").get("counts", {})
results["steve_items"] = counts
have = lambda item: counts.get(f"minecraft:{item}", counts.get(item, 0))
assert (have("diamond"), have("wheat"), have("torch")) == (0, 0, 5), counts
server.console("gamemode creative Alex")
time.sleep(1)
results["creative"] = alex.say("/sell all")
assert any("only with players in survival or adventure mode" in l for l in results["creative"]), results
server.console("gamemode survival Alex")
server.console("give Alex minecraft:diamond 10")
server.console("give Steve minecraft:emerald 5")
time.sleep(2)
results["trades"], steve = trades(server, alex, steve, port, ctl, resources, root)
# Before the coinflips: their last offer must still wait when the server stops.
results["places"] = places(server, alex, steve)
results["claims"] = claims(server, alex, steve)
results["world_guard"] = world_guard(server, alex, steve)
results["board"] = board(alex, steve, out)
results["polish"] = polish(server, alex, steve, out)
results["presence"] = presence(server, alex, steve)
results["coinflip"], alex_wins = coinflips(alex, steve)
results["lottery"], alex_lotto = lottery(alex, steve, alex_wins)
alex_after = 415 + (100 if alex_wins else -100) + (20 if alex_lotto else -30)
# Steve's 50 is still waiting: the server stops with both on.
lines = server.stop()
steve.quit()
alex.quit()
assert not any("panicked" in l or "teleport failed" in l for l in lines), lines[-40:]
saved = json.loads((data / "places.json").read_text())
assert sorted(saved["warps"]) == ["shop"] and saved["spawn"] and len(saved["homes"]) == 1, saved
saved = json.loads((data / "board.json").read_text())
assert len(saved["hidden"]) == 1, saved
saved = json.loads((data / "claims.json").read_text())
assert len(saved["chunks"]) == 1 and len(saved["trust"]) == 1, saved
economy = json.loads((data / "economy.json").read_text())
balances = {a["name"]: a["balance"] for a in economy["accounts"].values()}
results["economy.json"] = economy
held = sum(economy.get("held", {}).values())
assert balances["Alex"] == alex_after and sum(balances.values()) + held == 815, economy
audit = [json.loads(l) for l in (data / "audit.log").read_text().splitlines()]
results["audit"] = [a["kind"] for a in audit]
assert results["audit"] == ["open", "open", "eco_give", "pay", "sell 20 minecraft:wheat",
"sell 3 minecraft:diamond", "buy 5 minecraft:torch", "trade", "trade_failed",
"trade", "trade_money", "coinflip_stake", "coinflip_stake", "coinflip_win",
"coinflip_stake", "lottery_ticket", "lottery_ticket", "lottery_win"] + (
["coinflip_cancel"] * (held == 0)), results["audit"]
swap = [a for a in audit if a["kind"] == "trade"][-1]
assert (swap["a_items"], swap["b_items"], swap["b_money"]) == (
[{"item": "minecraft:diamond", "count": 10, "plain": True}],
[{"item": "minecraft:emerald", "count": 5, "plain": True}], 35), swap
# The daily reward, on: Alex gets it when she joins again.
config = json.loads((data / "config.json").read_text())
config["presence"]["daily_reward"] = 7
(data / "config.json").write_text(json.dumps(config))
server.start()
alex = Client(ctl, resources, "Alex2", root)
alex.name = "Alex"
alex.join(port)
results["after_restart"] = alex.say("/balance")
assert any(f"Balance: {alex_after + 7} coins" in l for l in results["after_restart"]), results
assert any("Daily reward: 7 coins." in l for l in alex.chat()), alex.chat()
assert not any("joined for the first time" in l for l in alex.chat()), alex.chat()
added, results["shop"] = shop(server, alex)
alex.quit()
server.stop()
economy = json.loads((data / "economy.json").read_text())
total = sum(a["balance"] for a in economy["accounts"].values())
assert "held" not in economy and total == 815 + 7 + added, economy
kinds = [json.loads(l)["kind"] for l in (data / "audit.log").read_text().splitlines()]
shop_kinds = ["eco_give", "eco_give", "buy 1 minecraft:iron_ingot", "buy 64 minecraft:cobblestone",
"sell 1 minecraft:iron_ingot", "sell 19 minecraft:diamond"]
assert kinds[-len(shop_kinds):] == shop_kinds, kinds[-8:]
kinds = kinds[:-len(shop_kinds)]
assert kinds[-2] in ("held_returned", "coinflip_cancel") and kinds[-1] == "daily_reward", kinds[-3:]
results["stake_back"] = kinds[-2]
sold = json.loads((data / "market.json").read_text())["sold"]
# Steve's sales of the first run's day, and Alex's.
assert sorted((s["coins"], s["items"]) for s in sold.values()) == [
(320, {"minecraft:diamond": 3, "minecraft:wheat": 20}),
(1908, {"minecraft:diamond": 19, "minecraft:iron_ingot": 1})], sold
print(json.dumps({"proof": "smp", "ok": True, **results}, indent=2))
finally:
(out / "server.log").write_text("\n".join(server.lines))
for c in (alex, steve):
c.quit()
if server.__dict__.get("process") and server.process.poll() is None:
server.process.kill()
shutil.rmtree(root, ignore_errors=True)
+27
View File
@@ -0,0 +1,27 @@
//! The SMP's rules, as the bundle adds them to the game: each file is a
//! module of `game` there and of this crate here, under the same name, so
//! `super::smp_store` reaches the same code in both.
#[path = "../mixins/smp/smp_board.rs"]
pub mod smp_board;
#[path = "../mixins/smp/smp_claims.rs"]
pub mod smp_claims;
#[path = "../mixins/smp/smp_economy.rs"]
pub mod smp_economy;
#[path = "../mixins/smp/smp_games.rs"]
pub mod smp_games;
#[path = "../mixins/smp/smp_lottery.rs"]
pub mod smp_lottery;
#[path = "../mixins/smp/smp_market.rs"]
pub mod smp_market;
#[path = "../mixins/smp/smp_places.rs"]
pub mod smp_places;
#[path = "../mixins/smp/smp_presence.rs"]
pub mod smp_presence;
#[path = "../mixins/smp/smp_recipes.rs"]
pub mod smp_recipes;
#[path = "../mixins/smp/smp_shop.rs"]
pub mod smp_shop;
#[path = "../mixins/smp/smp_store.rs"]
pub mod smp_store;
#[path = "../mixins/smp/smp_trade.rs"]
pub mod smp_trade;