Compare commits

...
Author SHA1 Message Date
Ludovic MarieandThomas-Slot dfd2102274 Add armorhud 1.0.0 (#207)
Submitted by @Thomas-Slot through the form.

Submission: 76bkxGdXpb4ErocsWQ1eS7V4X2HKbITdM-3LpERXGcM

Co-authored-by: Thomas-Slot <[email protected]>
2026-11-04 18:12:52 +05:00
Ludovic Marieandtomasmendes01 35ce93db53 Add wisdom_horizon_lite 1.1.0 (#208)
Submitted by @tomasmendes01 through the form.

Submission: QMPtuLy0qMsJH6Y0KP2iDIvzdYTpPS0FOLtPdmcSYd8

Co-authored-by: tomasmendes01 <[email protected]>
2026-11-04 18:12:45 +05:00
Ludovic Marieandtomasmendes01 3f3103d070 Add wisdom_horizon 1.1.0 (#209)
Submitted by @tomasmendes01 through the form.

Submission: XSR34WRLB30OrdD_82S40jKedlhtfgeaXXKyxlVPyis

Co-authored-by: tomasmendes01 <[email protected]>
2026-11-04 18:12:38 +05:00
Ludovic Marieandeltanschauung ed4aa85442 Add tectonic_horizon 0.3.4 (#210)
Submitted by @eltanschauung through the form.

Submission: kwCxRPKxqLPLNKKKt1Y3GT_vcpxGHQAPWgryPVRQq2s

Co-authored-by: eltanschauung <[email protected]>
2026-11-04 18:12:31 +05:00
Ludovic MarieandLyvt-Dev 43010d3eb2 Add fast_items 0.1.0 (#211)
Submitted by @Lyvt-Dev through the form.

Submission: deFN4Tw-EYAlMF5JrYvuWRKVeE70Nz8_RLfc3kFSyaA

Co-authored-by: Lyvt-Dev <[email protected]>
2026-11-04 18:12:25 +05:00
Ludovic MarieandBioneisme 546deac561 Add wayfarer_stones_native 0.6.0 (#216)
Submitted by @Bioneisme through the form.

Submission: fY38YPhqMsck59uJmF_l2k0rbzIoxvjj_NXKnGXK-uE

Co-authored-by: Bioneisme <[email protected]>
2026-11-04 18:12:18 +05:00
Ludovic MarieandBioneisme 2eca4bc2ec Add wayfarer_stones 0.6.0 (#215)
Submitted by @Bioneisme through the form.

Submission: krIkX2eCFc4Ty6wnSxmrnN2eSsO80WFuPorufUdY52k

Co-authored-by: Bioneisme <[email protected]>
2026-11-04 18:12:10 +05:00
Ludovic Marieandtariksabanuz a2ae910123 Update resourcify 0.1.1 (#218)
Submitted by @tariksabanuz through the form.

Submission: 37-NCw4vlb5QvsaAc75XvzDGeWa_EI_NcX66Dhyeid8

Co-authored-by: tariksabanuz <[email protected]>
2026-11-04 18:11:57 +05:00
Ludovic MarieandJoeypc6 88b20af247 Add jade 26.3.5 (#224)
Submitted by @Joeypc6 through the form.

Submission: -nLkctmd58LSoR6W4adlvSoOf5EtRDl4mLByfRAlPK0

Co-authored-by: Joeypc6 <[email protected]>
2026-11-04 17:59:09 +05:00
Ludovic Marieanddumbnully ceb5b2275d Add smoothie_capes 0.2.0 (#181)
Submitted by @dumbnully through the form.

Submission: ecbheoPm6ddWs6BpG-lVx36GlbB0EJDmhRW2DH8WH6E

Co-authored-by: dumbnully <[email protected]>
2026-11-04 11:53:46 +01:00
e980583b96 Add Invisible Frames 0.1.0 native mod (#164)
* Add Invisible Frames native mod listing

MIT source, pinned build and initial game validation are published in the linked repository.

Co-Authored-By: Codex <[email protected]>

* Pin reviewed Invisible Frames 0.1.1 and remove automatic repo releases

Co-Authored-By: Codex <[email protected]>

---------

Co-authored-by: Codex <[email protected]>
Co-authored-by: AltMugen <[email protected]>
2026-11-04 11:53:40 +01:00
BenjiSySandClaude Opus 5.5 5c094254e5 Add Visuality (visuality_horizon) 1.0.2, a native mod (#166)
* Add Visuality (visuality_horizon) 1.0.0, a native mod

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

* visuality_horizon: 1.0.1 instead of 1.0.0 (reproducible stored package, lighter tick)

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

* visuality_horizon: 1.0.2 instead of 1.0.1 (bundle holds only .rs and .toml files)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 10:55:30 +01:00
d8a3327ee3 Add Held Item Info 0.1.0 native mod (#165)
* Add Held Item Info native mod listing

MIT source, pinned build and initial game validation are published in the linked repository.

Co-Authored-By: Codex <[email protected]>

* Pin Held Item Info 0.1.0 for per-version review

Co-Authored-By: Codex <[email protected]>

---------

Co-authored-by: Codex <[email protected]>
Co-authored-by: AltMugen <[email protected]>
2026-11-04 10:52:16 +01:00
Ludovic Marieandrepositorykreml1n 239ce420fa Update basic_hud 0.5.0 (#217)
Submitted by @repositorykreml1n through the form.

Submission: jEsjBlLxuH_fH0gi0d1ZaAgPL2A5TB4NH7laEwo_zpY

Co-authored-by: repositorykreml1n <[email protected]>
2026-11-04 08:36:57 +01:00
ee1620f438 smp: /vanish for operators (#155)
An operator of level 3 leaves the Tab list and world of every player below
level 3 (in any game mode), with no join or leave messages for them;
operators still see them. A subtitle stays on screen the whole time. Vanished
players aren't counted online, don't hold the night up, aren't announced
AFK, and can't be reached by name (/tpa, /trade, /coinflip, /playtime).
Kept in vanish.json. Needs horizon#7003 (native_hide_player).

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Ludovic Marie <[email protected]>
2026-11-04 00:22:13 +01:00
a0dba26666 SMP: emergency switches for operators (/smp off|on|status) (#149)
Operators of level 3 turn parts of the SMP off while the server runs,
by name or all at once (land protection only by name). A part off refuses
its commands and menu clicks and its timed work waits; nothing held is
lost. Kept in switches.json across restarts, logged and audited.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Ludovic Marie <[email protected]>
2026-11-04 00:08:24 +01:00
82e29462a9 SMP: the sidebar no longer lists the richest players (#148)
The sidebar keeps the player's own block (name, balance, playtime, online)
and the address. /baltop still ranks the richest. board.top is ignored.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Ludovic Marie <[email protected]>
2026-11-03 22:35:19 +01:00
725ff7a410 Update chatheads 0.1.1 (#151)
* Update chatheads 0.1.1

Submitted by @Domanffe through the form.

Submission: m9iIfQyNTA6ee66EpcsIdlIFDTgx3mIakQro02chGVU
Co-authored-by: Domanffe <[email protected]>

* chatheads: fix the doubled icon URL and restore the name ChatHeads

The submission form pasted the icon URL twice and lowercased the name; the
in-game name is still ChatHeads.

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

---------

Co-authored-by: Domanffe <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-03 21:16:30 +01:00
Nuggetandadasjusk 64b5237f7c Add killc, stuck and rmotd native mods (#125)
Co-authored-by: adasjusk <[email protected]>
2026-11-03 21:16:24 +01:00
Ayano KaitoandGPT-6.1 Sol 7d17c4d63a Add Right-Click Crops 1.0.0 registry submission (#124)
Add the author's approved project page, icon and verified Minisign signature. Request maintainer staging because the web form rejects the submission origin.

Co-authored-by: GPT-6.1 Sol <[email protected]>
2026-11-03 21:16:18 +01:00
Matteo FranguelliandClaude Opus 5.5 aab9aa4c22 Add customhud (Beautiful Day Counter), a native mod (#115)
* Add customhud (Beautiful Day Counter), a native mod

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

* customhud: list version 1.6.0 explicitly instead of repo

Every version of a native mod is reviewed, so the entry names its file
and SHA-256 rather than following the repository's releases.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-03 21:16:11 +01:00
tariksabanuzandClaude Sonnet 5.5 6d9a7492e2 PhysicsModHorizon (#191)
* Add files via upload

* Delete mods/mod_menu-0.3.0.hnmod

* Add Horizon Physics 0.12.1 (native mod)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PaZj999vuofU9gSEtyNeUq

* Horizon Physics 0.13.0

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PaZj999vuofU9gSEtyNeUq

* Horizon Physics 0.14.0

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PaZj999vuofU9gSEtyNeUq

* horizon_physics 0.15.0

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PaZj999vuofU9gSEtyNeUq

* horizon_physics 0.15.1

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PaZj999vuofU9gSEtyNeUq

---------

Co-authored-by: Claude <[email protected]>
2026-11-03 20:04:20 +01:00
expensiverest 96c7c46e41 Radiant shader pack (#184) 2026-11-03 19:53:27 +01:00
AkirabaneandClaude Sonnet 5.5 09f69374d9 smp: /spawn lands on a safe spot within spawn_radius of the spawn point (#190)
/spawn teleported to one fixed point, which on the anarchy server can sit in
a lava cast. After the tp it now runs vanilla's spreadplayers around the
spawn point with places.spawn_radius (default 250, like the live server's
respawn_radius), which picks a random column that is not fluid, fire or
magma and has solid ground, retrying until one is found. Warps, homes, /back
and /tpa are unchanged. Radius 0 keeps the exact point.

Refs #144

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-11-03 19:41:23 +01:00
Ludovic MarieandDomanffe 4e8a65e584 Update rightharvest 0.1.1 (#152)
Submitted by @Domanffe through the form.

Submission: raf1Of2yOgkT_BRuLqW580FVXVBZqItOyqTK-1tIrj4

Co-authored-by: Domanffe <[email protected]>
2026-11-03 18:37:21 +01:00
Ludovic MarieandDomanffe bba4bda2d7 Update blockscope 0.3.1 (#150)
Submitted by @Domanffe through the form.

Submission: yQxeJFeA1pxjYCJZusO7QY83yMPaOXOZ1mKmjvzqfiU

Co-authored-by: Domanffe <[email protected]>
2026-11-03 18:37:16 +01:00
Ludovic Marieandkeksi2k 6263473f81 Add horizonmatica 0.1.1 (#145)
Submitted by @keksi2k through the form.

Submission: DXHbuwRsHNMIModbM_217IiUgMiBXsORLzIecTtl5TI

Co-authored-by: keksi2k <[email protected]>
2026-11-03 18:37:10 +01:00
Ludovic MarieandVanishingTacos 1fc4bd47d5 Add inventory_wizard 2.3.3 (#120)
Submitted by @VanishingTacos through the form.

Submission: MV28vLDz8HAq_j0ApAE1yIC4BXlLuoDRF8RWnRBpWkg

Co-authored-by: VanishingTacos <[email protected]>
2026-11-03 18:37:04 +01:00
Ludovic MarieandLauch2 7558c0945c Add waymap 0.1.4 (#179)
Submitted by @Lauch2 through the form.

Submission: eLte5hlJxy2U5VIR8-lJIqN2qCkgJ-dqT4i3o95uIVY

Co-authored-by: Lauch2 <[email protected]>
2026-11-03 18:34:43 +01:00
Ludovic MarieandNirokx 467b17bf38 Add voicechat 1.0.0 (#169)
Submitted by @Nirokx through the form.

Submission: AG7Q9X_ycyCwuouRding4ld-wXJjIAaCwGlv0aXEloc

Co-authored-by: Nirokx <[email protected]>
2026-11-03 18:34:37 +01:00
Ludovic MarieandNirokx 3d113f473a Add waypoints 1.0.0 (#168)
Submitted by @Nirokx through the form.

Submission: Ad0rlAatq4jNcgAxUy5vaU21EsMFePIXwBTE_ARctBM

Co-authored-by: Nirokx <[email protected]>
2026-11-03 18:34:32 +01:00
Ludovic MarieandNirokx 792fa822f4 Add fullbright_nofog 1.0.0 (#167)
Submitted by @Nirokx through the form.

Submission: slTmWEe55jWWepzv9ESpVWpyu4VvTiF0Jcl-9vNxfoo

Co-authored-by: Nirokx <[email protected]>
2026-11-03 18:34:26 +01:00
Ludovic Marieandsoufied 7b65e9b10f Update appleskin 0.1.2 (#163)
Submitted by @soufied through the form.

Submission: 3Qw5P2ElK53IqvJ2sEHjEZ-MUba5C0tz8aAXwIK23OA

Co-authored-by: soufied <[email protected]>
2026-11-03 18:34:21 +01:00
Ludovic Marieandumbx-dot cd1f1c3970 Add flair 1.0.0 (#199)
Submitted by @umbx-dot through the form.

Submission: XpkshdIFDfLtP-GXJPDX1VGRlBjjT8geDr-JKyATzvk

Co-authored-by: umbx-dot <[email protected]>
2026-11-03 18:32:11 +01:00
Ludovic Marieandrepositorykreml1n be266234cb Update basic_hud 0.4.1 (#198)
Submitted by @repositorykreml1n through the form.

Submission: 9rPRCH2NULh3Iy5kRA1ctwN9shoKNDOc0jW7uTbaeNo

Co-authored-by: repositorykreml1n <[email protected]>
2026-11-03 18:32:06 +01:00
Ludovic MarieandLutziano888 cf1f608745 Add accurate_block_placement 1.0.0 (#196)
Submitted by @Lutziano888 through the form.

Submission: o1TJGn5bAsBJA0ZlAhulN0DVgOliaVK0rD2jxCIXlR8

Co-authored-by: Lutziano888 <[email protected]>
2026-11-03 18:31:59 +01:00
Ludovic MarieandMayonesu0 f1f5aba33a Add chop 1.0.1 (#194)
Submitted by @Mayonesu0 through the form.

Submission: ABM9UnrYSccp3Lk313QYJcJI19WMsFz5VGL2n2tr1bQ

Co-authored-by: Mayonesu0 <[email protected]>
2026-11-03 18:31:54 +01:00
Ludovic Marieandtaxtii 0062a1e27a Add hitbox_plus 1.3.0 (#193)
Submitted by @taxtii through the form.

Submission: rkAkGw06AMsDZfPvNUJJT6YIwW6BWpiWu_teeKlJdJU

Co-authored-by: taxtii <[email protected]>
2026-11-03 18:31:49 +01:00
Ludovic Marieandtariksabanuz e79b92ff2c Add resourcify 0.1.0 (#189)
Submitted by @tariksabanuz through the form.

Submission: z-ohfvwLJTjWO_6m0RzyB-KvPLI-vNMmUkZ-vVnklR8

Co-authored-by: tariksabanuz <[email protected]>
2026-11-03 18:31:43 +01:00
Ludovic Marieandtariksabanuz ce14e90beb Add mod_menu 0.3.0 (#188)
Submitted by @tariksabanuz through the form.

Submission: YwrDR2k6vkOZjxp4ucVa-sBOHH2mUnBd45UuaIDYwys

Co-authored-by: tariksabanuz <[email protected]>
2026-11-03 18:31:38 +01:00
Ludovic MarieandJustKarkat 93b3f04390 Update crossversion 0.8.0 (#186)
Submitted by @JustKarkat through the form.

Submission: v_bcduqKfPvkdT3qbeHXZ9scM3YNOSvwoWEXL7agSes

Co-authored-by: JustKarkat <[email protected]>
2026-11-03 18:30:54 +01:00
Ludovic MarieandClaude Opus 5.5 bc40e7bafe Take crossversion 0.7.1 down (revoked.toml) (#185)
* Revert "Add crossversion 0.7.1 (#180)"

This reverts commit 7645804495: the listing
comes down while the mod's account handling is reviewed with its author.

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

* revoked: turn installed crossversion 0.7.1 copies off

Removing the entry stops new installs only; the revocation also turns
copies players already installed off, with the reason shown to them.

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

* Keep the crossversion listing; the revocation alone takes 0.7.1 down

The registry refuses removing a listing ("take its files down in revoked.toml instead").

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-03 05:04:42 +01:00
Ludovic MarieandClaude Opus 5.5 5628be3e6b voicechat-server: proximity voice chat on Horizon's own server (native server mod) (#173)
* voicechat-server: proximity voice chat on Horizon's own server

A native server mod (voicechat_server 1.0.0): the server half for voice chat
clients that speak Simple Voice Chat 2.6.x's protocol, such as azetech's
Complex Voice Chat. The handshake rides Horizon's mod messages on voicechat:
channel names; voices go over UDP through the server, sealed per player with
AES-128-GCM, to the players nearby in the same dimension or in the speaker's
group. A player without a voice client is never sent anything, and the UDP
port opens only when the first voice client asks.

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

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

* voicechat-server: replay-proof re-authentication; packages carry LICENSE

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

Tests: a_replayed_handshake_after_a_silence_moves_nothing,
a_client_the_server_stops_hearing_gets_a_fresh_secret,
repeated_secret_requests_and_group_changes_stay_bounded.

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

---------

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

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

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-03 04:04:06 +01:00
Ludovic MarieandJustKarkat 7645804495 Add crossversion 0.7.1 (#180)
Submitted by @JustKarkat through the form.

Submission: eL0F1_shzScYc6vd_ujpkodYloPjMgUo2cfokvjAw28

Co-authored-by: JustKarkat <[email protected]>
2026-11-03 02:51:04 +01:00
levzzzandClaude Opus 5.5 e5d840dabf Add Motion Blur (native mod) (#171)
* Add Motion Blur (native mod, levzzz5154/horizon-motion-blur)

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

* motion_blur: the registry's current entry format

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

No files matched your search

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

After

Width:  |  Height:  |  Size: 990 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+31
View File
@@ -0,0 +1,31 @@
id = "accurate_block_placement"
kind = "native-mod"
name = "Accurate Block Placement"
summary = "Hold right click with a block and every new spot you look at gets one, however fast you move."
description = """
Placing blocks fast is all about timing: vanilla places a block every four ticks while you hold right click, so moving fast skips spots and moving slowly stacks extra blocks. **Accurate Block Placement** times it for you: hold right click and every *new* spot you look at gets a block at once.
- **A new spot, a block**: bridging, building walls and floors, or sweeping along a row; no spot skipped, however fast you move.
- **No extra blocks**: staring at one spot places nothing more.
- **A single click stays a single block**: right after the click it waits as vanilla does, unless you turn the view.
- **Building backwards**: walking backwards while placing toward yourself places one block per step.
- Doors, chests, buttons and other blocks you click still work as usual.
**Settings** (Options > Mods > Accurate Block Placement): turn it on or off, bind a key to toggle it (unbound by default), and choose whether it works on servers.
Some servers see fast, accurate placing as a cheat: check their rules, or turn "Allowed on servers" off.
A port of [Accurate Block Placement: Reborn](https://modrinth.com/mod/accurate-block-placement-reborn) (MIT) by Clayborn, Flourick, schwar and KadTheHunter, rewritten in Rust for Horizon."""
authors = ["Lutziano888"]
owners = ["Lutziano888"]
license = "MIT"
source = "https://github.com/Lutziano888/horizon-accurate-block-placement"
icon = "https://raw.githubusercontent.com/Lutziano888/horizon-accurate-block-placement/main/icon.png"
[[versions]]
version = "1.0.0"
published = "2026-11-03"
game = "26.3"
file = "accurate_block_placement-1.0.0.hnmod"
sha256 = "e631745f6d267360de6eb51995ee995bf1ab3d64393d287b7b93c332b85939ce"
changelog = "First release."
+14 -1
View File
@@ -13,9 +13,22 @@ authors = ["soufi"]
owners = ["soufied"]
license = "MIT"
source = "https://github.com/soufied/AppleSkin-Horizon"
icon = "icons/appleskin.png"
icon = "https://raw.githubusercontent.com/soufied/AppleSkin-Horizon/refs/heads/main/icons/appleskin.png"
key = "RWT6eNq4e5WfVDA2lzOOi6XCm/+jJgilqT1+HUUM33ie5MCjcRfSg760"
[[versions]]
version = "0.1.2"
published = "2026-11-02"
game = "26.3"
file = "appleskin-0.1.2.hnmod"
sha256 = "627d1e7c4b4fa3dd191a4ff5887fe38b20ceb7d7d7f1197c1e03d1f25b6b6b92"
signature = """
untrusted comment: signature from minisign secret key
RUT6eNq4e5WfVJqiP4tt8vQGW6sVLSxiLXLeVunyGuyfS9aZWqPilg77biu/2B+HUDS1FlBE31F/HOhBR2YWy0GSmdwhUO7xNAs=
trusted comment: timestamp:1793633868\tfile:appleskin-0.1.2.hnmod\thashed
IW9M2qJIktmQDlARS6ZTJ1IA9BoGCfMZvatnjIlSpVPkpeG/Qpr5QKRRRPFhnZa93a5KcUG34ok+cgFxipRjBg=="""
changelog = "animation fix"
[[versions]]
version = "0.1.1"
published = "2026-10-31"
+24
View File
@@ -0,0 +1,24 @@
id = "armorhud"
kind = "native-mod"
name = "Armor HUD"
summary = "Armor HUD mod"
description = "A mod that adds 4 armor durability viewing slots to the hotbar."
authors = ["Thomas9950"]
owners = ["Thomas-Slot"]
license = "MIT"
source = "https://github.com/Thomas-Slot/horizon-armor-hud"
icon = "https://github.com/Thomas-Slot/horizon-armor-hud/blob/main/icon.png"
key = "RWQNLUB+Hf9pa54kEx/ItgJHmDivzpAZxK9lqvv9wnecj34yzNVQHQur"
[[versions]]
version = "1.0.0"
published = "2026-11-04"
game = "26.3"
file = "armorhud-1.0.0.hnmod"
sha256 = "8bd85e1584f307201bce5de2e60466d603b61d4807803d4ebc02772b4f6b704b"
signature = """
untrusted comment: signature from minisign secret key
RUQNLUB+Hf9pa0KnzbNFmdhyi6xpMr56two0l9g3QlnoBYyrHFtJt5luXXEl9FnJQpP6GH7Y9KDqYUSAac7QZEQDejqrM2gA7A4=
trusted comment: timestamp:1793750475\tfile:armorhud-1.0.0.hnmod\thashed
q840egwiCqF5Ptn8MCP/0VqaPUQQgaWzyXwhnFhD47GLeblsZWdvPyaTnEUbsGxHCgHGT5FFrqJ54Mu1OSz/DQ=="""
changelog = "First upload."
+57 -13
View File
@@ -1,29 +1,73 @@
id = "basic_hud"
kind = "native-mod"
name = "The Basic HUD"
summary = "Effect timers, potions, golden apples and totems carried, totems used by each player, and armour, elytra and shield durability."
summary = "A visual HUD editor with 14 customizable widgets, recent pickups, effect timers, durability, coordinates, FPS and a customizable Tab list."
description = """
A small HUD box in a corner of the screen.
A client-side visual HUD for Horizon. Move, resize and style fourteen independent widgets directly over the game.
## What it shows
Includes effects and timers, consumable counts, durability, observed totem uses, available player health, item cooldowns, coordinates, FPS, ping, speed, CPS, world clock, mini armour and recent item pickups.
- Active effects with their time left. Potion effects are pinned by default; any other effect can be pinned on the mod's settings page, or all of them shown at once.
- Potions of each kind, golden apples, enchanted golden apples and totems in your inventory.
- Totems each player has used, above that player's name. Your own count is in the box.
- Durability of worn armour, elytra and a held shield.
Recent pickups aggregates matching items and refreshes their expiry: 4 torches + 2 torches becomes x6. Five seconds by default, adjustable from 1 to 60 seconds. Includes icon + count mode.
The count of a player's totems resets when they die or when you leave the world.
Customize each widget's position, scale, colours, opacity, padding and shadows. Includes a customizable Tab list and ten interface languages. A short tutorial appears on the first settings visit and can be replayed with ?. Reset requires confirmation; leaving with edits offers Save / Discard / Stay.
## What it does not do
Controls: Right Shift, or Options > Mods > The Basic HUD > Settings > Edit HUD.
- It builds on one Horizon commit only, 68ac6f6 (Minecraft 26.3). Counting totems and writing above a player have no hook point, so it hooks two of the game's own functions (totem_event, name_tag_geometry). The rest uses hook points but reads the game's internal data.
- It knows only what the client is told: a totem used out of your sight is not counted.
- Its own texts are in English. Effect and item names follow the game's language.
- Tested on Windows in single player and over LAN with two clients, not on a dedicated server."""
Install the .hnmod through Horizon and choose Performance. Minecraft 26.3; pinned Horizon 68ac6f647e7022b2c696e81863d7cface28c9416. Compatibility with other Nightlies is not claimed.
Client-side only: unknown health stays unknown, unobserved totem uses cannot be counted, and server-plugin-only cooldowns are unavailable. No FPS improvement is claimed.
Release notes and verification: https://github.com/repositorykreml1n/the-basic-hud/releases/tag/v0.5.0"""
authors = ["repositorykreml1n"]
owners = ["repositorykreml1n"]
license = "MIT"
source = "https://github.com/repositorykreml1n/the-basic-hud"
icon = "https://raw.githubusercontent.com/repositorykreml1n/the-basic-hud/v0.5.0/icon.png"
[[versions]]
version = "0.5.0"
published = "2026-11-04"
game = "26.3"
file = "basic_hud-0.5.0.hnmod"
sha256 = "97011cc7374354c6293678c7ab593240d0512bffde1d7b642dad521e1df3f8ac"
changelog = """
## 0.5.0 — Recent pickups
- New movable widget for recently collected items: pick up 4 torches, then 2 more before expiry, and it shows x6. Each repeat pickup refreshes that item's timer.
- Default display time: 5 seconds; adjustable from 1 to 60 seconds.
- Supports icon + count mode, dragging, scale, colours and opacity.
- Partial pickups count only the amount that fits. Giving, crafting and inventory rearrangement do not by themselves create entries.
- New pixel-art totem icon; new labels translated into all ten interface languages. Existing layouts and settings are preserved.
Verified: 75 strict Rust test executions, warning-free pinned native build, real in-game aggregation/expiry/partial-pickup checks, editor persistence and ordinary startup without a controller.
Русский: добавлен блок недавнего подбора с суммированием количества, обновлением таймера и настройкой 1–60 секунд (по умолчанию 5). Новая иконка — тотем.
Deutsch: Neues Widget für aufgehobene Gegenstände mit addierten Mengen und einstellbarer Dauer von 1–60 Sekunden.
Français : nouveau widget des objets ramassés, quantités cumulées et durée réglable de 1 à 60 secondes.
Español: nuevo widget de objetos recogidos, cantidades acumuladas y duración ajustable de 1 a 60 segundos."""
[[versions]]
version = "0.4.1"
published = "2026-11-03"
game = "26.3"
file = "basic_hud-0.4.1.hnmod"
sha256 = "36431d3a2d810e2aefbd8611767a80f9184e3df032e4cc2b9b9fd9398f794560"
changelog = """
Major update from 0.1.0 to 0.4.1.
- Visual in-game editor: drag, individual scale, colours, opacity, shadows, padding and snap.
- 13 widgets, including FPS, coordinates, available player health and actual item cooldowns.
- Icons + values: ×16, 1:45 and durability without long labels.
- Mini armour beside the hotbar, including elytra.
- Custom Tab list: numeric ping, bars, player counts, sorting and scale.
- Smoothed optional speed; single-player ping labelled Local.
- Ten UI languages plus Auto; first-visit tutorial, replay through ?.
- Reset confirmation; Save / Discard changes / Stay on leaving with edits; failed-save recovery.
Pinned to Minecraft 26.3 / Horizon 68ac6f6. Use the Performance build profile on Windows; Dev can overflow the startup stack on this baseline. No compatibility with other Nightlies or FPS gain is claimed.
Validated: 67 Rust test executions, native build, isolated gameplay/editor tests, restart persistence and ordinary asynchronous startup.
Full notes (English + Russian, Ukrainian, German, French and Spanish summaries): https://github.com/repositorykreml1n/the-basic-hud/blob/main/CHANGELOG.md"""
[[versions]]
version = "0.1.0"
+10 -2
View File
@@ -1,14 +1,22 @@
id = "blockscope"
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."
summary = "The block under your crosshair: its name, id, distance and icon, plus hardness, states and a container's slots."
description = "A Jade-style tooltip for Horizon: the block under your crosshair with its name in your language, block id, distance and icon, plus its hardness and the tool it wants, its state properties, a crop's growth and, for a container, its slots. Ten switches on its own settings page."
authors = ["Domanffe"]
owners = ["Domanffe"]
license = "MIT"
source = "https://github.com/Domanffe/blockscope"
icon = "https://horizon-rust.github.io/horizon-mods/icons/blockscope.png"
[[versions]]
version = "0.3.1"
published = "2026-11-02"
game = "26.3"
file = "blockscope-0.3.1.hnmod"
sha256 = "4fea94e5337aa82df3d981fc6231fecc80161262add577b596bb6661e0e303d1"
changelog = "Rebuilt on the 2026-11-01 Nightly (Horizon `4aa6854`). All five hook points are named now — `mods`, `settings`, `settings_click`, `hud`, `tick` — so the bundle still applies when the game moves them."
[[versions]]
version = "0.3.0"
published = "2026-10-30"
+9 -6
View File
@@ -2,18 +2,21 @@ 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."""
description = "Each chat line from a player gets their 8x8 skin face in the strip to its left; lines without a player stay as they are, and the face fades with its line. The switch lives in Options > Mods."
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.1"
published = "2026-11-02"
game = "26.3"
file = "chatheads-0.1.1.hnmod"
sha256 = "16f5e1db33255c550c087fcf3ed982a4be9abf61b547b470a08f471d309ad1fe"
changelog = "Rebuilt on the 2026-11-01 Nightly (Horizon `4aa6854`). The three hook points it shares — `mods`, `settings`, `settings_click` — are named now instead of pinned to raw source targets, so a Horizon update that moves them no longer breaks the bundle."
[[versions]]
version = "0.1.0"
published = "2026-10-30"
+41
View File
@@ -0,0 +1,41 @@
id = "chop"
kind = "native-mod"
name = "Chop!"
summary = "Cut one log and the whole tree tips over, crashes down and breaks apart, with soft vanilla sounds and particles."
description = """
Cut one log with an axe: the whole tree tips over like a real falling tree, crashes to the ground, and breaks apart from the base to the top in little pops. Everything is made of vanilla sounds and particles: no resource pack.
- **The log shakes while you cut it**, then pops when it gives way.
- **A physical fall**: the trunk drops onto the stump, creaks, tips over with gravity and stops where it meets the ground. Leaves in the way are crushed.
- **Falls in front of you**, within an adjustable angle, toward the clearest side.
- **A cute break-up** from the base to the top, with clouds, sparkles and hearts.
- **Replants a sapling** of the tree's kind (from its loot, or always).
- **Chain reactions**: a falling trunk can knock over the next tree.
- **Adjustable damage**: hurts monsters by default; animals, allies and players are spared unless you turn them on.
- Vanilla loot (Fortune, Silk Touch), honest axe wear, fall preview, shaking trees with an empty hand, giant mushrooms and Nether fungi, angry bees, birds, apples, biome-tinted leaves, optional loot into your inventory.
- **Everything is configurable in game**, with commands or by clicking in the chat: `/chop config` (operators), `/chop perso` (each player's sounds, particles and preview), `/chop stats`, `/chop aide`. The menus are in French.
**What it does and doesn't do.** It runs server-side, where the world runs: players who join install nothing. No network access, no telemetry, never runs a program. It reads and writes one file, `<world>/data/chop.txt` (settings, stats, player-placed logs). It only touches trees, the sapling it replants, its own `block_display` entities and what a falling tree hits. Besides the game's functions, it runs a few vanilla commands as the server: `kill` on its own tagged displays, `item modify` for the axe's wear, `damage` on a player the tree hits (off by default), and `summon item` / `setblock … keep` for loot and replanting when the tree is in another dimension.
Native source mod: install it on the launcher's Mods page, press Build there, and confirm the native-code warning. The launcher builds a separate game with it, and it runs with your account's permissions. Each version is pinned to one Horizon Nightly and Horizon Mixins revision, named in its changelog; the launcher builds those exact revisions.
Original mod by Mayonesu0. All rights reserved, except the grants in its [LICENSE](https://github.com/Mayonesu0/chop/blob/main/LICENSE): players may use it unchanged, Horizon's registry may distribute it, and Horizon's reviewers may read it to review it."""
authors = ["Mayonesu0", "Claude"]
owners = ["Mayonesu0"]
license = "LicenseRef-Chop-1.0"
source = "https://github.com/Mayonesu0/chop"
icon = "https://raw.githubusercontent.com/Mayonesu0/chop/main/registry/icon.png"
key = "RWSwx1v4zUCUjQ8rssfZCXvXkvgVc9TbA60RJyCtVCeMx4lSTanIleIs"
[[versions]]
version = "1.0.1"
published = "2026-11-03"
game = "26.3"
file = "chop-1.0.1.hnmod"
sha256 = "5abac195c54da7716f35f641489c0dba780444c91b0b3e4f3387ecd4cb2b260b"
signature = """
untrusted comment: signature from minisign secret key\r
RUSwx1v4zUCUjfR+WYS97be0UecaBcKGyNXNsCQAPVG0j2U2yeMfnNPkWMEU9CMOobMI6nKaPj/Kqv0EPAuw2xiPnRaduNgI8wA=\r
trusted comment: timestamp:1793711491\tfile:chop-1.0.1.hnmod\thashed\r
eOi2t+LMQ4LOM/nsJqmpvAraWsLcgdRZA4OJotjBkgxcANkG3MeZ9tbAKbEaidIy1kBSZyc4Dri+3MtOsd+xBA=="""
changelog = "First release (1.0.0 was never published: its package carried a LICENSE file the registry's checker refuses; the code is the same). Pinned to Horizon Nightly 2026-11-01 (c9d474d) and Horizon Mixins ddd9700; the launcher builds those exact revisions."
+41
View File
@@ -0,0 +1,41 @@
id = "crossversion"
kind = "native-mod"
name = "Cross Version"
summary = "Allows version hopping offline."
description = """
Hop versions, offline.
Uses ViaProxy as an inbetween for now."""
authors = ["JustKarkat"]
owners = ["JustKarkat"]
license = "MIT"
source = "https://github.com/JustKarkat/horizon-crossversion/"
icon = "https://github.com/JustKarkat/horizon-crossversion/blob/master/icon.png?raw=true"
key = "RWTcPJHJYjPW0pV3lPwr45crCu0wJxW5HbDLc3fr3Eom1TzxO++kHOo4"
[[versions]]
version = "0.8.0"
published = "2026-11-03"
game = "26.3"
file = "crossversion-0.8.0.hnmod"
sha256 = "5b371f1b7b3582e0c92aa1dc22b491b50c024fe95833aa2bd9457189631caafd"
signature = """
untrusted comment: signature from rsign secret key
RUTcPJHJYjPW0vc8h9pG+LC/9EkYg60UM28gNkxJ31iDOXapyV80qqnB9H12OuOtzBmPkbB33fkh0IKE5O8zcCzat0jyj6Tpqw8=
trusted comment: timestamp:1793679858\tfile:crossversion-0.8.0.hnmod\thashed
3hY5NK517bzva/2q54Pmj9QXO3z6WF0WWMfuA8T9H8qCuOe6TYnmYB5eEagRMpZUNK/ICcrfPEx+DE303XIOAQ=="""
changelog = "Offline mode only. "
[[versions]]
version = "0.7.1"
published = "2026-11-02"
game = "26.3"
file = "crossversion-0.7.1.hnmod"
sha256 = "deb9cf8a84838e6c48999f70fbd3c81db90992c456d6e85a0e0746b088c5b8fa"
signature = """
untrusted comment: signature from minisign secret key
RUTcPJHJYjPW0nmeON4u6rxOS7AxK+s/e3kPaLkJ3lm618iPPc8oP8nWdJQ8k9yYriSZSu091TZ5m7qX56nNovAjeZyTcOdrUAk=
trusted comment: timestamp:1793657606\tfile:crossversion-0.7.1.hnmod\thashed
q2Oy6yVv5T+GHc2iM+HxNDImfPcaKGV/XPToNCTQFHSTKJ8+rz5IV4LfJnOBwBp1rZQZoGkExopDrXfZyQybCQ=="""
changelog = """
Version: 0.7.1
Changelog: The MIT notice now ships inside the .hnmod (it is the header of the bundled engine source). The settings page's 'Delete the downloaded ViaProxy' deletes the jars only, so the account added to ViaProxy and its refresh token in saves.json stay. Also: a version switch on Edit Server and Direct Connect: choose what version ViaProxy tells that server it is (auto, 1.8, 1.9, 1.12.2, 1.16.5, 1.20.1, 1.21.1, 1.21.4, 26.3), kept per address, for servers whose status reports the wrong version."""
+28
View File
@@ -0,0 +1,28 @@
id = "customhud"
kind = "native-mod"
name = "Beautiful Day Counter"
summary = "A lightweight day counter, clock and day/night cycle bar for the HUD, with an in-game settings screen."
description = """
Beautiful Day Counter for Horizon, a native mod.
Shows the current survival day, an in-game clock and a color-shifting bar that follows the day/night cycle, without changing gameplay. It is a port of the author's own Fabric/NeoForge mod [CustomHUD](https://github.com/sonoFrangu/CustomHUD), with the same HUD and the same settings screen.
- Day counter with the game mode icon; a golden apple or totem, a rainbow counter and a sound on milestone days (50, 100, 365, every 100).
- 12-hour or 24-hour clock; the clock icon turns into a bed when you can sleep.
- Day/night bar with four color themes (Classic, Pastel, Colorblind, Mono), night marks, countdown to night or day, moon phase.
- Top or bottom placement, size, opacity, offsets, optional background; vanilla boss bars move below the HUD when it is at the top.
- Settings screen opened with H (or from Options > Mods): Elements, Look and Behavior, with tooltips.
- English, Italian, German, Spanish, French and Portuguese."""
authors = ["SCARABOCCHIO100"]
owners = ["sonoFrangu"]
license = "MIT"
source = "https://github.com/sonoFrangu/CustomHUD-Horizon"
icon = "icons/customhud.png"
[[versions]]
version = "1.6.0"
published = "2026-10-31"
game = "26.3"
file = "customhud-1.6.0.hnmod"
sha256 = "ab255d3c722b40f4d068a762857d6c027c11381308bfe23196e2974307814a7f"
changelog = "First release for Horizon: the CustomHUD 1.6.0 HUD and settings screen as a native mod."
+35
View File
@@ -0,0 +1,35 @@
id = "fast_items"
kind = "native-mod"
name = "Fast Items for Horizon"
summary = "Optimize item rendering by transforming item entities to 2D"
description = """
Fast Items (2D Items)
Optimizes item rendering by transforming items to 2D forms.
For 2D items(such as potions), this mod avoids rendering their sides, so they are completely flat
For 3D items(such as cube blocks), this mod makes them always face to camera, just as the same behaviour in Lunar Client/BLC."""
authors = ["Lyvt (dc: _.lyvt)"]
owners = ["Lyvt-Dev"]
license = "MIT"
source = "https://github.com/Lyvt-Dev/FastItems-for-Horizon"
icon = "https://github.com/Lyvt-Dev/FastItems-for-Horizon/tree/main/logo/fastitems.png"
key = "RWQ84d8BvD1EaarVDgHgXyz+MDiL7ONsCZc0BUrJqTmqzXBItnJy918A"
[[versions]]
version = "0.1.0"
published = "2026-11-04"
game = "26.3"
file = "fast_items-0.1.0.hnmod"
sha256 = "c3acd8adfeac6450c3a13b5172c8abb298cb3ceb355dfc539b4bdd653ef15502"
signature = """
untrusted comment: signature from minisign secret key\r
RUQ84d8BvD1EaR6fPq1+iCBwAhUosfQDHMjVQLYQkIRcmU7I9Ls7Z3nC8wFUF4BOzgB4DaP5UonvlkAG4wqNMZH6EnRLmwaGqgw=\r
trusted comment: Fast Items for Horizon 0.1.0; fast-items-0.1.0.hnmod\r
iqcDMYeA2LekavHGy7yMhtCtH6kCyjq7x76cVPD994O+CYj0lfW4tZL1HSgtXQJFM+3Gw3h2xr55DndjcqFiAA=="""
changelog = """
Optimizes item rendering by transforming items to 2D forms.
For 2D items(such as potions), this mod avoids rendering their sides, so they are completely flat
For 3D items(such as cube blocks), this mod makes them always face to camera, just as the same behaviour in Lunar Client/BLC."""
+24
View File
@@ -0,0 +1,24 @@
id = "flair"
kind = "native-mod"
name = "flair"
summary = "Custom accessory mod"
description = "A cosmetics catalog for Horizon. Open it from the title screen or the pause menu and wear HD capes that move like cloth, animated bandanas with Japanese script, hats, wings, shoulder pets and auras with colour themes. Turn your player in a live preview, hide your armor, and add your own capes and Blockbench cosmetics with animations. Only you see them."
authors = ["_idom_"]
owners = ["umbx-dot"]
license = "MIT"
source = "https://github.com/umbx-dot/horizon-flair"
icon = "https://raw.githubusercontent.com/umbx-dot/horizon-flair/main/icon.png"
key = "RWT9Ujo/oCprPGHHiSLHFNOJV1ZzoJdAU6EdZyiQcBwT2iMXfiYms4hD"
[[versions]]
version = "1.0.0"
published = "2026-11-03"
game = "26.3"
file = "flair-1.0.0.hnmod"
sha256 = "64e4d645d5bda3c363603d483af2095d6d3673d91803954f338d54265bf4fab5"
signature = """
untrusted comment: signature from minisign secret key\r
RUT9Ujo/oCprPBIbqV9rmrjRgpF9gnCIL+4FlZXSTq0Qi4kkklSKfjNDx9YFIw1DuufGVSL3+lOieSX4Fx0nRl7j/CCUNjiWIQs=\r
trusted comment: timestamp:1793723044\tfile:flair-1.0.0.hnmod\thashed\r
QPCGNEAWFCaZLdZLgkUVY9v+hRP4/gJe4H2hCMh7YT7k/V4tynkQ0AM3hZqEdrBoPn4q5SFHDVIcloGkqCTzDw=="""
changelog = "Initial Release."
+25
View File
@@ -0,0 +1,25 @@
id = "fullbright_nofog"
kind = "native-mod"
name = "Fullbright & NoFog"
summary = "Toggle full brightness and the world's fog with a key (G / N), /fullbright or /nofog."
description = """
A native Horizon mod: the launcher builds it into the game (install it on the launcher's Mods page, then press Build there). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Uses only Horizon's native hook points (hooks 80, for `render_fog`); tested on Nightly 2026-11-01 (c9d474d) and horizon-mixins 0d6b103.
- **Fullbright**: press G (or `/fullbright`, `/fb`) to toggle full brightness (gamma 1500%). Your brightness slider and `options.txt` are never changed; turning it off gives your own brightness back.
- **NoFog**: press N (or `/nofog`) to remove the world's fog. `/nofog fluids` also clears water, lava and powder snow fog.
- `/fullbright on|off` and `/nofog on|off` set them directly.
- Options → Mods → Fullbright & NoFog: a button for each toggle and both keys, rebindable.
Toggling is silent (no chat messages). Client-side: works in single player, on LAN and on servers. Settings are remembered between sessions."""
authors = ["Nirokx"]
owners = ["Nirokx"]
license = "MIT"
source = "https://github.com/Nirokx/horizon-fullbright-nofog"
[[versions]]
version = "1.0.0"
published = "2026-11-02"
game = "26.3"
file = "fullbright_nofog-1.0.0.hnmod"
sha256 = "07a13abd8af2405343241f8985db7595a03bfb8ced2f1dfab8a105848eb1b7d7"
changelog = "First release."
+17
View File
@@ -0,0 +1,17 @@
id = "held_item_info"
kind = "native-mod"
name = "Held Item Info"
summary = "Selected-item durability, count and enchantment names above the hotbar."
description = "Minimal client-only selected-item text HUD for Horizon 26.3. Displays remaining/maximum durability, stack count and up to six translated enchantment names; long strings are shortened to GUI width. Original MIT implementation inspired by A5b84/held-item-info; no LGPL source was read or included. Potion details, layout options and fading are not implemented. Existing Armor Status covers equipped-item icons/durability; this mod focuses on selected-item text and enchantment names. See the source for validation and exact engine/mixin pins."
authors = ["AltMugen"]
owners = ["AltMugen"]
license = "MIT"
source = "https://github.com/AltMugen/horizon-held-item-info"
[[versions]]
version = "0.1.0"
published = "2026-11-02"
game = "26.3"
file = "held_item_info-0.1.0.hnmod"
sha256 = "37a11d98f0afabe37f8ffa9982dc710ad2985817db6b5e094d807c24d30a6a6a"
changelog = "Initial reviewed native release."
+38
View File
@@ -0,0 +1,38 @@
id = "hitbox_plus"
kind = "native-mod"
name = "Hitbox+"
summary = "Customizable entity hitboxes with exact weapon & spear in-reach colors, thickness, eye rays, and sprint HUD."
description = """
Hitbox+ for Horizon
Hitbox+ is a high-performance native mod ported to Horizon that revamps vanilla F3+B debug hitboxes with full customization and PvP reach indicators.
Key Features:
Dynamic In-Reach Detection: Hitboxes automatically change to your chosen reach color when your crosshair is within hitting distance.
Spear Reach: Accurately extends reach to 4.5 blocks (survival) and 6.5 blocks (creative).
Melee Reach: Standard 3.0 blocks for swords, axes, maces, and fists.
11 Selectable Reach Colors: Choose between Bright Green, Neon Cyan, Aqua Mint, Gold Yellow, Sunset Orange, Hot Pink, Neon Purple, Electric Blue, Crimson Red, Pure White, or smooth dynamic Rainbow.
Hitbox Styles & Thickness: Custom styles (White, Cyan, Yellow, Red, Rainbow) and 3 thickness modes (1.5px Thin, 2.5px Default, 4.0px Thick).
Eye-Ray & Looking Direction: Toggle looking direction lines and forehead level markers.
Always-On Sprint HUD: Built-in sprint status indicator with customizable screen corner placement (Top-Left, Top-Right, Bottom-Left, Bottom-Right).
Zero FPS Cost: Pure lock-free atomic architecture.
Controls:
Press H to toggle hitboxes on/off.
Access Options > Mods > Hitbox+ to customize all colors, thickness, and HUD positions."""
authors = ["taxtii", "itzglecni"]
owners = ["taxtii"]
license = "MIT"
source = "https://github.com/taxtii/hitbox_plus.git"
[[versions]]
version = "1.3.0"
published = "2026-11-03"
game = "26.3"
file = "hitbox_plus-1.3.0.hnmod"
sha256 = "8d1b95e7461e38fc40994b35a82c1f753a58e9751ce27af1dc101a4717dba8a4"
changelog = """
- Added dynamic spear attack reach support (4.5 blocks in survival, 6.5 blocks in creative with 0.125 hitbox margin).
- Standard melee weapons (swords, axes, fists) use exact 3.0-block in-reach detection.
- Added 11 selectable reach color options (Bright Green, Neon Cyan, Aqua Mint, Gold Yellow, Sunset Orange, Hot Pink, Neon Purple, Electric Blue, Crimson Red, Pure White, Rainbow).
- Removed red hit flashing when attacking entities.
- Zero-overhead lock-free atomic rendering with configurable line thickness, eye rays, and always-on toggle sprint HUD."""
+31
View File
@@ -0,0 +1,31 @@
id = "horizon_physics"
kind = "native-mod"
name = "Horizon Physics"
summary = "Physics for blocks, mobs, items, snow, water, smoke, capes and more, with its own settings screen."
description = """
By tariksabanuz. Physics for Horizon in the spirit of PhysicsMod: broken blocks fall apart into pieces, mobs die as
ragdolls, snow gives way under your feet, water waves, smoke rises and plants sway in the wind.
- Broken blocks crumble into pieces (blocky, particles, crumbled voxel or fractured). Mobs die as ragdolls or come apart.
- Structures and trees fall when what holds them up is broken. Dropped items tumble and settle.
- Snow layers are pressed down by you and the mobs, water has waves that boats (and you in them) ride, and leaves, plants and vines sway.
- Hit a ragdoll or a piece and it flies off; grass and flowers bend away as you walk through them.
- Fire, campfires and lava give off smoke and embers that gather and darken under ceilings, capes and banners are cloth, doors swing open and shut, leads and fishing lines are ropes.
- Rain splashes and rings the water, snow falls, and the wind blows harder with the weather.
- A Physics Settings button in Options opens a screen with a page for every part, and a Quick Setup the first time you start.
Needs the Performance build in the launcher. Inspired by PhysicsMod by Haubna; this is a clean-room version for
Horizon and contains none of its code or assets. It uses your own game's textures at runtime, and the icon is my own."""
authors = ["tariksabanuz"]
owners = ["tariksabanuz"]
license = "MIT"
source = "https://github.com/tariksabanuz/PhysicsModHorizon"
icon = "icons/horizon_physics.png"
[[versions]]
version = "0.15.1"
published = "2026-11-03"
game = "26.3"
file = "horizon_physics-0.15.1.hnmod"
sha256 = "d7cf1201f0a47a852aa1a4c9684ef9e658ec3c86852104d44716d05f11281ca3"
changelog = "First release: block, mob, item, snow, water, smoke, cloth, weather and collapse physics, with a Physics Settings screen."
+64
View File
@@ -0,0 +1,64 @@
id = "horizonmatica"
kind = "native-mod"
name = "Horizonmatica"
summary = "Litematica for Horizon: schematics as ghost blocks, a verifier, material lists and a printer that builds the missing blocks for you."
description = """
Horizonmatica is a re-creation of **Litematica** (with its **printer**) for Horizon. Load a schematic, see it as ghost blocks in your world, check your build against it, get a material list, and let the printer place the missing blocks.
## Features
- **Formats:** `.litematic` (versions 1–7, several regions), `.schem` (Sponge v1–v3: WorldEdit, FAWE, Amulet, Axiom), `.schematic` (MCEdit / WorldEdit up to 1.12) and vanilla structure `.nbt`. Older files are upgraded with Horizon's data fixer, the same way old worlds are.
- **Overlay:** missing blocks as translucent real block models (chests, banners, heads and other block entities included), optionally visible through walls.
- **Verifier:** wrong block (red), wrong state (orange), extra block (pink), plus a progress HUD.
- **Layers:** all / one layer / above / below / range.
- **Placement:** move, rotate, mirror; a move mode for mouse wheel and arrow keys. Each placement is remembered per world and dimension and restored when you join again.
- **Material list:** missing, total and carried, with item icons, in the HUD, in chat or in the menu.
- **Menu (M):** a screen of its own, drawn with the game's widgets.
- **Printer:** places one missing block within reach every few ticks, from the bottom up. It only clicks when Horizon's own placement logic says the click produces exactly the wanted state (stairs, slabs, logs, furnaces…). Redstone-aware order, repeater, comparator and note block setup after placing, double slabs and stacks, chests, falling blocks. The camera never moves.
Usage: put schematics into `schematics/` next to `options.txt` of your instance (`/hm folder` shows the path), then `/hm load <name>` or press **M**. All commands and keys are in the [README](https://github.com/keksi2k/horizonmatica#usage).
## What it does and doesn't do
- **Reads** only files in the instance's `schematics/` folder, and creates that folder if it's missing.
- **Writes** nothing itself: settings and remembered placements go through Horizon's mod settings.
- **Network:** no connections of its own. The only thing sent to a server is what the printer does: the same block-placing clicks a player would make (with the matching look direction), and hotbar slot changes.
- **"Open folder"** in the menu opens the schematics folder in your file manager through Horizon's own function.
- No telemetry, no downloads, no code run from schematic files. Schematics are parsed as data only.
⚠️ On other people's servers, automatic placing may break the rules or trip anti-cheat. The printer is off until you turn it on (`K` or `/hm printer on`). Its *air place* option (on by default) is noticed by anti-cheat plugins such as Grim.
**Version:** native mods use game internals, so Horizonmatica is pinned to one Horizon commit (currently Nightly 2026-11-01, `4aa6854`). A new Nightly needs a new release.
## Credits
The idea, the way it's used, the file format and the behaviour come from **[Litematica](https://github.com/maruohon/litematica)** by masa (maruohon), maintained by **[Sakura-Ryoko](https://github.com/sakura-ryoko/litematica)**, and from **[Litematica Printer](https://github.com/aleksilassila/litematica-printer)** by aleksilassila. Horizonmatica is not an official project of these authors, isn't made or endorsed by them, and contains none of their code. Only the file format and the behaviour are re-created. If you like it, go and support the originals.
Menus built with [horizon-menus](https://github.com/keksi2k/horizon-menus). MIT licensed."""
authors = ["keksi2k"]
owners = ["keksi2k"]
license = "MIT"
source = "https://github.com/keksi2k/horizonmatica"
icon = "https://raw.githubusercontent.com/keksi2k/horizonmatica/main/icon.png"
key = "RWS+BRoCJslNCFapck3cbG2VFQcjlr1MUH5m3+CWA8zhJecoBoja7X94"
[[versions]]
version = "0.1.1"
published = "2026-11-02"
game = "26.3"
file = "horizonmatica-0.1.1.hnmod"
sha256 = "2ddc562dd2ea0f9a76a47eb761c64c803def68a8c81b92cb5d15ec26c5d432aa"
signature = """
untrusted comment: signature from minisign secret key\r
RUS+BRoCJslNCOkWo88+wHXYmMr1C6TzXpxZIGwzFuUN+K4lbd9RT6tLC8sIQQ5uD3QZQZx2rawTHRJvUvRhCgUNDoqhIek18w4=\r
trusted comment: timestamp:1793599192\tfile:horizonmatica-0.1.1.hnmod\thashed\r
BmryY+OjbG0qZnnFappBEO2tR8zx+a5BmqBFA4AVytjqvLnuvDWYE0PUwZLLodqbN218NM0JluVGpstnI3DvBQ=="""
changelog = """
First release in the registry.
- Loads .litematic, .schem (Sponge v1–v3), .schematic (MCEdit, 1.12 ids) and structure .nbt; older files are upgraded with Horizon's data fixer
- Ghost overlay with real block models, see-through mode, verifier colours and layer modes
- Placements remembered per world and dimension; move mode, rotate, mirror
- Own menu screen (M) and a material list with item icons
- Printer: places missing blocks within reach, checked against the game's own placement logic; the camera never moves
Since 0.1.0 on GitHub: loading another schematic no longer shows the previous one's blocks.
Built for Horizon Nightly 2026-11-01 (c9d474d, hook API 80)."""
+39
View File
@@ -0,0 +1,39 @@
id = "inventory_wizard"
kind = "native-mod"
name = "InventoryWizard"
summary = "InventoryWizard's one-click sorting as a native mod for Horizon"
description = """
## Features
- **Smart sorting** by category (stone, earth, wood, ores, blocks, tools, weapons, armor, food, redstone,
transportation, decoration), from item tags and components, so other mods' items are sorted too
- **Survival-friendly hotbar**: weapons, tools, food, building blocks, utility items, then the rest
- **Stacks merged**, but enchanted or named items are never combined
- **Three modes**: Default, Alphabetical and Stack-based
- **Sort buttons** by the inventory and container titles, plus a mode button (it shows the mode's letter)
- **Locked slots**: **Alt+Click** a hotbar or inventory slot to lock it, and sorting leaves it alone
- **Keys**: **R** (or **Shift+Right Click**) sorts the part under the cursor: the hotbar sorts everything, the
inventory the inventory, a container the container. Outside a screen, R sorts the whole inventory.
**Shift+Right Click** on the middle hotbar slot cycles the mode. The mode also has its own key, unbound by default.
- **Commands**: `/iwiz [hotbar|inventory|all|mode]` (also `/inventorywizard`)
- **Works on** the inventory, hotbar, chests, barrels, shulker boxes and ender chests, **in singleplayer and on any
server**: every sort is made of ordinary left clicks, as a player could click them, so a vanilla server accepts it"""
authors = ["VanishingTacos"]
owners = ["VanishingTacos"]
license = "MIT"
source = "https://github.com/VanishingTacos/InventoryWizard-Horizon"
icon = "https://cdn.modrinth.com/data/cU0ynk3p/21b58402405493964af438e3e3b9551676b68433.png"
key = "RWTos7ZtiqwDTrtjtHk3WhMJJc1+sSTTyTu9CaDtIYWSQvww2D9WrAN1"
[[versions]]
version = "2.3.3"
published = "2026-11-01"
game = "26.3"
file = "inventory_wizard-2.3.3.hnmod"
sha256 = "03a7c8a528148611e5f309f3d05294a97b6766ca5e3bda9b3f1f79458fe65a42"
signature = """
untrusted comment: signature from minisign secret key\r
RUTos7ZtiqwDTmRp04qx/n5ZXozvaeOig3nJZFeNqmfmZ1PjB/2JUcfiF0JLZZJBkqpwm6sZQAvrt4HNce0juB5KaxJUOQJtAAo=\r
trusted comment: timestamp:1793498930\tfile:inventory_wizard-2.3.3.hnmod\thashed\r
Q0SE4CMnaFQHCmizFD0JcIPkUfiYQVs3KyGCCpS99m3OfFNhnS0gQt20ul0QlyYDAC8bAPJRjyrSIgcD1KzPAQ=="""
changelog = "Full native port of InventoryWizard for Horizon "
+17
View File
@@ -0,0 +1,17 @@
id = "invisible_frames"
kind = "native-mod"
name = "Invisible Frames"
summary = "Sneak-right-click a filled frame to hide and lock it, or restore it."
description = "Server-side frame visibility toggling for Horizon 26.3. Ordinary and glowing frames are supported. Empty visible frames remain visible. Original MIT implementation inspired by ADHDMC/InvisibleFrames; no Java source or assets included. See the source repository for tested engine/mixin pins, build instructions and validation scope."
authors = ["AltMugen"]
owners = ["AltMugen"]
license = "MIT"
source = "https://github.com/AltMugen/horizon-invisible-frames"
[[versions]]
version = "0.1.1"
published = "2026-11-03"
game = "26.3"
file = "invisible_frames-0.1.1.hnmod"
sha256 = "76128b48ce5c826007379c85634b934ae6bad51c134fbaead7c46b36fecd9274"
changelog = "Use the acting player's authoritative server sneak input instead of physical keyboard state."
+34
View File
@@ -0,0 +1,34 @@
id = "jade"
kind = "native-mod"
name = "Jade"
summary = "The look-at tooltip: what you're pointing at, its mod, its details and its state."
description = """
An unofficial port of [Jade](https://github.com/Snownee/Jade) 26.3.5 by Snownee to Horizon: the look-at overlay for blocks and entities, with its providers, its settings under Options > Mods, its keys, and the `/jade` and `/jadec` commands.
Rewritten in Rust for Horizon's hooks, so no original code runs. Not made or endorsed by Snownee, so please report problems to this project, not to them.
**Ported:** themes, opacity, text background, profiles, ignore lists, boss bar overlap, TTS, perspective mode, reach, icon positions, the item mod name and the plugin sub-options.
**What differs from Jade:**
- Themes are drawn with solid fills in the theme's colours, not Jade's sprites.
- The position adjust screen is a setting you cycle through, not a screen you drag on.
- The sniffer's dig cooldown isn't shown.
- Energy Storage has nothing to show, because Horizon has no shared energy API for other mods.
- Access variant and NPC details are approximate.
Tested on Windows, in single player and on LAN with one guest. Dedicated servers don't build native mods, so "server" here means a LAN host.
Licence: CC BY-NC-SA 4.0, noncommercial use only, the same licence as Jade."""
authors = ["Snownee", "Joeypc6"]
owners = ["Joeypc6"]
license = "CC-BY-NC-SA-4.0"
source = "https://github.com/Joeypc6/Jade-Horizon"
icon = "https://media.forgecdn.net/avatars/thumbnails/207/323/256/256/636965628804677340.png"
[[versions]]
version = "26.3.5"
published = "2026-11-04"
game = "26.3"
file = "jade-26.3.5.hnmod"
sha256 = "676a129f55db075284313a39ff4387674c426ebd9be04a8830ffb8955281c7ec"
changelog = "First Horizon release, a port of Jade 26.3.5. Built and tested on Horizon commit c9d474d."
+34
View File
@@ -0,0 +1,34 @@
id = "killc"
kind = "native-mod"
owners = ["Vakarux12"]
key = "RWS5u/7lEyKbzVHC6uMJRlhM/N7vzKp5fZe2LCDUlHsDK4lvPupxJbqQ"
name = "KillC"
summary = "/killc, /killc <player> and /suicide, with an optional random respawn near (0, 0)."
description = """
A Horizon port of [adasjusk's KillC](https://github.com/adasjusk/KillC), the kill command plugin for Paper, Folia, Velocity and Fabric.
- `/killc` and `/suicide` kill you.
- `/killc <player>` kills another player (operator level 2).
- After a death in the Overworld, players respawn at a random surface spot within `randomSpawnRadius` of (0, 0).
- Config: `config/killc.json` (`enabled`, `useRandomSpawn`, `randomSpawnRadius`), the same file as the Fabric build.
Vanilla keeps `/kill`. Runs where the world is hosted: single player and worlds opened to LAN (Horizon's dedicated server cannot build native mods yet). Native mods run with the player's account permissions and are not sandboxed.
Source: `horizon/` in https://github.com/Vakarux12/KillC, a fork of the original. CC-BY-SA-4.0, the original's licence, with a grant for Horizon's reviewers in `horizon/REVIEW-GRANT.md`.
"""
authors = ["adasjusk", "Vakarux12"]
license = "CC-BY-SA-4.0"
source = "https://github.com/Vakarux12/KillC/tree/main/horizon"
[[versions]]
version = "5.1.0"
published = "2026-11-01"
game = "26.3"
file = "killc-5.1.0.hnmod"
sha256 = "6c44d0a5e93aef42f2a33df8286980d9957a04526ff2c1884dfe7aa83f837086"
signature = """
untrusted comment: signature from minisign secret key
RUS5u/7lEyKbzbHxeuREFeXgBYM6FjB/wiwBtmdu5na35k0DXINdt3FILWvtW7yDekrFYjn4Xrl5pv9ttijPbocgpNbqZpPqLgY=
trusted comment: timestamp:1793694719 file:killc-5.1.0.hnmod hashed
vC2AGwA5JYAw1yjFLScjYyy+ef03gA5lxE3mwpD/D6CqKRblrpIKtHacgjYViC714ob5w1fgXFPya8AobYmVBg=="""
changelog = "First Horizon release, ported from adasjusk's KillC."
+22
View File
@@ -0,0 +1,22 @@
id = "mod_menu"
kind = "native-mod"
name = "Mod Menu"
summary = "ModMenu For Horizon"
description = """
Adds a Mods button next to Accessibility on the title screen. It opens a Mod Menu style screen that lists every mod you have installed, with its icon, name, version and description.
- Search by name, id or description, and filter by on, off, native or content mods.
- Pick a mod to see its details, turn it on or off, or open its own settings page.
- Open Mods Folder and Done at the bottom, as in the Java mod."""
authors = ["tariksabanuz"]
owners = ["tariksabanuz"]
license = "MIT"
source = "https://github.com/tariksabanuz/ModMenuHorizon"
icon = "https://raw.githubusercontent.com/tariksabanuz/ModMenuHorizon/main/icon.png"
[[versions]]
version = "0.3.0"
published = "2026-11-03"
game = "26.3"
file = "mod_menu-0.3.0.hnmod"
sha256 = "7896b3b5ad727ef6e2bc56039796713809483fb157daa385f78ebd3353d653bd"
+22
View File
@@ -0,0 +1,22 @@
id = "motion_blur"
kind = "native-mod"
name = "Motion Blur"
summary = "Blurs what moves: mobs, players, items and vehicles along their own motion, with optional camera blur."
description = """
Moving mobs, players, items, minecarts and boats blur along their own motion. The camera's motion (turning, walking) can blur too, and is off by default.
Each frame the mod works out every pixel's motion since the last frame (the world's from the depth buffer and the camera, entities from last frame's vertices), finds the largest motion near each tile, and gathers along it with depth-aware weights (McGuire et al., "A Reconstruction Filter for Plausible Motion Blur", 2012).
Settings are in Options → Mods → Motion Blur: strength for moving things and the camera, shutter angle or a fixed exposure, the longest blur, quality, blurring yourself in third person, and a view that shows each pixel's motion."""
authors = ["levzzz5154"]
owners = ["levzzz5154"]
license = "MIT"
source = "https://github.com/levzzz5154/horizon-motion-blur"
[[versions]]
version = "0.1.0"
published = "2026-11-02"
game = "26.3"
file = "motion_blur-0.1.0.hnmod"
sha256 = "4cdaa6369fffea8116b013edfc618358ca989f48ed65c83306d2102f0e506c47"
changelog = "First release, built against Nightly 2026-11-01: motion blur for moving mobs, players, items and vehicles, with optional camera blur."
+4
View File
@@ -0,0 +1,4 @@
id = "radiant"
kind = "shader"
repo = "expensiverest/radiant"
owners = ["expensiverest"]
+38
View File
@@ -0,0 +1,38 @@
id = "resourcify"
kind = "native-mod"
name = "ResourcifyHorizon"
summary = "Resourcify for Horizon"
description = """
Find and install resource packs from Modrinth without leaving the game. By tariksabanuz.
A **+** button appears in the top right of the Resource Packs screen. It opens a browser with:
- a search box that searches while you type, a sort button (Relevance, Downloads, Newest, Updated) and a switch between "for this game version" and "all versions";
- a list of packs with pictures, authors and download counts, loaded as you scroll;
- a detail pane with the versions of the selected pack and an **Install** button.
Installing downloads the file from Modrinth's own server (`cdn.modrinth.com` only), checks it against the SHA-1 hash Modrinth provides, and saves it in `resourcepacks/`. Nothing is turned on for you.
Source code: https://github.com/tariksabanuz/ResourcifyHorizon"""
authors = ["tariksabanuz"]
owners = ["tariksabanuz"]
license = "MIT"
source = "https://github.com/tariksabanuz/ResourcifyHorizon"
icon = "https://github.com/tariksabanuz/ResourcifyHorizon/blob/364648324ac0e67489a3f06faa716d96d24668ed/icon.png"
[[versions]]
version = "0.1.1"
published = "2026-11-04"
game = "26.3"
file = "resourcify-0.1.1.hnmod"
sha256 = "a6611819c9401dc7370dece7453cdda2356747f2a34c32940bf82ccd6abdcf53"
changelog = """
- Version 0.1.1.
- Rebuilt and re-checked against the current Horizon revision."""
[[versions]]
version = "0.1.0"
published = "2026-11-03"
game = "26.3"
file = "resourcify-0.1.0.hnmod"
sha256 = "789447fb454864af2a711ad8204f395331248d70dba570016b248766b959a2a2"
+50
View File
@@ -0,0 +1,50 @@
id = "right_click_crops"
kind = "native-mod"
name = "Right-Click Crops"
summary = "Right-click harvest for 16 plant families, with replanting and individual settings."
description = """
Harvest mature plants with a right-click, replant crops, and keep your garden growing.
### Features
- Supports 16 plant families.
- Replants crops using one planting item from the harvest, or from your inventory.
- Keeps the base of tall plants and the stems of pumpkins and melons.
- Lets you enable or disable each plant separately in **Options > Mods > Right-Click Crops > Settings**.
### Supported plants
Wheat, carrots, potatoes, beetroot, nether wart, cocoa, torchflower, pitcher crop, sugar cane, cactus, bamboo, kelp, twisting vines, weeping vines, pumpkin and melon.
### Good to know
- Harvesting works in survival, one targeted plant at a time.
- Without a suitable planting item, the crop is harvested but not replanted.
- Torchflower needs spare seeds; pitcher crop needs spare pitcher pods.
- Twisting and weeping vines can drop nothing when harvested by hand. Use shears for guaranteed drops.
- Bone meal, ordinary breaking and planting, sweet berries and glow berries keep their normal behaviour.
### Compatibility
Last checked on **November 1, 2026**, for Minecraft 26.3. The release is pinned to Horizon Nightly 2026-10-31 (`9d979ca`), where single-player and LAN were tested. A compilation-only check also passed on the newest Nightly available on that date (`d91cab6`); gameplay on that newer revision was not tested.
This is a native mod: install only if you trust its source and accept the launcher's native-code warning.
[Watch the demo](https://youtu.be/kll-qvj5s54) · [Harvesting guide](https://github.com/AyanoKaito/horizon-right-click-crops/blob/main/docs/harvesting.md)
"""
authors = ["AyanoKaito"]
owners = ["AyanoKaito"]
license = "MIT"
source = "https://github.com/AyanoKaito/horizon-right-click-crops"
icon = "icons/right_click_crops.png"
key = "RWRwbuaByP5mM4VUm4kZEQVPiuw+3IEVJ7CZcEHIjY5fPKf9lhQKUZRy"
[[versions]]
version = "1.0.0"
published = "2026-11-01"
game = "26.3"
file = "right_click_crops-1.0.0.hnmod"
sha256 = "681ce896c50dd041132900e7bbab71167f133b3f3862f86e8b1a0cac52754b97"
signature = """
untrusted comment: signature from minisign secret key
RURwbuaByP5mM4x8HGWHv/S/WlHPsmamRwsi70PTq7E5u4uJ7wyNzNT4rwpR/vGA9V2H6w9yLhmF1CPtS1grEfWorPixnk6efAw=
trusted comment: timestamp:1793523764\tfile:right_click_crops-1.0.0.hnmod\thashed
seiWz0D/X34FIKhiE1yfZc2uAkkSGuwAnTHbGrK63sHg+HdL+EgqEkzaxNgJTiMOdnf/iIdSkDVVwuF+Od/SCQ==
"""
changelog = "First public release: right-click harvesting for 16 plant families, ordinary Horizon loot, paid replanting, retained bases and stems, and individual plant settings. Validated in single-player and for LAN host and guest."
+9 -4
View File
@@ -2,16 +2,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."""
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."
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.1"
published = "2026-11-02"
game = "26.3"
file = "rightharvest-0.1.1.hnmod"
sha256 = "2f70f3a5abaca35f1ff8df7b198f0fd616eacaa5229f5113f75935e16f50255b"
changelog = "Rebuilt on the 2026-11-01 Nightly (Horizon `4aa6854`); the `mods` hook is named instead of pinned to a source target, so it survives an update that moves that point."
[[versions]]
version = "0.1.0"
published = "2026-10-30"
+34
View File
@@ -0,0 +1,34 @@
id = "rmotd"
kind = "native-mod"
owners = ["Vakarux12"]
key = "RWS5u/7lEyKbzVHC6uMJRlhM/N7vzKp5fZe2LCDUlHsDK4lvPupxJbqQ"
name = "RMotd"
summary = "Rotating server list MOTDs, managed in game with /randommotd and /motd."
description = """
A Horizon port of [adasjusk's RMotd](https://github.com/adasjusk/RMotd), the rotating MOTD plugin.
- The MOTD in the server list changes every 10 seconds, through the list in `config/rmotd.json` (the Fabric build's file).
- `/randommotd` turns rotation on and off.
- `/motd create <name> <motd>` adds an MOTD, `/motd reload` reads the file again (operator level 2).
- MOTDs take `&` colour codes.
It sets the MOTD of a world opened to LAN. The server list ping shows the rotation, the LAN broadcast keeps the first one. Horizon has no MOTD hook point, so a support module in `horizon_client::lan` writes the private MOTD field of the LAN server. Its injections are all hook points, but that field is not a stable API, so a later Nightly can break the build. Runs where the world is hosted: single player and worlds opened to LAN (Horizon's dedicated server cannot build native mods yet). Native mods run with the player's account permissions and are not sandboxed.
Source: `horizon/` in https://github.com/Vakarux12/RMotd, a fork of the original. CC-BY-SA-4.0, the original's licence, with a grant for Horizon's reviewers in `horizon/REVIEW-GRANT.md`.
"""
authors = ["adasjusk", "Vakarux12"]
license = "CC-BY-SA-4.0"
source = "https://github.com/Vakarux12/RMotd/tree/main/horizon"
[[versions]]
version = "1.0.0"
published = "2026-11-01"
game = "26.3"
file = "rmotd-1.0.0.hnmod"
sha256 = "68d43b760e95178a67bc1c7d54fbde004eee4bb764d9891d83bd4e4fa4bd8ce5"
signature = """
untrusted comment: signature from minisign secret key
RUS5u/7lEyKbzSnV+LLOgmX/1ryZ/5d3asf4KyfdUUOJKALk9isprehFuFIDN5X+GZy0C7jzHAImFnq9FRt2yc1J3N5lbA1oUgM=
trusted comment: timestamp:1793694734 file:rmotd-1.0.0.hnmod hashed
x+Ecd8KV0OFBcUEONAvAHxw87IG2MnjWZzcrIiMLjRNtl7KcuZQXvakrAm6slC1qADzj+n+HpwYoGT1QmQ07Bg=="""
changelog = "First Horizon release, ported from adasjusk's RMotd."
+21
View File
@@ -0,0 +1,21 @@
id = "smoothie_capes"
kind = "native-mod"
name = "Smoothie Capes"
summary = "An Wavey Capes inspired mod for Horizon"
authors = ["Nully"]
owners = ["dumbnully"]
license = "MIT"
source = "https://github.com/dumbnully/smoothie-capes"
key = "RWRJPBl24rTE23dFezewHWl465FAcgTsmeJnDkANtUPT19YfVmQ1gYCu"
[[versions]]
version = "0.2.0"
published = "2026-11-02"
game = "26.3"
file = "smoothie_capes-0.2.0.hnmod"
sha256 = "303b88bf5dd2c518ed9f5186252813b5a98716289c42e60838d0cb9846fc6429"
signature = """
untrusted comment: signature from minisign secret key
RURJPBl24rTE247uqvyB1Bh5PIfR4C2XUVWvCx4/lt5jAO8sgDBEgtfs9adTKkv+L89ENRyYUVCLG1c8yptts8AYfcmPEI7bcQs=
trusted comment: timestamp:1793663973\tfile:smoothie_capes.hnmod\thashed
f+QyN8EVz5M41RHayEZGX7Fk1Z+FZnhUTv7MwH5Y3GTBYXiNMAHAIMdox4i62RDTx3wQhVUuIOBa3GqJ2H7KAQ=="""
+35
View File
@@ -0,0 +1,35 @@
id = "stuck"
kind = "native-mod"
owners = ["Vakarux12"]
key = "RWS5u/7lEyKbzVHC6uMJRlhM/N7vzKp5fZe2LCDUlHsDK4lvPupxJbqQ"
name = "Stuck?"
summary = "/stuck and /escape move you to a safe spot nearby, off the Nether roof too."
description = """
A Horizon port of [adasjusk's Stuck?](https://github.com/adasjusk/Stuck), the unstuck plugin for Spigot, Paper, Folia and Fabric.
- `/stuck` and `/escape` search random columns near you for solid ground with two blocks of air above, and teleport you there.
- Skips lava, fire, magma, cactus and other hazards, and leaves and snow.
- A player on the Nether's bedrock roof is brought back under it.
- Cooldown between uses, 30 seconds by default.
- Config: `config/stuck.properties`, the same keys as the Fabric build.
Only loaded chunks are searched. Runs where the world is hosted: single player and worlds opened to LAN (Horizon's dedicated server cannot build native mods yet). Native mods run with the player's account permissions and are not sandboxed.
Source: `horizon/` in https://github.com/Vakarux12/Stuck, a fork of the original. CC-BY-SA-4.0, the original's licence, with a grant for Horizon's reviewers in `horizon/REVIEW-GRANT.md`.
"""
authors = ["adasjusk", "Vakarux12"]
license = "CC-BY-SA-4.0"
source = "https://github.com/Vakarux12/Stuck/tree/main/horizon"
[[versions]]
version = "1.0.0"
published = "2026-11-01"
game = "26.3"
file = "stuck-1.0.0.hnmod"
sha256 = "8e6292d9c89f660e72f01e54be4103b5273017955c7bd8034dd5ae8ec2e98630"
signature = """
untrusted comment: signature from minisign secret key
RUS5u/7lEyKbzf0/fn5Is2b8jJq55073yA8Sl7hSeLN1jAg9itcMlCO2kcEMVJaGyYrxFZIwPYfQJMCb61o/0g8XD8iYIUCeswU=
trusted comment: timestamp:1793694727 file:stuck-1.0.0.hnmod hashed
/JDVcy8eoaPHppahV7p+f+3LTx0CtfE34jqqZq7bU4TNvnSOx7ISCgp6Eg3+X1xYMRJRwTYM2aJwgbytRGzOCA=="""
changelog = "First Horizon release, ported from adasjusk's Stuck."
+30
View File
@@ -0,0 +1,30 @@
id = "tectonic_horizon"
kind = "native-mod"
name = "Tectonic-Horizon"
summary = "Unofficial native port of Tectonic: mountain ranges, underground rivers and configurable terrain."
description = """
Unofficial native Rust port of Tectonic 3.0.1 by Apollo (Apollounknowndev), revision 0761b561062de01b701d2571401f625c31059a9a under CC0-1.0. Not made or endorsed by Apollo or the Horizon project.
Mountain ranges, terrain density recipes, underground rivers and configurable terrain for Horizon 26.3. Source-only native mod: install in the launcher, then Build; runs with the game's account permissions. No Minecraft code or assets are included.
Enable under Options > Mods before opening a world. The mod's Settings page edits terrain and biome configuration. Config: config/tectonic-horizon.json in the game home. Invalid configuration logs a diagnostic and uses defaults. Restart after editing; new chunks only. Select increased height before world creation.
Pins upstream Horizon 8dbbde43501924d16497d251e6e9d08f812eafcd and Mixins e8cd0aa38cffef49cb2e088ef716baa107148c02. See docs/REVIEW-0.3.4.md for current validation and docs/VALIDATION.md for historical checks with their original revisions.
No block-for-block Java parity claim. Native river-decoration placement differs from Lithostitched. Java config UI and Terralith/CliffTree overlays are omitted. Other biome mods need their own Horizon ports.
![Tectonic terrain](https://raw.githubusercontent.com/eltanschauung/tectonic-horizon/6f69f2b64a41f0cad09f87840cbdb104138c54c3/docs/screenshots/tectonic-landscape.png)
Author-supplied screenshot captured November 1 in our Horizon fork; illustrative, not evidence of current upstream validation. Minimap is a separate mod. Original Tectonic by Apollo; Horizon adaptation by eltanschauung with Codex assistance. Source licenses, notices and provenance hashes retained."""
authors = ["eltanschauung", "Apollo (original Tectonic)"]
owners = ["eltanschauung"]
license = "CC0-1.0"
source = "https://github.com/eltanschauung/tectonic-horizon"
[[versions]]
version = "0.3.4"
published = "2026-11-04"
game = "26.3"
file = "tectonic_horizon-0.3.4.hnmod"
sha256 = "590198d7df43b5c9474c41648db89a15bd4d388be0fe860a2f84a84b59285cec"
changelog = "Revision of PR #160: removes unsupported runtime-toggle.json, falls back to defaults with a diagnostic for invalid/unreadable/oversized configuration, and fixes the GitHub formatting failure. Includes the 0.3.2 in-game settings editor and 0.3.3 upstream height adapter. Pins Horizon 8dbbde43501924d16497d251e6e9d08f812eafcd and Mixins e8cd0aa38cffef49cb2e088ef716baa107148c02. All 13 Rust tests, Clippy, formatting, native-package checking, reproducible packaging, mixin plan/apply, and the patched horizon-world compile check pass. No new interactive game test is claimed. Source: https://github.com/eltanschauung/tectonic-horizon/tree/6f69f2b64a41f0cad09f87840cbdb104138c54c3"
+29
View File
@@ -0,0 +1,29 @@
id = "visuality_horizon"
kind = "native-mod"
name = "Visuality"
summary = "Small pixel particles: hit fragments, slime blobs, creeper sparks, glints, soul wisps and rain ripples."
description = """
An original client-side Rust implementation of the effects of PinkGoosik's Visuality (MIT), with its own art made in Blender.
Effects
Hit fragments: bone chips from skeletons, strays and skeleton horses, dark chips from wither skeletons, feathers in each chicken variant's colours, emerald chips from villagers.
Slime blobs: chunky blobs in three sizes when a slime lands, in a colour you choose (magma cubes are left alone).
Charged creeper sparks, amethyst sparkles on full clusters, armor glints on gold and diamond armor, ore glints on uncovered faces only, soul wisps over soul sand and soil and when sprinting on them, and flat rain ripples on open water.
Low preset by default, for integrated GPUs; Balanced and High add density. Settings in Options > Mods > Visuality, lists in config/visuality_horizon.toml.
Client only: it sends nothing to the server and needs nothing installed there. Tested in single player on Windows; LAN, servers, Linux and macOS are not tested yet (see docs/VALIDATION.md).
Pinned to Horizon c9d474d (Nightly 2026-11-01): it hooks private client functions, so it builds on that commit only.
Not made or endorsed by Visuality's author."""
authors = ["BenjiSyS"]
owners = ["BenjiSyS"]
license = "MIT"
source = "https://github.com/BenjiSyS/visuality-horizon"
icon = "icons/visuality_horizon.png"
[[versions]]
version = "1.0.2"
published = "2026-11-03"
game = "26.3"
file = "visuality_horizon-1.0.2.hnmod"
sha256 = "b764f15a062e44b27c55f478b2d02e163bfb01c3d8e222fcd6d83bcc66f19a0d"
changelog = "First release, for Horizon Nightly 2026-11-01 (c9d474d). 1.0.1: entity pass skips entities out of reach (client tick 20-31 us instead of 71-157 us); package entries stored, so the file is byte-identical whatever Python builds it. 1.0.2: atlas and notices carried as Rust source, so the bundle holds only .rs and .toml files."
+42
View File
@@ -0,0 +1,42 @@
id = "voicechat"
kind = "native-mod"
name = "Horizon Voice Chat"
summary = "Talk with Simple Voice Chat players: proximity voice, groups, push to talk and a menu (V)."
description = """
An unofficial client for the [Simple Voice Chat](https://github.com/henkelmax/simple-voice-chat) protocol. On a server with the Simple Voice Chat mod or plugin, you can talk with players who use the Java mod on the regular launcher.
## What it does
- **Menu (V):** connection status, microphone and voice chat on/off, push to talk or voice activation, voice volume, microphone gain and activation level with a live meter, and every hotkey as it is bound now. The right side lists the server's groups with their members: join (with a password if needed), leave, or create a group.
- **Talking:** hold Caps Lock (push to talk) or use voice activation. Voices come from where players stand, getting quieter with distance and panned left or right.
- **Groups:** each player's group shows above their head. `/vc join <group> [password]`, `/vc leave`, `/vc create <name> [password] [normal|open|isolated]` and `/vc groups` work too.
- **Keys** (Options > Mods > Horizon Voice Chat): menu (V), push to talk (Caps Lock), whisper (unbound), mute (J), voice chat off/on (K).
- **Protocols:** Simple Voice Chat 2.6.x (compatibility 20) and 2.7 (21).
## What it touches
- It reads the server's `voicechat:*` custom payloads and other players' positions inside `game::remote`, so it is pinned to Horizon `c9d474d`.
- It opens one UDP connection to the server's voice chat port, encrypted with AES-128-GCM using the secret the server sends.
- It uses the default microphone and speakers through cpal, and loads the system **libopus** at run time (the `opus` package must be installed).
- It keeps its settings in Horizon's own mod settings. It contacts no host other than the Minecraft server you are on (and DNS, for the server's SRV record).
## Not affiliated
This project is not made, endorsed or supported by Max Henkel or the Simple Voice Chat project. It contains none of Simple Voice Chat's code: everything is written from scratch in Rust.
## Tested
The engine's unit tests pass, and the modded client type-checks. Live, against Paper 26.3 with the Simple Voice Chat 2.6.27 plugin, it authenticated and connected, a test tone was heard through the server, and groups and labels came from the server. Not tested yet: the menu in the real game, a real microphone, 2.7 servers, and talking with the Java mod itself.
"""
authors = ["Nirokx"]
owners = ["Nirokx"]
license = "MIT"
source = "https://github.com/Nirokx/horizon-voice-chat"
[[versions]]
version = "1.0.0"
published = "2026-11-02"
game = "26.3"
file = "voicechat-1.0.0.hnmod"
sha256 = "7a12ef9e691d10128ea081906a313d597c67f22897380618871477f9dd8eabf1"
changelog = "First release."
+23
View File
@@ -0,0 +1,23 @@
id = "wayfarer_stones"
kind = "mod"
name = "Wayfarer Stones (assets)"
summary = "The textures, text, recipes and villages of Wayfarer Stones. Needs the Wayfarer Stones mod."
description = """
**This is one of the two parts of [Wayfarer Stones](https://playhorizon.dev/mods/wayfarer_stones_native), and does nothing alone.** Install both.
It holds the mod's models and textures, its English and Russian text, its recipes and drops, and the stones villages build. It has no code. The code, and the description of what the mod does, are in Wayfarer Stones.
All the art is original. No Minecraft assets are included. MIT licensed."""
authors = ["bioneisme"]
owners = ["Bioneisme"]
license = "MIT"
source = "https://github.com/Bioneisme/wayfarer-stones"
icon = "https://raw.githubusercontent.com/Bioneisme/wayfarer-stones/main/content/assets/wayfarer_stones/textures/item/wayfarer_stone.png"
[[versions]]
version = "0.6.0"
published = "2026-11-04"
game = "26.3"
file = "wayfarer_stones-0.6.0.hmod"
sha256 = "87bec46b6bbe05d95563a4bf7b8a3b92340ca1dd54c2d5709b6a0a41989d7176"
changelog = "First release."
+33
View File
@@ -0,0 +1,33 @@
id = "wayfarer_stones_native"
kind = "native-mod"
name = "Wayfarer Stones"
summary = "Place stones around your world, activate them, and travel between the ones you know."
description = """
**This mod has two parts, and you need both.** Install this one and [Wayfarer Stones (assets)](https://playhorizon.dev/mods/wayfarer_stones), then press Build.
- **Place** a stone, or find one in a village. Right-click it once to add it to your list.
- **Travel** from any stone you know: pick a destination on the screen, or search for it. You land beside the stone, on solid ground.
- **Pay** in experience levels: 1, plus 1 for each 500 blocks, up to 5. Travel between dimensions costs 3.
- **Carry** a Wayfarer's Scroll to travel from anywhere. Each travel uses one up.
- **Stones are safe:** only the owner or an operator can break one, and pistons won't move it.
Every world has its own settings: cost, cooldown, warmup and more. English and Russian.
**Commands:** `/wayfarer`, `/wayfarer list`, `/wayfarer to <name>`, `/wayfarer name <name>`, `/wayfarer forget <name>`. Operators also have `/wayfarer global <name>` and `/wayfarer remove <name>`.
Native source mod: install it on the launcher's Mods page, press Build there, and confirm the native-code warning. It runs with your account's permissions. It is pinned to one Horizon Nightly and Horizon Mixins revision, named in each version's changelog.
No Minecraft code or assets are included. MIT licensed."""
authors = ["bioneisme"]
owners = ["Bioneisme"]
license = "MIT"
source = "https://github.com/Bioneisme/wayfarer-stones"
icon = "https://raw.githubusercontent.com/Bioneisme/wayfarer-stones/main/content/assets/wayfarer_stones/textures/item/wayfarer_stone.png"
[[versions]]
version = "0.6.0"
published = "2026-11-04"
game = "26.3"
file = "wayfarer_stones_native-0.6.0.hnmod"
sha256 = "0c5929425bea6c570ad455229b519fc411ea2b769ef08be8a91bb8b7793d9dba"
changelog = "First release. Pinned to Nightly 2026-11-04 (67dad0d) and Horizon Mixins e8cd0aa."
+31
View File
@@ -0,0 +1,31 @@
id = "waymap"
kind = "native-mod"
name = "Waymap"
summary = "Native Horizon waypoint mod with per-context storage, minimap dots, world markers, and full command suite."
description = """
Waymap — Native client-side waypoints for Horizon.
Features:
- World markers (3D, camera-projected)
- Minimap dots (rotation-aware)
- Waypoint list panel (bottom-left, 6 nearest)
- Per-context storage (per server server_<host:port>.txt / world world_<folder>.txt)
- Atomic saves (temp + atomic rename), legacy migration
- Commands: /wp add|del|list|rename|color|clear|edit|help
- Keys: N = add, B = toggle list"""
authors = ["Aron"]
owners = ["Lauch2"]
license = "MIT"
source = "https://github.com/Lauch2/horizon-waymap-mod"
key = "RWS1B1QZPeYaSNKJr9rffnfHDSvB0AZj/Ul1B2pKrJ2XFBgKXL0FC96u"
[[versions]]
version = "0.1.4"
published = "2026-11-02"
game = "26.3"
file = "waymap-0.1.4.hnmod"
sha256 = "61bf573af5b2711453ca37a41b4ac2abc78ab827863bcafb9bfabe7dbf19ccd5"
signature = """
untrusted comment: signature from minisign secret key
RUS1B1QZPeYaSBV67KshMqdN7e9tTtnd0aJ2j2hE0IOvZNQpo896FjvOETBrxu0OhB738CE44moftY8pvDMvF8o+dxXsfxQduAU=
trusted comment: timestamp:1793662341\tfile:waymap-0.1.4.hnmod\thashed
j0dStr7Yhq33q3uA+wVGhfiEfjdNx0XBCUIHiZ5AfD7vPhcCqtVnrnYx8KKcliC2UOMNBP0Kl3gOz2MyiqJSAQ=="""
+27
View File
@@ -0,0 +1,27 @@
id = "waypoints"
kind = "native-mod"
name = "Waypoints"
summary = "Place waypoints with N, manage them with M, and see their name and distance in the world."
description = """
Waypoints for Horizon.
- **N**: create a waypoint where you stand: name, X/Y/Z, dimension, colour, visible or hidden. Enter saves, Tab moves between fields, Esc cancels.
- **M**: the waypoint list, filtered by All / Overworld / Nether / End. Each row shows its colour, name, coordinates, dimension and distance, with Hide/Show, Edit and Delete buttons.
- **In the world**: each visible waypoint in your current dimension shows its name, its distance and a marker in its colour.
- **Options > Mods > Waypoints**: rebind both keys and turn the in-world labels on or off.
**What it does and doesn't do:** it only reads the game's own state (player position, dimension, camera, world folder name, server address) and draws on the HUD and its own screen. It stores waypoints as a text file per world and per server in `horizon-mods/.horizon-mod-data/waypoints/` inside the game folder. It uses only the Rust standard library, opens no network connections, and reads or writes no other files.
Pinned to Horizon `c9d474d`, because it reads the world folder, server address and last frame's camera from the game itself."""
authors = ["Nirokx"]
owners = ["Nirokx"]
license = "MIT"
source = "https://github.com/Nirokx/horizon-waypoints"
[[versions]]
version = "1.0.0"
published = "2026-11-02"
game = "26.3"
file = "waypoints-1.0.0.hnmod"
sha256 = "9c0e670f1205c23fed0fe0b0eaf8f0b90a01f3cbb883a8eee445ebc03f4d90a4"
changelog = "First release for Horizon 26.3 (Nightly c9d474d)."
+32
View File
@@ -0,0 +1,32 @@
id = "wisdom_horizon"
kind = "shader"
name = "Wisdom Shaders V3 (Horizon port)"
summary = "bobcao3's Wisdom Shaders V3, ported to Horizon's own WGSL shader format."
description = """
An unofficial port of **Wisdom Shaders V3 (Build 3300)** by bobcao3 (https://github.com/bobcao3/Wisdom-Shaders) to Horizon's shader API (schema 1, API 0.1), translated from GLSL to WGSL. Wisdom Shaders is licensed under the Apache License 2.0, and so is this port. It is not an official Wisdom Shaders release.
## Ported
- Wisdom's sun, ambient and torch colours by time of day, and its exposure curve.
- Oren-Nayar diffuse and GGX specular lighting, light through thin plants, and soft shadows from a jittered 3x3 kernel.
- The atmosphere, 2D clouds and their shadows, the sun and moon discs, and height fog.
- The AgX tonemap with its lookup table (ACES as an option), plus bloom, the vignette and waving foliage.
## Changed for Horizon
- Lighting runs per surface in the material stage, since Horizon has no G-buffers.
- Shadows use Horizon's shadow map with PCF, and water and stained glass tint the light passing through them.
- There is no SSAO, SSR, volumetric clouds, god rays, caustics or TAA. FXAA replaces TAA.
- The water is rewritten for Horizon's translucent pass.
The full list of changes is in the README. For integrated graphics there is a Lite version (`wisdom_horizon_lite`) based on Wisdom's LITE profile."""
authors = ["bobcao3", "tomasmendes01"]
owners = ["tomasmendes01"]
license = "Apache-2.0"
source = "https://github.com/tomasmendes01/wisdom-horizon"
[[versions]]
version = "1.1.0"
published = "2026-11-04"
game = "26.3"
file = "wisdom_horizon-1.1.0.hshader"
sha256 = "2b7b4e11e3506b65e8c4162ed56fbf3c33bc51bbc56038a4b1297219d917dd1e"
changelog = "First release: a port of Wisdom Shaders V3 (Build 3300) to Horizon's shader API."
+25
View File
@@ -0,0 +1,25 @@
id = "wisdom_horizon_lite"
kind = "shader"
name = "Wisdom Shaders V3 Lite (Horizon port)"
summary = "The LITE profile of bobcao3's Wisdom Shaders V3 for Horizon, made for integrated graphics."
description = """
The LITE profile of the unofficial Horizon port of **Wisdom Shaders V3 (Build 3300)** by bobcao3 (https://github.com/bobcao3/Wisdom-Shaders). Like the original, it is licensed under the Apache License 2.0. It is not an official Wisdom Shaders release.
It has the same lighting, sky, clouds, fog and AgX tonemap as the full version (`wisdom_horizon`), tuned to Wisdom's LITE profile for integrated graphics:
- a 1024 shadow map over 64 blocks, with single-tap shadows
- no cloud shadows and no water mirror
- no bloom and no FXAA
The README in the repository lists everything that was ported and what changed."""
authors = ["bobcao3", "tomasmendes01"]
owners = ["tomasmendes01"]
license = "Apache-2.0"
source = "https://github.com/tomasmendes01/wisdom-horizon"
[[versions]]
version = "1.1.0"
published = "2026-11-04"
game = "26.3"
file = "wisdom_horizon_lite-1.1.0.hshader"
sha256 = "662570653111bd34fec37a7482791b8b1d0bdcee1bcba5351c4137acababb818"
changelog = "First release: the LITE profile of Wisdom Shaders V3 (Build 3300), ported to Horizon's shader API."
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "4baa571566e39bfd6594988d3bb2bc85440c30e7",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "4baa571566e39bfd6594988d3bb2bc85440c30e7",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "horizon-mod-smp"
version = "0.29.0"
version = "0.31.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "horizon-mod-smp"
version = "0.29.0"
version = "0.31.0"
edition = "2024"
license = "MIT"
publish = false
+84 -110
View File
@@ -8,8 +8,8 @@ Players install nothing: it speaks only what a stock Horizon client already show
lines, vanilla chest menus the server fills with ordinary items (the trade menu); later sounds, titles, the action
bar, boss bars and the sidebar).
This version: the economy with its Treasury and taxes, the auction house, the bank, the server market and its `/shop` menu, player trades, the coinflip, the lottery, places (spawn, homes, warps,
teleport requests, the rules), land claims, and the scoreboard (a sidebar with your balance, playtime and the richest players, the Tab list's
This version: the economy, the auction house, the server market and its `/shop` menu, player trades, the coinflip, the lottery, places (spawn, homes, warps,
teleport requests, the rules), land claims, and the scoreboard (a sidebar with your balance and playtime, the Tab list's
header and footer).
| Command | Does |
@@ -17,19 +17,14 @@ header and footer).
| `/balance [player]`, `/bal` | your balance, or another player's |
| `/pay <player> <amount>` | gives money to a player who has an account (anyone who has played since the mod was on) |
| `/baltop [page]` | the richest players, 10 a page |
| `/eco take <player> <amount> [reason]`, `/money take ...` | operators of level 3: a cheat's gains go into the Treasury, logged with the reason. Nobody gives or sets money (`/eco give` and `/eco set` say so) |
| `/treasury`, `/treasury log` | the Treasury's balance, its income by tax and its spending (today and since it opened), its last movements |
| `/treasury pay <player> <amount> <reason>` | operators of level 3: pays a player from the Treasury (an event prize, a bug refund, a grant), never more than it holds; everyone sees it |
| `/jobs`, `/jobs deliver <n>` | the day's contracts: deliver items the state asks for, paid from the Treasury |
| `/futures`, `/futures long\|short <item> <margin> [leverage]`, `/futures close <n>`, `/futures info <item>`, `/futures list` | perpetual futures on the market's prices: a chest menu of your positions (profit green, loss red, liquidation price), leverage up to 10x |
| `/eco stats` | the money supply and where it sits, what the server made and destroyed today, taxes and fees, how evenly money is spread |
| `/bank`, `/bank deposit <amount\|all>`, `/bank withdraw <amount\|all>` | your savings, which earn a small interest from the Treasury each night |
| `/eco give <player> <amount> <reason>`, `/money give ...` | operators of level 3: makes money for a player (an event prize, a bug refund); everyone reads it with the reason, and the audit log keeps it |
| `/eco take <player> <amount> [reason]`, `/money take ...` | operators of level 3: takes a cheat's gains out of the game, logged with the reason |
| `/eco stats` | the money supply and where it sits, what the server made and destroyed today, how evenly money is spread |
| `/ah` | the auction house: a chest menu of listings by category, cheapest a unit first; click one to buy it or bid; your listings (click to take one back) and your mail |
| `/ah sell <price> [count]` | lists the stack in your hand (or part of it) at a fixed price, for `listing_hours` (48); the listing fee (1%, at least 1 coin) goes to the Treasury |
| `/ah sell <price> [count]` | lists the stack in your hand (or part of it), whole (enchantments, name, contents...), at a fixed price, for `listing_hours` (48); listing is free |
| `/ah auction <start> [hours]` | auctions the stack in your hand from a starting bid, for 1 to 24 hours (12) |
| `/ah bid <listing> <amount>`, `/ah buy <listing>` | bids (the money is held until someone bids more or it ends), buys a fixed-price listing |
| `/ah cancel <listing>`, `/ah claim`, `/ah search <words>`, `/ah mine`, `/ah mail` | takes back a listing nobody bid on; gives you your mail; the menu on a search, your listings, your mail |
| `/eco taxes`, `/taxes` | the tax code and your own bill on Monday; `/eco taxes pay` pays land tax you owe |
| `/shop` | the market as a chest menu: categories, a click buys one, a shift-click 64, a click on a stack of yours sells it, "Sell all" |
| `/market [item]` | the moving market as a chest menu: categories, each item's price, its move over 1 h and 24 h and its volume; an item's page buys or sells 1, 16 or 64, sells all you carry, and gives its price chart as a map |
| `/worth [item]` | an item's price, the same to buy and to sell (the one in your hand without a name), and what you may still sell today |
@@ -44,7 +39,7 @@ header and footer).
| `/casino`, `/casino roulette\|slots\|crash\|blackjack` | the casino's lobby menu, or a game's table (needs `gambling.enabled` and `casino.enabled`) |
| `/casino help`, `/casino top`, `/casino stats` | every game's odds and house edge and the limits; today's biggest wins; what you bet, won and may still lose today |
| `/coinflip accept <player>`, `/coinflip cancel`, `/coinflip list` | takes an offer (a fair coin decides, the winner gets both stakes), withdraws yours, lists those waiting |
| `/spawn` | to the server's spawn; `/setspawn` (operators of level 3) sets it where you stand |
| `/spawn` | to a safe spot within `places.spawn_radius` of 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 |
@@ -59,8 +54,10 @@ header and footer).
| `/nick <name>`, `/nick off`, `/nick` | operators of level 3 (with `ops_only`, the default): another name for you in chat and the Tab list, shown as `~name` (hovering it gives your real one); `/nick off` (anyone with a nickname) takes it off; `/nick` alone says how |
| `/nick <player> <name\|off>` | operators of level 3: sets or takes off a player's nickname |
| `/realname <nick>` | who goes by that nickname |
| `/vanish`, `/vanish list` | operators of level 3: hides you from the other players, or shows you again; who is vanished |
| `/invsee <player>`, `/endersee <player>` | operators of level 3: the player's inventory (main, hotbar, armour, off hand) or ender chest in a chest menu, on or off the server; click one of theirs to take it, one of yours to give it; every move in `audit.log`, a summary in `staff.log` |
| `/sidebar off`, `/sidebar on`, `/sidebar` | hides the scoreboard sidebar for you (remembered), shows it again |
| `/smp status`, `/smp off [part\|all] [reason]`, `/smp on [part\|all]` | operators of level 3: the emergency switches (below) |
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
@@ -70,7 +67,7 @@ money and the room before anything moves.
- One price per item, to buy and to sell (since 0.29): a config that still has two (`buy` 400, `sell` 100) gets
their geometric mean (200) at load, logged ("one price now"); `buy` or `sell` 0 still closes that side. A trade
of N items is priced along the curve it moves the price on (below), so buying N and selling them back gives back
exactly what was paid, less the tax: a round trip never earns (tested for 1, 64 and past the minute's band, and
exactly what was paid (less whole-coin rounding): a round trip never earns (tested for 1, 64 and past the minute's band, and
over thousands of random trades with the drift in between).
- No chain of recipes turns what the market sells into something it buys for more: the cheapest way to get each item
from the market (bought, or crafted, smelted, cut from bought items, through any number of recipes) is worked out,
@@ -86,8 +83,7 @@ money and the room before anything moves.
and paying it all to a main is refused once the main's limit is reached ("can only receive N more today").
Trades (`/trade`) and auction house sales are not counted.
- Amounts are checked: no price above 1,000,000,000,000, no sum that overflows.
- The coinflip and the lottery give the winner the stakes (less `fee_percent`, 0 by default): they move money
between players and never make any.
- The coinflip and the lottery give the winner all the stakes: they move money between players and never make any.
Default prices (coins; "buy only": the market sells it but doesn't buy it, "sell only" the other way):
@@ -116,6 +112,14 @@ before it is made (`invsee_take`, `invsee_put`: operator, player, inventory, ite
its reason); closing the menu writes one line to the server log (`[smp] staff: ...`), `staff.log`, and the chat of
the other operators on. Needs horizon#6106 (`native_inventory`, `native_move_stack`).
**Vanish.** `/vanish` takes an operator of level 3 out of the Tab list and the world of every player below level 3,
whatever the game mode (survival and creative too), and those players don't read them joining or leaving; operators of
level 3 or more still see them, and are told who vanished. A vanished operator reads a subtitle the whole time
(*Vanished: only operators see you*), isn't counted online (the sidebar, the Tab list's footer), doesn't hold the night up, isn't
announced as AFK or on a first join, and can't be reached by name by those who don't see them (`/tpa`, `/trade`,
`/coinflip`, `/playtime`). It lasts over a relog and a restart (`vanish.json`) until `/vanish` again. Needs
horizon#7003 (`native_hide_player`).
**Nicknames.** With `ops_only` (the default since 0.22) only operators of level 3 set nicknames: their own, or a
player's with `/nick <player> <name>`; a player may take off the one given to them (`/nick off`). At each start, a
nickname a player who isn't an operator picked themselves is taken off and logged (`for operators only`). 3 to 16 letters, digits or `_`, shown with a `~` in front (`~Rocky`) so a nickname never passes for
@@ -142,22 +146,20 @@ when selling: one diamond at 200 costs 201 and sells for 199, 64 cobblestone at
exact path of the purchase. All trades together move an item at most `max_minute_move_percent` (5%) a minute from
where it started that minute: past that, a purchase is cut short and a sale keeps the rest ("sell them in a
minute"). The factor goes back to 1 with a half-life of `half_life_hours` (6) and stays within `min_factor` (0.6)
and `max_factor` (1.6), where the rest of a trade goes at that price. A `tax_percent` (2%) of each sale and purchase
goes to the Treasury, on its own line of the receipt ("Sold 3 diamond at 198 coins each: 595 coins." then "Tax 2%:
-12 coins. You get 583 coins."). Items a player bought from the market in the last `round_trip_hours` (24) can't be
and `max_factor` (1.6), where the rest of a trade goes at that price. There is no tax on trades (an older config's
`tax_percent` is ignored): the receipt is the price ("Sold 3 diamond at 198 coins each: 595 coins."). Items a player bought from the market in the last `round_trip_hours` (24) can't be
sold back to it. (`max_trade_impact_percent` and `max_player_impact_percent` are no longer used: the curve and the
minute's band do their work.) History: one point a minute for 24 hours and one an hour for 30 days (the mid price, the
items traded and those sold), in `market_prices.json`, written by a thread every 5 minutes and when the world closes.
At the first minute of each UTC day, the day's three biggest risers and fallers (at least 1%) and news from real
activity ("Diamond supply up 40% today", sales against the day before's, at least `news_percent` and
`news_min_volume`) go to everyone's chat and to `web/market.json` for Kira to post. `enabled: false` keeps the
fixed prices of 0.23. For other mods (the futures): `Market::spot`, `twap(id, minutes, now)`, `mark_price(id, now)`
(the 10-minute TWAP from at least 5 points), `volume(id, now, hours)`, `history(id, Span)`.
fixed prices of 0.23. For other mods: `Market::spot`, `volume(id, now, hours)`, `history(id, Span)`.
**`/market`.** A 6-row chest menu: the shop's categories and the day's movers; a category's items, each with its
price, its change over 1 h and 24 h (green ▲, red ▼) and what was traded in 24 h; an item's page with its
base price, the tax and what you may still sell today, buttons to buy or sell 1, 16 and 64 (what they come to
along the curve, and the tax), sell all of it you carry, and the price chart over 24 hours or 7 days, given as a filled map (128 × 128,
base price and what you may still sell today, buttons to buy or sell 1, 16 and 64 (what they come to
along the curve), sell all of it you carry, and the price chart over 24 hours or 7 days, given as a filled map (128 × 128,
drawn by the server: the curve green when it rose, red when it fell, the price, the change, the low and high). A
chart map is drawn again each minute for whoever carries it, and once a minute the open menus follow the prices.
`/market <item>` opens the item's page. `/worth` adds the item's moves and volume.
@@ -180,14 +182,14 @@ the trade, and swaps everything in one step, or nothing (and both must confirm a
adventure players only.
**Coinflips.** Off by default: the owner turns them on in `config.json` (`gambling`: `enabled`, `min_bet` 10,
`max_bet` 1000, `cooldown_seconds` 30, `offer_seconds` 60, `fee_percent` 0). A stake is held from the offer, in
`max_bet` 1000, `cooldown_seconds` 30, `offer_seconds` 60). A stake is held from the offer, in
`economy.json` with the balances, until the coin falls; an offer cancelled, not taken in time, or whose player
left gives it back, and so does the next start for one the server stopped with. The coin is a fresh random key from
the system, never the world's random. Only the in-game currency is staked and won.
**The lottery.** Part of `gambling` (`ticket_price` 10, `max_tickets` 100 a player a draw, `lottery_seconds` 3600
from a round's first ticket to its draw). Tickets are held like coinflip stakes; at the draw one ticket is picked at
random among all sold (more tickets, more chances), its owner gets the pot (less `fee_percent`), and everyone hears
random among all sold (more tickets, more chances), its owner gets the whole pot, and everyone hears
it. A round with a single player, or one the server stopped with, gives every ticket back.
**The casino.** On with `gambling.enabled` (and `casino.enabled`, true by default). Every game is a chest menu any
@@ -211,26 +213,25 @@ Every draw is made by the server (ChaCha20 keyed from the system) before the ani
`cooldown_ms` (2000) passes between the end of a round and their next bet. A bet is held like a coinflip stake;
closing the menu or leaving never loses a round: a spin or a pull ends and is paid, a blackjack hand stands, a crash
bet is cashed out when its player leaves in flight (or given back before take-off). Wins are paid from the casino's
pot (`economy.json`'s pool `casino`), never with new money: a win larger than the pot is paid up to it. Once an hour
the pot above `float` (20,000) goes to the Treasury (`casino_profit`); below it, the Treasury tops it up with what it
holds (`casino_float`). A win of `jackpot_multiple` (50) times the stake, or of `announce_profit` (5000) coins, is
pot (`economy.json`'s pool `casino`): a win larger than the pot is paid up to it. A new casino's pot gets `float`
(20,000) once, made for it (`casino_float`; an older pot only what it lacks of it), then keeps every lost bet: the
house edge stays in the pot, out of the players' hands. A win of `jackpot_multiple` (50) times the stake, or of `announce_profit` (5000) coins, is
told to everyone. Settings in `config.json`'s `casino`, also `crash_edge_percent` (4) and `crash_max` (100).
**Places.** A teleport waits `warmup_seconds` (3) in `config.json`'s `places`; a player who moves or is hurt meanwhile
stays where they are, and `cooldown_seconds` (10) passes between two of one player's teleports. `/tpa` takes the
stays where they are, and `cooldown_seconds` (10) passes between two of one player's teleports. Teleports are free (an older config's
`places.fees` is ignored). `/tpa` takes the
asker to where the other player is when the warmup ends. Homes grow with the time played on the server since the mod
was on (`homes`: `[hours, homes]`, by default 1 home, 2 from 5 hours, 3 from 20, 5 from 100). `rules` holds `/rules`'
lines. Teleports are vanilla's `tp`, run as the server (`execute in <level> run tp`), so they cross levels and land
as any teleport does. Teleports cost a fee to the Treasury (`places.fees`: `spawn` 0, `home` 5, `warp` 10, `back` 10,
`tpa` 10, paid by the one who moves), logged as `travel_fee`. It is checked when the command runs and paid when the
teleport happens: one called off (moved, hurt, the money spent meanwhile) costs nothing and leaves `/back` and the
cooldown as they were. The spawn stays free so a lost or new player can always get back.
lines. `/spawn` lands on a random safe column within `spawn_radius` (250) blocks of the spawn point, like vanilla's `respawn_radius` for a first join or a respawn: after the `tp`, vanilla's `spreadplayers` moves the player off fluid, fire and magma onto solid ground (0: exactly the spawn point; if no column is found the player stays at the spawn point). 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
turns them off. `claims.free_radius` (0, none) keeps the land within that many blocks of 0 0, in every dimension, free
for all: no one claims a chunk that touches it, and claims already there are dropped at start (each logged). The world stays out too: no water or lava flows, no fire spreads or burns, and no piston pushes into
a claim from land that isn't its owner's (or a player's they trust), and explosions break no claimed block, whoever
lit them. Buckets are guarded where they fill or empty, and hitting the claim's item frames, armour stands,
paintings, boats, minecarts and animals does nothing for others (`claims.protected_entities`, entity ids; monsters
@@ -239,14 +240,14 @@ items, shearing, leads), which waits for Horizon's hook (#4648). The server's `s
the spawn.
**The scoreboard.** A sidebar on the right, titled `board.title` ("Horizon SMP"): your own block first (your name, or
your nickname; your balance, `125.4k`, `3.2M`; your playtime, `2h 05m`; the players online), then the `board.top` (3)
richest players with their balance, then `board.footer` (`play.playhorizon.dev`, empty for none). The ranking is
built once for everyone, only when a balance changed (at most every `board.min_seconds`, 5) or every
`board.refresh_seconds` (60); each player's own lines are looked at on the same `board.refresh_seconds`, and a
your nickname; your balance, `125.4k`, `3.2M`; your playtime, `2h 05m`; the players online), then `board.footer`
(`play.playhorizon.dev`, empty for none). It lists no ranking: `/baltop` does (`board.top`, of 0.14 to 0.29, is
ignored). The balances are read once for everyone, only when one changed (at most every `board.min_seconds`, 5) or
every `board.refresh_seconds` (60); each player's own lines are looked at on the same `board.refresh_seconds`, and a
sidebar is sent only to the players whose lines changed and to newcomers: nothing runs per tick per player. The Tab
list shows `board.tab_header` and `board.tab_footer` (`{server}`, `{tps}`, `{players}` filled in, every
`board.tab_refresh_seconds`, 5). `board.sidebar` and `board.tab` turn either off; `board.server_name` names the Tab
list. A server that kept 0.13's defaults (`Richest players`, the top 10) gets these at its first start of 0.14. The
list. A server that kept 0.13's title (`Richest players`) gets `Horizon SMP` at its first start. 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
@@ -264,82 +265,40 @@ limited. All of it is looked at once a second.
leave the seller's inventory at once; what doesn't sell, what an auction's winner gets, and what didn't fit in a
buyer's inventory wait in the player's mail (`/ah claim`, or the menu's mail page; as much as fits, the rest stays).
A purchase takes the whole listing: the listing leaves first, the items move, then the money (the seller gets the
price less VAT, which goes to the Treasury); of two players who buy at once (the game runs every click on one
whole price); of two players who buy at once (the game runs every click on one
thread), the second finds it gone. Bids wait in the `auction` pool: a higher bid gives the last one back at once,
raising one's own only holds the difference, and a bid in the last `snipe_seconds` (60) moves the end back to
60 seconds away. When an auction ends, the seller is paid (less VAT) and the items go to the winner's mail. Sellers
60 seconds away. When an auction ends, the seller is paid the winning bid and the items go to the winner's mail. Sellers
and outbid players who are offline find a note when they come back. Everything (listings, bids, mail, notes) is
written in `economy.json` with the balances, in one step: a crash can't keep the money and lose the items, or the
reverse. Only plain items for now (not worn, named or enchanted); whole stacks with their enchantments wait for
Horizon's `native_give_stack` to be stamped. Survival and adventure players only. `config.json`'s `auction`:
`enabled`, `listing_fee_percent` (1), `max_listings` (8 a player), `listing_hours` (48), `auction_hours` (12),
reverse. A listing carries the whole stack (since 0.31): its enchantments, name, damage, lore, a shulker box's
or a bundle's contents and every other component, written as the game writes it in player files, and given back
exactly so (Horizon's `native_give_stack`) to the buyer, the winner, or the seller when it comes back. `/ah sell
<price> <count>` lists part of the stack in hand; the rest stays in the hand, the same. The menu shows the stack
itself (its tooltip), with the listing's price, seller and time under it. A stack is taken only if it would come
back the same (its data written and read back equal); not market charts (free at `/market`), nor a stack holding
more than 32 KiB of data (a box of books). Listings and mail from before 0.31 hold plain items and are given back as
before. Survival and adventure players only. `config.json`'s `auction`:
`enabled`, `max_listings` (8 a player), `listing_hours` (48), `auction_hours` (12),
`auction_hours_max` (24), `bid_step_percent` (5: the smallest raise), `snipe_seconds` (60). Ended listings are
looked at once a second (a pass over the open listings).
**The Treasury and taxes.** Nobody can make money, operators included: money enters the game only through the daily
reward and what the market buys from players (later, paid contracts, from the Treasury). Every tax, fee and house edge
goes into the Treasury, a pool in `economy.json` that belongs to no player (not in `/baltop` or the sidebar), and
operators pay only from it (`/treasury pay`, with a reason everyone sees, logged as `grant`). A refund for a bug is
such a payment, never new money. `/treasury` shows its balance, its income by kind and its spending; `/treasury log`
its last 50 movements. The tax code (`config.json`'s `taxes`):
- VAT (`vat_percent`, 5): on sales between players (the auction house, next), paid by the seller from the price.
- Land tax, every Monday 00:00 UTC: `land_tax_per_chunk` (5 coins) a week per claimed chunk past the first
`land_free_chunks` (4). What a player can't pay is owed: no new claim until it's paid (`/eco taxes pay`, or the
next Monday's collection). Nothing is ever unclaimed for it.
- Wealth tax, every Monday: `wealth_brackets` `[from, percent]`, by default 1% of a balance's part above 50,000,
2% above 250,000 and 3% above 1,000,000 a week, on the balance and bank savings together (paid from the
balance). On 2026-11-01 the richest player held 11,804 coins: nobody pays it
yet; it keeps fortunes from growing without bound as the economy does.
- Games of chance: the coinflip's and the lottery's `fee_percent` goes to the Treasury.
The week's collection is looked at once a second (one comparison) and runs once a week; a restart never collects
twice (the week collected is kept with the balances). `enabled: false` turns the weekly taxes off.
**Money in and out.** Money enters the game through what the market buys from players, the daily reward, the
casino's float (once) and operators' `/eco give` (with a reason everyone reads, logged as `eco_give`). It leaves
through what the market sells and operators' `/eco take`. There are no fees, taxes, bank, contracts or futures: no
VAT, land or wealth tax, no tax on market trades, no teleport, auction or coinflip fee. A world from before 0.30 is
cleaned up at its first start: bank savings and the margins of open futures positions go back to their owners
(`bank_closed`, `futures_closed`), and the Treasury, its insurance fund, contracts and land tax owed are dropped.
`config.json`'s old `taxes`, `bank`, `jobs` and `futures` sections and the `fee_percent` and `listing_fee_percent`
keys are ignored.
**The bank.** `/bank` shows your savings; `/bank deposit <amount|all>` and `/bank withdraw <amount|all>` move coins
between your wallet and the bank (the `bank` pool), free and at any time. Interest (`bank.daily_basis_points`, 10:
0.1% a day) is paid at 00:00 UTC from the Treasury, on the lowest amount saved since the last payment (a deposit made
just before midnight earns nothing that night), on at most `max_interest_on` (10,000) coins of an account; a player
saves at most `max_deposit` (25,000). One night's interest for everyone is at most `max_treasury_percent` (5) of the
Treasury, shared out pro rata when that's short: the bank never makes money, it hands back part of the taxes to
those who keep coins out of circulation. Interest stays saved (logged as `bank_interest`); deposits and withdrawals
are `bank_deposit` and `bank_withdraw`. No loans: their collateral (items) can't be held safely here, and a loan
without it is free money for an alt that never pays back. Savings are kept in `economy.json` (`ext.bank`).
**Contracts.** Each day at 00:00 UTC the state posts `jobs.contracts_per_day` (4) contracts drawn from `jobs.items`
(farm, mine and mob produce the market buys): "deliver 64 wheat for 128 coins", announced to everyone. `/jobs` lists
them; `/jobs deliver <n>` takes `batch` (64) plain items from the inventory (they leave the game) and pays at once from
the Treasury, logged as `contract <count> <item>`. A delivery pays the market's sell price plus `premium_percent`
(30), and never more than `ask_percent` (90) of what the market asks for the item, checked again at delivery as
prices move: buying from the market to deliver never pays. Each contract takes `deliveries` (8) for the whole
server, a player makes at most `max_per_player` (2) a day, survival or adventure only, and one day's contracts cost
at most `budget_percent` (10) of the Treasury when posted (an empty Treasury posts none). Never AFK income: only a
command with the items pays. The board is kept in `economy.json` (`ext.jobs`): a restart neither posts twice nor
loses a delivery.
**Futures.** `/futures long|short <item> <margin> [leverage]` opens a perpetual position on an item's market price:
its size is margin × leverage at the mark, in items. The mark is the market's 10-minute average of the mid
(`Market::mark_price`/`twap`), never the last trade, so one player can't move it in one go; an item needs 5 minutes of
prices first. Leverage: 2x for any traded item, +1x per `volume_per_leverage` (500) items the market traded in 24 h,
up to `max_leverage` (10x). The liquidation price is shown when it opens and in the menu. Each minute (the market's
price timer, never per tick) positions are marked: one whose equity falls to `maintenance_percent` (5%) of its size
is liquidated, online or not, and its whole margin goes to the insurance fund (the player is told when back; a
margin of `announce_margin` (1,000) or more is told to everyone). A player never loses more than the margin: a move
past it is the fund's (counted as `gaps`). The house is every trade's other side through the insurance fund (part of
the Treasury): profits come out of it, losses go in, and a position that would raise the house's net exposure past
what the fund could pay in a `stress_percent` (30%) move is refused. Fees `fee_percent` (0.2%) of the size, to open
and to close, go to the Treasury. Funding, each hour: the side with more open interest pays the fund up to
`funding_percent_hour` (0.05%) of its size, in proportion to the imbalance, from its margins. Caps: `max_player_item`
(20,000 coins of size on one item) and `max_player_total` (50,000), open interest on an item at most
`max_item_volume_days` (3) days of its market volume. Positions and their margins (the `futures` pool) are in
`economy.json` (`ext.futures`), written with the money: a restart keeps them and never liquidates twice.
**The report.** `/eco stats` (anyone) shows the money supply (wallets, savings and money held, the Treasury, the
insurance fund, each pool), what the server made today (market sales, the daily reward) and destroyed (market
purchases, operators' `/eco take`), the day's taxes and fees, and how evenly money is spread (Gini over players
**The report.** `/eco stats` (anyone) shows the money supply (wallets and money held, each pool, the casino's pot),
what the server made today (market sales, the daily reward, `/eco give`) and destroyed (market purchases, operators'
`/eco take`), and how evenly money is spread (Gini over players
with money, the richest ten's share). At 00:00 UTC everyone reads the day before in one line (`report.daily_report`).
A snapshot of each day is kept 60 days (`report.history_days`) in `economy.json` (`ext.report`). Once a minute
`world/horizon-smp/web/economy.json` (`report.web_json`) is written off the tick thread for the site's /hub:
`{"schema": 1, "generated": <ms>, "currency", "today", "pools", "minted_today", "burned_today", "treasury": {balance,
insurance, income, spent, income_today, spent_today, log}, "taxes": {rates, last_collection}, "days": [...],
`{"schema": 1, "generated": <ms>, "currency", "today", "pools", "minted_today", "burned_today", "days": [...],
"daily_report": "<yesterday's line>"}`. The server's Discord relay posts only chat, joins, leaves and deaths: the
daily line reaches Discord through whoever reads that file (Kira).
@@ -348,6 +307,19 @@ pool: the stake goes in, the payout comes out, never more than the pool holds),
The currency is earned in game only: it can't be bought with real money or cashed out (Mojang's usage guidelines).
**Emergency switches.** An operator of level 3 turns parts of the SMP off while the server runs: `/smp off`
(everything but land protection), `/smp off <part> [reason]`, `/smp on [part]`, `/smp status` (what is off, by whom,
when and why). Parts: `economy` (balances, `/pay`, `/eco`, the daily
reward), `market` (`/worth`, `/sell`, `/buy`, `/shop`, `/market`), `auction`, `trades`, `gambling` (coinflips, the
lottery, the casino), `places` (spawn, homes, warps, `/tpa`, `/back`), `claims` (land protection and
`/claim`: only by name, `/smp off claims`), `board` (the sidebar and the Tab list's header and footer), `presence`
(welcomes, AFK, the commands' rate limit), `nicks`, `invsee`. A part that is off answers its commands with "... is
turned off for now by the server's staff", and its menus close at the next click. Turning one off ends open trades
with nothing moved, closes the shop, market and auction menus, calls off teleports waiting, takes the
sidebars and nicknames away. Its timed work waits (the lottery's draw, auctions ending, the daily reward); nothing held is lost: coinflips not taken still go back and a casino
round under way still ends and is paid. The other operators on are told, and each change is in the server's log and
`audit.log` (`switch_off`, `switch_on`). What is off is kept in `switches.json`, so a restart keeps it off.
## Data
Everything is in the world's folder, `world/horizon-smp/`, so a backup of the world carries it. A world without that
@@ -356,18 +328,20 @@ folder is untouched until the mod first runs on it; it changes no block and no p
| File | Holds |
|---|---|
| `config.json` | `currency` (default `coins`), `starting_balance` (default 0), `market`: `enabled`, `items` (each item's `buy`, what the market asks, and `sell`, what it pays, 0 closing that side), `daily_sell_coins` (2000) and `daily_sell_items` (640), 0 for no limit; `shop`: `enabled`, `title`, `categories` (`name`, `icon`, `items`); `web`: `url` (Kira's `/smp/ingest`) and `token` (empty: nothing is sent), where the pages of `web/` go; written with the defaults the first time, and again when a newer version adds keys (your values kept) |
| `economy.json` | balances by player UUID, with the name last seen; `held`: coinflip stakes and lottery tickets being decided; `pools`: the Treasury, its insurance fund and other system pools; `treasury`: its income and spending by kind and its last movements; `ext`: other parts' state written in the same step as the money (`tax`: the week collected, land tax owed; `auction`: the listings, bids, mail and notes) |
| `economy.json` | balances by player UUID, with the name last seen; `held`: coinflip stakes and lottery tickets being decided; `pools`: the casino's pot and the auction house's bids; `ext`: other parts' state written in the same step as the money (`auction`: the listings, bids, mail and notes) |
| `places.json` | the spawn, the warps, each player's homes, and the seconds each player has played (written once a minute) |
| `nicks.json` | each player's nickname and when they last changed it |
| `vanish.json` | the vanished operators by UUID, with their name |
| `staff.log` | one line per `/invsee` or `/endersee` menu in which something moved: who, whose, what was taken and put |
| `market.json` | what each player sold today (coins and items), for the daily limits |
| `market_prices.json` | each item's price factor and history (minutes for 24 h, hours for 30 days: `[time, mid, traded, sold]`), what each player bought in the last day (no selling it back), the day's summary |
| `web/market.json` | for the website (`/hub/market`, through Kira), written by a thread each minute prices move: `schema` 1, `generated`, `currency`, `tax_percent`, `taxes_collected`, `daily_summary` (`day`, `lines`), `items` (`id`, `name`, `category`, `price` (the one price; `buy` and `sell` repeat it on the sides the market trades, for older readers), `mid`, `base`, `change_1h`, `change_24h`, `volume_24h`, `day`: 5-minute points, `month`: hours, each `{step, t, price, volume}`). When `web.token` is set, the same thread POSTs each page of `web/` that changed (gzipped, `Authorization: Bearer <token>`) to `<web.url>/<name>`: Kira keeps the last one and serves it to the site; the server needs no inbound access and Kira no key to it |
| `web/market.json` | for the website (`/hub/market`, through Kira), written by a thread each minute prices move: `schema` 1, `generated`, `currency`, `tax_percent` (always 0, for older readers), `daily_summary` (`day`, `lines`), `items` (`id`, `name`, `category`, `price` (the one price; `buy` and `sell` repeat it on the sides the market trades, for older readers), `mid`, `base`, `change_1h`, `change_24h`, `volume_24h`, `day`: 5-minute points, `month`: hours, each `{step, t, price, volume}`). When `web.token` is set, the same thread POSTs each page of `web/` that changed (gzipped, `Authorization: Bearer <token>`) to `<web.url>/<name>`: Kira keeps the last one and serves it to the site; the server needs no inbound access and Kira no key to it |
| `presence.json` | the players who have joined (no welcome again), and the day each last got the daily reward |
| `board.json` | the players who hid the sidebar (`/sidebar off`) |
| `switches.json` | the parts operators turned off (`/smp off`): by whom, when and why |
| `casino.json` | the day's ledger: each player's bets, wins and rounds today (the daily loss cap), the day's biggest wins, each game's totals, the pot's last sweep |
| `claims.json` | each claimed chunk (`<dimension> <chunk x> <chunk z>`) with its owner, and whom each owner trusts |
| `audit.log` | one JSON line per change, written before the change: `time` (ms), `kind` (`open`, `pay`, `grant`, `land_tax`...), `amount`, `from`/`to` with their balance before, `by` for an operator, `to_pool`/`from_pool` with `pool_before` and `reason` for a pool's movement; a trade's line (`trade`: both players, their items and money) comes before its swap, `trade_failed` after one that didn't go through |
| `audit.log` | one JSON line per change, written before the change: `time` (ms), `kind` (`open`, `pay`, `grant`, `ah_listing_fee`...), `amount`, `from`/`to` with their balance before, `by` for an operator, `to_pool`/`from_pool` with `pool_before` and `reason` for a pool's movement; a trade's line (`trade`: both players, their items and money) comes before its swap, `trade_failed` after one that didn't go through |
`economy.json` is written whole to a temporary file, flushed and renamed over the old one, at most once a server tick
and when the world closes: a crash leaves the old file or the new one. If it can't be read, the economy stays off (the
@@ -380,13 +354,13 @@ acting player's items (API 9), the server menus and `native_exchange_items` (API
`native_run_command` (API 21), `native_destroy_block` (API 14), `native_right_click_block`, the acting player and the
action bar (API 21), `native_block_spread`, `native_piston_move`, `native_explosion_detonate`, `native_fill_bucket` and
`native_attack_entity` (API 22), `native_sidebar`, `native_tab_list` and `native_tick_stats` (API 49),
`native_set_nickname` and `native_text_allowed` (API 62), the server tick and the world lifecycle.
`native_set_nickname` and `native_text_allowed` (API 62), `native_hide_player` (API 314, horizon#7003), the server tick and the world lifecycle.
`compatibility.json` names `"hooks": 58`.
| File | What it is |
|---|---|
| `mixins/smp/horizon-mixin.toml` | The bundle: twenty-eight modules and the HEAD hooks |
| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs`, `smp_recipes.rs`, `smp_shop.rs`, `smp_nick.rs`, `smp_trade.rs`, `smp_games.rs`, `smp_lottery.rs`, `smp_places.rs`, `smp_claims.rs`, `smp_board.rs`, `smp_presence.rs`, `smp_tax.rs`, `smp_auction.rs`, `smp_bank.rs`, `smp_report.rs`, `smp_jobs.rs`, `smp_market_prices.rs`, `smp_market_menu.rs`, `smp_market_chart.rs`, `smp_invsee.rs`, `smp_futures.rs` | The rules, no game types (a trade's stacks are a type parameter): added to the game as `game::smp_store`, `game::smp_economy`, `game::smp_market`, `game::smp_recipes`, `game::smp_shop`, `game::smp_nick`, `game::smp_trade`, `game::smp_games`, `game::smp_lottery`, `game::smp_places`, `game::smp_claims`, `game::smp_board`, `game::smp_presence`, `game::smp_tax`, `game::smp_auction`, `game::smp_bank`, `game::smp_report`, `game::smp_jobs`, `game::smp_market_prices`, `game::smp_market_menu`, `game::smp_market_chart`, `game::smp_invsee`, `game::smp_futures`; also this crate's modules (`src/lib.rs`), for their tests |
| `mixins/smp/horizon-mixin.toml` | The bundle: twenty-nine modules and the HEAD hooks |
| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs`, `smp_recipes.rs`, `smp_shop.rs`, `smp_nick.rs`, `smp_trade.rs`, `smp_games.rs`, `smp_lottery.rs`, `smp_places.rs`, `smp_claims.rs`, `smp_board.rs`, `smp_presence.rs`, `smp_auction.rs`, `smp_report.rs`, `smp_market_prices.rs`, `smp_market_menu.rs`, `smp_market_chart.rs`, `smp_invsee.rs`, `smp_switch.rs`, `smp_vanish.rs` | The rules, no game types (a trade's stacks are a type parameter): added to the game as `game::smp_store`, `game::smp_economy`, `game::smp_market`, `game::smp_recipes`, `game::smp_shop`, `game::smp_nick`, `game::smp_trade`, `game::smp_games`, `game::smp_lottery`, `game::smp_places`, `game::smp_claims`, `game::smp_board`, `game::smp_presence`, `game::smp_auction`, `game::smp_report`, `game::smp_market_prices`, `game::smp_market_menu`, `game::smp_market_chart`, `game::smp_invsee`, `game::smp_switch`, `game::smp_vanish`; also this crate's modules (`src/lib.rs`), for their tests |
| `mixins/smp/glue.rs` | The state for the open world and the edges to the game's types (`game::smp_glue`) |
| `mixins/smp/{register,command,tick,lifecycle,menu_clicked,menu_closed,destroy_block,right_click_block,block_spread,piston_move,explosion_detonate,fill_bucket,attack_entity}.rs` | One small helper per hook |
| `mixins/smp/smp_casino.rs`, `smp_casino_roulette.rs`, `smp_casino_slots.rs`, `smp_casino_crash.rs`, `smp_casino_blackjack.rs` | The casino's rules, menus and effects (`game::smp_casino*`) |
+3 -3
View File
@@ -1,8 +1,8 @@
{
"game": "26.3",
"hooks": 62,
"hooks": 314,
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "975c07d9151847e6af13afc4b5ab37b6bbe78ef5",
"horizon_revision": "48c63bc09d9acc82f2074e75ae59720514796a9d",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+1 -1
View File
@@ -132,7 +132,7 @@ pub(in crate::game) fn clicked(
}
/// The casino's share of the server's tick: frames, rounds that end, the
/// pot's hourly sweep. The console commands, for after the lock.
/// pot's float (once). The console commands, for after the lock.
pub(in crate::game) fn tick(
game: &mut Game<'_>,
casino: &mut Casino,
File diff suppressed because it is too large. Load diff
+16 -28
View File
@@ -1,12 +1,12 @@
[mod]
id = "smp"
version = "0.29.0"
version = "0.31.0"
priority = 100
# The commit of horizon it was tested on: it needs horizon#6106 (merged at 975c07d): native_inventory,
# native_move_stack, native_send_map, lore on the wire.
# The commit of horizon it was tested on: it needs native_hide_player (horizon#7003, API 314, stamped at 48c63bc),
# on top of horizon#6106's native_inventory, native_move_stack, native_send_map and lore on the wire.
[compatibility]
horizon_revision = "975c07d9151847e6af13afc4b5ab37b6bbe78ef5"
horizon_revision = "48c63bc09d9acc82f2074e75ae59720514796a9d"
[[module]]
id = "store"
@@ -62,42 +62,24 @@ file = "crates/horizon-client/src/game.rs"
name = "smp_report"
source = "smp_report.rs"
[[module]]
id = "jobs"
file = "crates/horizon-client/src/game.rs"
name = "smp_jobs"
source = "smp_jobs.rs"
[[module]]
id = "futures"
file = "crates/horizon-client/src/game.rs"
name = "smp_futures"
source = "smp_futures.rs"
[[module]]
id = "bank"
file = "crates/horizon-client/src/game.rs"
name = "smp_bank"
source = "smp_bank.rs"
[[module]]
id = "auction"
file = "crates/horizon-client/src/game.rs"
name = "smp_auction"
source = "smp_auction.rs"
[[module]]
id = "tax"
file = "crates/horizon-client/src/game.rs"
name = "smp_tax"
source = "smp_tax.rs"
[[module]]
id = "trade"
file = "crates/horizon-client/src/game.rs"
name = "smp_trade"
source = "smp_trade.rs"
[[module]]
id = "vanish"
file = "crates/horizon-client/src/game.rs"
name = "smp_vanish"
source = "smp_vanish.rs"
[[module]]
id = "games"
file = "crates/horizon-client/src/game.rs"
@@ -188,6 +170,12 @@ file = "crates/horizon-client/src/game.rs"
name = "smp_invsee_glue"
source = "invsee_glue.rs"
[[module]]
id = "switch"
file = "crates/horizon-client/src/game.rs"
name = "smp_switch"
source = "smp_switch.rs"
[[module]]
id = "glue"
file = "crates/horizon-client/src/game.rs"
+12 -1
View File
@@ -14,7 +14,7 @@ use crate::game::{
smp_economy::{Out, Player},
smp_glue,
smp_invsee::{self, Cell, Invsee, Kind, Move},
smp_store,
smp_store, smp_switch,
};
use horizon_client::{
items::ItemStack,
@@ -88,6 +88,11 @@ pub(in crate::game) fn perform(game: &mut Game<'_>, player: &NativeServerPlayer,
return;
}
*ran = true;
if !smp_glue::part_on(game, smp_switch::Part::Invsee) {
let refusal = smp_glue::refusal(smp_switch::Part::Invsee);
game.native_feedback(Text::literal(refusal).with_color(text::RED));
return;
}
let me = Player {
uuid: player.uuid,
name: player.name.clone(),
@@ -251,6 +256,12 @@ pub(in crate::game) fn menu_clicked(game: &mut Game<'_>, uuid: u128, window: i32
if !matches!(mode, 0 | 1) {
return true;
}
// Turned off since it opened: closed, nothing moves.
if !smp_glue::part_on(game, smp_switch::Part::Invsee) {
drop(guard);
game.native_close_menu(uuid);
return true;
}
let mut asked: Option<Move> = None;
// Each move is in the audit log before it is made; one the log
// refuses isn't made.
File diff suppressed because it is too large. Load diff
-442
View File
@@ -1,442 +0,0 @@
// The bank (`/bank`): savings that earn a small interest, paid once a day
// from the Treasury (taxes and fees), never minted. A player deposits coins
// (they wait in the `bank` pool) and takes them back at any time, free.
// Interest is paid at 00:00 UTC on the lowest amount the account held since
// the last payment, so a deposit made just before midnight earns nothing
// that night; only the first `max_interest_on` coins of each account earn,
// and one day's interest for everyone is at most `max_treasury_percent` of
// the Treasury (shared out pro rata when it's short). No loans: a loan's
// collateral (items) can't be held safely here, and a loan without it is
// free money for an alt that never pays back.
//
// The accounts live in `economy.json` (`ext.bank`), the coins in its pool:
// money and records are written together. No game types. Added to the game
// as `game::smp_bank`; also a module of this crate, for its tests.
use super::smp_economy::{DAY_MS, Economy, MAX_AMOUNT, Out, Player, TREASURY, parse_amount};
use super::smp_store::{parse_uuid, uuid_string};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
/// The pool savings wait in.
pub const POOL: &str = "bank";
/// `config.json`'s `bank`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub enabled: bool,
/// Interest a day, in hundredths of a percent (10: 0.1% a day).
pub daily_basis_points: i64,
/// Coins of an account that earn interest; more may be saved.
pub max_interest_on: i64,
/// What a player may save in all.
pub max_deposit: i64,
/// One day's interest for everyone is at most this share of the
/// Treasury.
pub max_treasury_percent: i64,
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: true,
daily_basis_points: 10,
max_interest_on: 10_000,
max_deposit: 25_000,
max_treasury_percent: 5,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Saving {
pub amount: i64,
/// The lowest `amount` since the last payment: what earns.
pub low: i64,
pub earned: i64,
}
/// `economy.json`'s `ext.bank`.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Data {
pub accounts: BTreeMap<String, Saving>,
/// The day (since 1970, UTC) interest was last paid.
pub day: i64,
pub paid: i64,
/// The last payment: what it was, and whether the Treasury's cap cut it.
pub last: i64,
pub last_cut: bool,
}
pub struct Bank {
pub config: Config,
pub data: Data,
dirty: bool,
}
impl Bank {
pub fn open(economy: &Economy, config: Config) -> Self {
let mut data = economy
.ext("bank")
.and_then(|v| serde_json::from_value::<Data>(v.clone()).ok())
.unwrap_or_default();
if data.day == 0 {
data.day = economy.now.div_euclid(DAY_MS);
}
Self {
config,
data,
dirty: false,
}
}
pub fn store(&mut self, economy: &mut Economy) {
if self.dirty {
economy.set_ext("bank", serde_json::to_value(&self.data).unwrap_or_default());
self.dirty = false;
}
}
/// What each player has saved (the wealth tax counts it).
pub fn savings(&self) -> HashMap<u128, i64> {
self.data
.accounts
.iter()
.filter_map(|(k, s)| Some((parse_uuid(k)?, s.amount)))
.collect()
}
pub fn saved(&self, uuid: u128) -> i64 {
self.data
.accounts
.get(&uuid_string(uuid))
.map_or(0, |s| s.amount)
}
/// `/bank ...`; None for another command.
pub fn command(&mut self, economy: &mut Economy, me: &Player, line: &str) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
if words.first() != Some(&"bank") {
return None;
}
if !self.config.enabled {
return Some(vec![Out::Error(
"The bank is closed on this server.".into(),
)]);
}
Some(match words[1..] {
[] => self.show(economy, me),
["deposit", amount] => self.deposit(economy, me, amount),
["withdraw", amount] => self.withdraw(economy, me, amount),
_ => vec![Out::Error(
"Usage: /bank, /bank deposit <amount|all>, /bank withdraw <amount|all>".into(),
)],
})
}
fn rate(&self) -> String {
let bp = self.config.daily_basis_points;
format!("{}.{:02}% a day", bp / 100, bp % 100)
}
fn show(&self, economy: &Economy, me: &Player) -> Vec<Out> {
let s = self
.data
.accounts
.get(&uuid_string(me.uuid))
.cloned()
.unwrap_or_default();
let next =
(s.low.min(self.config.max_interest_on) * self.config.daily_basis_points) / 10_000;
vec![
Out::Reply(format!(
"Saved: {} (earned so far {}). Wallet: {}.",
economy.money(s.amount),
economy.money(s.earned),
economy.money(economy.balance(me.uuid))
)),
Out::Reply(format!(
"Interest: {} on up to {}, paid at 00:00 UTC on the lowest amount saved since the last payment, from the Treasury (at most {}% of it a day). Tonight: about {}.",
self.rate(),
economy.money(self.config.max_interest_on),
self.config.max_treasury_percent,
economy.money(next)
)),
Out::Reply(format!(
"/bank deposit <amount|all> (up to {} saved), /bank withdraw <amount|all>: free, at any time. No loans.",
economy.money(self.config.max_deposit)
)),
]
}
fn amount(word: &str, all: i64) -> Option<i64> {
if word == "all" {
(all > 0).then_some(all)
} else {
parse_amount(word)
}
}
fn deposit(&mut self, economy: &mut Economy, me: &Player, word: &str) -> Vec<Out> {
let key = uuid_string(me.uuid);
let saved = self.saved(me.uuid);
let room = (self.config.max_deposit - saved).max(0);
let wallet = economy.balance(me.uuid);
let Some(amount) = Self::amount(word, wallet.min(room)) else {
return vec![Out::Error(if room == 0 {
format!(
"You have saved the most there is: {}.",
economy.money(self.config.max_deposit)
)
} else {
"The amount must be a positive whole number, or all.".into()
})];
};
if amount > room {
return vec![Out::Error(format!(
"You may save {} more (at most {} in all).",
economy.money(room),
economy.money(self.config.max_deposit)
))];
}
if let Err(e) = economy.to_pool("bank_deposit", me.uuid, POOL, amount, "") {
return vec![Out::Error(e)];
}
// `low` stays: what was there all day earns, not the new coins.
let s = self.data.accounts.entry(key).or_default();
s.amount += amount;
self.dirty = true;
vec![Out::Reply(format!(
"Saved {}: {} in the bank. It earns from tomorrow's payment on.",
economy.money(amount),
economy.money(s.amount)
))]
}
fn withdraw(&mut self, economy: &mut Economy, me: &Player, word: &str) -> Vec<Out> {
let key = uuid_string(me.uuid);
let saved = self.saved(me.uuid);
if saved == 0 {
return vec![Out::Error("You have nothing in the bank.".into())];
}
let Some(amount) = Self::amount(word, saved) else {
return vec![Out::Error(if saved == 0 {
"You have nothing in the bank.".into()
} else {
"The amount must be a positive whole number, or all.".into()
})];
};
if amount > saved {
return vec![Out::Error(format!(
"You have {} in the bank.",
economy.money(saved)
))];
}
if let Err(e) = economy.from_pool("bank_withdraw", POOL, me.uuid, amount, "") {
return vec![Out::Error(e)];
}
let s = self.data.accounts.get_mut(&key).expect("saved > 0");
s.amount -= amount;
s.low = s.low.min(s.amount);
let left = s.amount;
if left == 0 && s.earned == 0 {
self.data.accounts.remove(&key);
}
self.dirty = true;
vec![Out::Reply(format!(
"Took {} out: {} left in the bank.",
economy.money(amount),
economy.money(left)
))]
}
/// Once a second from the glue; the work once a day: interest from the
/// Treasury into each account (it stays saved, and earns from then on).
pub fn tick(&mut self, economy: &mut Economy, now: i64) -> Vec<Out> {
let today = now.div_euclid(DAY_MS);
if today <= self.data.day {
return Vec::new();
}
self.data.day = today;
self.dirty = true;
let bp = self.config.daily_basis_points.max(0);
let due: Vec<(String, i64)> = self
.data
.accounts
.iter()
.map(|(k, s)| {
(
k.clone(),
(s.low.clamp(0, self.config.max_interest_on) as i128 * bp as i128 / 10_000)
as i64,
)
})
.collect();
let total: i64 = due.iter().map(|d| d.1).sum();
let cap = economy.pool(TREASURY).max(0) as i128
* self.config.max_treasury_percent.clamp(0, 100) as i128
/ 100;
let cut = i128::from(total) > cap;
let mut paid = 0;
let mut out = Vec::new();
for (key, d) in due {
let pay = if cut {
(i128::from(d) * cap / i128::from(total.max(1))) as i64
} else {
d
};
let pay = pay.min(MAX_AMOUNT);
if pay > 0
&& economy
.pool_move("bank_interest", TREASURY, POOL, pay, "")
.is_ok()
{
paid += pay;
let s = self.data.accounts.get_mut(&key).expect("listed");
s.amount += pay;
s.earned += pay;
if let Some(u) = parse_uuid(&key) {
out.push(Out::Tell(
u,
format!(
"Bank interest: {} (saved: {}).",
economy.money(pay),
economy.money(s.amount)
),
));
}
}
}
// From now on, what's there all day earns.
for s in self.data.accounts.values_mut() {
s.low = s.amount;
}
self.data.paid += paid;
self.data.last = paid;
self.data.last_cut = cut;
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::smp_economy::Config as EcoConfig;
const ALEX: u128 = 1;
const ALT: u128 = 2;
const T0: i64 = 1_793_577_600_000; // a midnight, UTC
fn me(uuid: u128) -> Player {
Player {
uuid,
name: format!("P{uuid}"),
level: 0,
}
}
fn setup(name: &str, treasury: i64) -> (Economy, Bank, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("smp-bank-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut e = Economy::open(&dir, EcoConfig::default()).unwrap();
e.now = T0 + 1000;
for u in [ALEX, ALT] {
e.seen(u, &format!("P{u}"));
e.adjust("sell", u, 50_000, "").unwrap();
}
if treasury > 0 {
e.to_pool("seed", ALT, TREASURY, treasury, "").unwrap();
}
let b = Bank::open(&e, Config::default());
(e, b, dir)
}
fn total(e: &Economy) -> i64 {
e.balance(ALEX) + e.balance(ALT) + e.pool(TREASURY) + e.pool(POOL)
}
fn say(b: &mut Bank, e: &mut Economy, who: u128, line: &str) -> Vec<Out> {
b.command(e, &me(who), line).unwrap()
}
#[test]
fn a_deposit_earns_from_the_next_full_day_and_interest_comes_from_the_treasury() {
let (mut e, mut b, _d) = setup("earn", 10_000);
let start = total(&e);
say(&mut b, &mut e, ALEX, "bank deposit 5000");
assert_eq!((e.balance(ALEX), e.pool(POOL)), (45_000, 5_000));
// The first midnight: deposited during the day, nothing earned.
assert!(b.tick(&mut e, T0 + DAY_MS).is_empty());
// The next: 0.1% of 5,000.
let out = b.tick(&mut e, T0 + 2 * DAY_MS);
assert_eq!(
out,
[Out::Tell(
ALEX,
"Bank interest: 5 coins (saved: 5,005 coins).".into()
)]
);
assert_eq!((e.pool(TREASURY), b.saved(ALEX)), (9_995, 5_005));
assert!(
b.tick(&mut e, T0 + 2 * DAY_MS + 5_000).is_empty(),
"once a day"
);
assert_eq!(total(&e), start, "nothing minted");
}
#[test]
fn deposit_before_midnight_and_withdraw_after_earns_nothing() {
let (mut e, mut b, _d) = setup("game", 10_000);
b.tick(&mut e, T0 + DAY_MS);
say(&mut b, &mut e, ALEX, "bank deposit 10000");
b.tick(&mut e, T0 + 2 * DAY_MS); // deposited today: low was 0
say(&mut b, &mut e, ALEX, "bank withdraw all");
say(&mut b, &mut e, ALEX, "bank deposit 10000");
// Took it out during the day: the low was 0 again.
assert!(b.tick(&mut e, T0 + 3 * DAY_MS).is_empty());
assert_eq!(e.balance(ALEX) + b.saved(ALEX), 50_000);
}
#[test]
fn caps_hold_and_a_poor_treasury_pays_pro_rata() {
let (mut e, mut b, _d) = setup("caps", 100);
let err = |o: Vec<Out>| match &o[0] {
Out::Error(s) => s.clone(),
o => panic!("{o:?}"),
};
assert!(
err(say(&mut b, &mut e, ALEX, "bank deposit 30000"))
.starts_with("You may save 25,000 coins more")
);
assert!(err(say(&mut b, &mut e, ALEX, "bank withdraw 1")).starts_with("You have nothing"));
say(&mut b, &mut e, ALEX, "bank deposit all");
assert_eq!(b.saved(ALEX), 25_000, "all = up to the cap");
say(&mut b, &mut e, ALT, "bank deposit 10000");
b.tick(&mut e, T0 + DAY_MS);
// Due: 10 each (only 10,000 earns); the Treasury (100) pays 5% = 5 in all.
b.tick(&mut e, T0 + 2 * DAY_MS);
assert_eq!((b.data.last, b.data.last_cut), (4, true));
assert_eq!(e.pool(TREASURY), 96);
// An empty Treasury pays nothing; nobody goes below zero.
let (mut e, mut b, _d) = setup("empty", 0);
say(&mut b, &mut e, ALEX, "bank deposit 5000");
b.tick(&mut e, T0 + DAY_MS);
assert!(b.tick(&mut e, T0 + 2 * DAY_MS).is_empty());
assert_eq!(b.saved(ALEX), 5_000);
}
#[test]
fn savings_survive_a_restart_with_the_money() {
let (mut e, mut b, dir) = setup("restart", 1_000);
say(&mut b, &mut e, ALEX, "bank deposit 700");
b.store(&mut e);
e.save().unwrap();
let e2 = Economy::open(&dir, EcoConfig::default()).unwrap();
let b2 = Bank::open(&e2, Config::default());
assert_eq!((b2.saved(ALEX), e2.pool(POOL)), (700, 700));
assert_eq!(b2.savings()[&ALEX], 700);
}
}
+58 -90
View File
@@ -1,10 +1,10 @@
// The SMP's screens: a scoreboard sidebar, the player's own block first (their
// name or nickname, balance, playtime, the players on), then the three
// richest and the server's address, which a player hides with `/sidebar off`
// (kept in `board.json`); and the Tab list's header and footer (the server's
// name, its TPS and the players on). Nothing happens per tick: the ranking is
// built once for everyone when a balance changed (at most once each
// `min_seconds`) or every `refresh_seconds`; the players' own lines are looked
// name or nickname, balance, playtime, the players on), then the server's
// address, which a player hides with `/sidebar off` (kept in `board.json`);
// and the Tab list's header and footer (the server's name, its TPS and the
// players on). Nothing happens per tick: the balances are read once for
// everyone when one changed (at most once each `min_seconds`) or every
// `refresh_seconds`; the players' own lines are looked
// at on the same `refresh_seconds`, and a sidebar is sent only to the players
// whose lines changed and to newcomers. The Tab list goes out every
// `tab_refresh_seconds`, and to a player who just joined. No game types. Added to the game as `game::smp_board`;
@@ -16,10 +16,6 @@ use serde::{Deserialize, Serialize};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};
/// The most of the richest a sidebar lists: vanilla's 15 lines, less the
/// player's four, the two gaps, the heading and the address.
const MAX_TOP: usize = 7;
/// `config.json`'s `board` section.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -27,11 +23,9 @@ pub struct Config {
/// The sidebar (each player may hide it).
pub sidebar: bool,
pub title: String,
/// How many of the richest players it lists (at most 7; 0: none).
pub top: usize,
/// Its last line, the server's address ("": none).
pub footer: String,
/// The ranking is built again, and the players' own lines looked at,
/// The balances are read again, and the players' own lines looked at,
/// at least this often.
pub refresh_seconds: i64,
/// After a balance changed, at most this often.
@@ -50,7 +44,6 @@ impl Default for Config {
Self {
sidebar: true,
title: "Horizon SMP".into(),
top: 3,
footer: "play.playhorizon.dev".into(),
refresh_seconds: 60,
min_seconds: 5,
@@ -63,19 +56,14 @@ impl Default for Config {
}
}
/// SMP 0.13's sidebar defaults (the richest ten), moved to 0.14's when a
/// server never changed them. True when something changed.
/// SMP 0.13's sidebar title (`Richest players`), moved to today's when a
/// server never changed it. True when something changed.
pub fn migrate(config: &mut Config) -> bool {
let mut changed = false;
if config.title == "Richest players" {
config.title = Config::default().title;
changed = true;
return true;
}
if config.top == 10 {
config.top = Config::default().top;
changed = true;
}
changed
false
}
/// `board.json`.
@@ -95,16 +83,6 @@ pub enum Line {
Stat { label: &'static str, value: String },
/// An empty line.
Gap,
/// The heading over the richest.
Heading,
/// One of the richest: their rank, name and balance; `me` on the
/// player's own line.
Top {
rank: usize,
name: String,
amount: String,
me: bool,
},
/// The server's address.
Footer(String),
}
@@ -131,10 +109,9 @@ pub struct Board {
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.
/// Every account's balance.
balances: HashMap<u128, i64>,
/// When the balances were read, and at which economy revision.
built_at: Option<i64>,
built_revision: u64,
/// What each player on was last sent.
@@ -145,6 +122,8 @@ pub struct Board {
personal_at: Option<i64>,
tab_at: Option<i64>,
tab_sent: HashSet<u128>,
/// The vanished players (the glue sets it): on, but not counted online.
pub quiet: HashSet<u128>,
}
/// `1,250`, `125.4k`, `3.2M`: short enough for the sidebar's right side.
@@ -180,8 +159,7 @@ impl Board {
path,
data,
changed: false,
ranked: Vec::new(),
rank_of: HashMap::new(),
balances: HashMap::new(),
built_at: None,
built_revision: 0,
sent: HashMap::new(),
@@ -189,6 +167,7 @@ impl Board {
personal_at: None,
tab_at: None,
tab_sent: HashSet::new(),
quiet: HashSet::new(),
})
}
@@ -200,11 +179,19 @@ impl Board {
Ok(())
}
/// Forgets what each player was sent: everyone gets theirs again at the
/// next update (the sidebars were taken away).
pub fn forget(&mut self) {
self.sent.clear();
self.tab_sent.clear();
self.stale = true;
}
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
/// Whether the balances should be read 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 {
@@ -218,27 +205,17 @@ impl Board {
|| 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;
/// The balances from the accounts (UUID, name, balance).
pub fn rebuild(&mut self, accounts: Vec<(u128, String, i64)>, now: i64, revision: u64) {
self.balances = accounts.into_iter().map(|(u, _, b)| (u, b)).collect();
self.built_at = Some(now);
self.built_revision = revision;
self.stale = true;
}
/// `uuid`'s sidebar: their own block, the richest from the ranking, the
/// address.
/// `uuid`'s sidebar: their own block, then the address.
pub fn lines(&self, uuid: u128, me: &Me, online: usize) -> Vec<Line> {
let balance = self
.rank_of
.get(&uuid)
.map_or(0, |&rank| self.ranked[rank - 1].2);
let balance = self.balances.get(&uuid).copied().unwrap_or(0);
let mut lines = vec![
Line::Name(me.name.clone()),
Line::Stat {
@@ -254,19 +231,6 @@ impl Board {
value: online.to_string(),
},
];
let top = self.config.top.min(MAX_TOP);
if top > 0 && !self.ranked.is_empty() {
lines.push(Line::Gap);
lines.push(Line::Heading);
lines.extend(self.ranked.iter().take(top).enumerate().map(
|(i, (u, name, balance))| Line::Top {
rank: i + 1,
name: name.clone(),
amount: compact(*balance),
me: *u == uuid,
},
));
}
if !self.config.footer.is_empty() {
lines.push(Line::Gap);
lines.push(Line::Footer(self.config.footer.clone()));
@@ -297,11 +261,12 @@ impl Board {
}
let all = std::mem::take(&mut self.stale) || personal;
let mut out = Vec::new();
let online = on.iter().filter(|u| !self.quiet.contains(u)).count();
for &uuid in on {
if self.hidden(uuid) || (!all && self.sent.contains_key(&uuid)) {
continue;
}
let lines = self.lines(uuid, &me(uuid), on.len());
let lines = self.lines(uuid, &me(uuid), online);
if self.sent.get(&uuid) != Some(&lines) {
self.sent.insert(uuid, lines.clone());
out.push((uuid, lines));
@@ -429,7 +394,7 @@ mod tests {
}
#[test]
fn the_sidebar_shows_the_player_then_the_three_richest_then_the_address() {
fn the_sidebar_shows_the_player_then_the_address_and_no_ranking() {
let d = dir();
let mut b = Board::open(&d, Config::default()).unwrap();
assert!(b.due(0, 0));
@@ -442,12 +407,6 @@ mod tests {
label,
value: value.into(),
};
let top = |rank, name: &str, amount: &str, me| Line::Top {
rank,
name: name.into(),
amount: amount.into(),
me,
};
assert_eq!(
b.lines(11, &rocky, 42),
vec![
@@ -456,27 +415,20 @@ mod tests {
stat("Playtime", "2h 05m"),
stat("Online", "42"),
Line::Gap,
Line::Heading,
top(1, "P12", "1,200", false),
top(2, "P11", "1,100", true),
top(3, "P10", "1,000", false),
Line::Gap,
Line::Footer("play.playhorizon.dev".into()),
]
);
// A player with no account yet; a server with no address and no top.
// A player with no account yet; a server with no address.
assert_eq!(b.lines(99, &rocky, 1)[1], stat("Balance", "0"));
b.config.footer.clear();
b.config.top = 0;
assert_eq!(b.lines(99, &rocky, 1).len(), 4);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn the_old_defaults_move_to_the_new_ones_and_set_ones_stay() {
fn the_old_title_moves_to_the_new_one_and_a_set_one_stays() {
let mut old = Config {
title: "Richest players".into(),
top: 10,
..Config::default()
};
assert!(migrate(&mut old));
@@ -484,15 +436,21 @@ mod tests {
assert!(!migrate(&mut old));
let mut own = Config {
title: "My server".into(),
top: 5,
..Config::default()
};
assert!(!migrate(&mut own));
assert_eq!((own.title.as_str(), own.top), ("My server", 5));
assert_eq!(own.title, "My server");
}
#[test]
fn sidebars_go_out_on_join_on_a_new_ranking_and_on_the_refresh() {
fn an_old_config_with_a_top_still_reads() {
let old = serde_json::json!({ "title": "Horizon SMP", "top": 3 });
let c: Config = serde_json::from_value(old).unwrap();
assert_eq!(c, Config::default());
}
#[test]
fn sidebars_go_out_on_join_on_a_balance_change_and_on_the_refresh() {
let d = dir();
let mut b = Board::open(&d, Config::default()).unwrap();
b.rebuild(accounts(3), 0, 1);
@@ -511,11 +469,12 @@ mod tests {
assert!(b.due(5_000, 2));
b.rebuild(accounts(3), 5_000, 2);
assert!(b.updates(5_000, &[1, 2], me).is_empty(), "same lines");
// Player 1 gets richer: everyone's top changes.
// Player 1 gets richer: only their own sidebar changes.
let mut a = accounts(3);
a[0].2 = 1_000;
b.rebuild(a, 10_000, 3);
assert_eq!(b.updates(10_000, &[1, 2], me).len(), 2);
let sent = b.updates(10_000, &[1, 2], me);
assert_eq!(sent.iter().map(|s| s.0).collect::<Vec<_>>(), vec![1]);
// A newcomer gets theirs at once, and only they are looked at.
asked.set(0);
assert_eq!(b.updates(11_000, &[1, 2, 3], me).len(), 1);
@@ -529,6 +488,15 @@ mod tests {
let sent = b.updates(60_000, &[1, 2, 3], me);
assert_eq!(sent.iter().map(|s| s.0).collect::<Vec<_>>(), vec![2]);
assert!(b.due(70_000, 3), "a minute passed");
// A vanished player isn't counted, at the next refresh.
b.quiet.insert(3);
let sent = b.updates(120_000, &[1, 2, 3], me);
assert_eq!(sent.len(), 3, "everyone's count changed");
let online = Line::Stat {
label: "Online",
value: "2".into(),
};
assert!(sent[0].1.contains(&online), "{:?}", sent[0].1);
let _ = std::fs::remove_dir_all(d);
}
+53 -58
View File
@@ -9,9 +9,9 @@
// each player staked and got back today, the day's biggest wins, each game's
// totals) and the lobby menu. Winnings are paid from the casino's own pot
// (the economy's "casino" pool, which every lost bet fills); a win the pot
// can't cover is paid up to what it holds, never with new money. Once an
// hour the pot above its float goes to the Treasury: the house edge is the
// state's income, a money sink for the players. Each game is a file of its
// can't cover is paid up to what it holds. A new casino's pot gets its
// float once, made for it; then it keeps the house edge, a money sink for
// the players. Each game is a file of its
// own (smp_casino_roulette, ...). No game types: the glue (casino_glue.rs)
// turns the icons and effects into menus, packets and commands. Added to the
// game as `game::smp_casino`; also a module of this crate, for its tests.
@@ -20,7 +20,7 @@ use super::smp_casino_blackjack::{self as bj, Blackjack};
use super::smp_casino_crash::{self as crash, Crash};
use super::smp_casino_roulette::{self as roulette, Roulette};
use super::smp_casino_slots::{self as slots, Slots};
use super::smp_economy::{Economy, Out, Player, TREASURY};
use super::smp_economy::{Economy, Out, Player};
use super::smp_store::{read_json, uuid_string, write_json};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
@@ -34,7 +34,6 @@ pub const SIZE: usize = 54;
/// The least time between two pictures of an animated menu: 5 a second.
pub const FRAME_MS: i64 = 200;
const DAY_MS: i64 = 86_400_000;
const HOUR_MS: i64 = 3_600_000;
/// Wins kept in the day's list.
const WINS: usize = 10;
@@ -53,7 +52,7 @@ pub struct Config {
pub daily_win_cap: i64,
/// Between the end of a player's round and their next bet.
pub cooldown_ms: i64,
/// What the pot keeps for paying wins; above it, the Treasury's.
/// What a new casino's pot starts with, for paying wins (made once).
pub float: i64,
/// A win of at least this profit is told to everyone.
pub announce_profit: i64,
@@ -398,10 +397,13 @@ pub struct Ledger {
/// The day's biggest wins (by profit), biggest first.
pub wins: Vec<Win>,
pub totals: BTreeMap<String, Totals>,
/// When the pot last sent its profit to the Treasury (ms).
pub last_sweep: i64,
}
/// The mark, in `economy.json`'s `ext`, that the pot got its float: kept with
/// the pot's money so one write saves both, and a lost or unreadable
/// `casino.json` can't make the float again.
const FLOATED: &str = "casino_float";
/// A bet placed and held, for a game to decide.
#[derive(Clone, Debug, PartialEq)]
pub struct Stake {
@@ -710,31 +712,21 @@ impl Casino {
fx
}
/// Once an hour: the pot above its float goes to the Treasury; below
/// it, the Treasury tops it up with what it can spare. Never new money.
pub fn sweep(&mut self, economy: &mut Economy, now: i64) -> Option<String> {
if now - self.ledger.last_sweep < HOUR_MS {
/// Once, ever: a new casino's pot gets its float (what it lacks of
/// it), made for it. A world whose pot already holds money (from before
/// 0.30) gets only what it lacks.
pub fn seed(&mut self, economy: &mut Economy) -> Option<String> {
if economy.ext(FLOATED) == Some(&serde_json::Value::Bool(true)) {
return None;
}
self.ledger.last_sweep = now;
self.dirty = true;
let (pot, float) = (economy.pool(POT), self.config.float.max(0));
let result = if pot > float {
economy.pool_move(
"casino_profit",
POT,
TREASURY,
pot - float,
"the casino's pot above its float",
)
} else {
let top_up = (float - pot).min(economy.pool(TREASURY));
if top_up <= 0 {
return None;
}
economy.pool_move("casino_float", TREASURY, POT, top_up, "the casino's float")
};
result.err()
economy.set_ext(FLOATED, serde_json::Value::Bool(true));
let lacks = self.config.float.max(0) - economy.pool(POT);
if lacks <= 0 {
return None;
}
economy
.mint_pool("casino_float", POT, lacks, "the casino's float")
.err()
}
/// The day's biggest wins, as lines.
@@ -823,7 +815,7 @@ impl Casino {
),
"Every draw is made by the server (ChaCha20 seeded by the system), before the animation starts.".into(),
format!(
"Wins are paid from the casino's pot ({} now); a win larger than the pot is paid up to it. The house's profit goes to the Treasury.",
"Wins are paid from the casino's pot ({} now); a win larger than the pot is paid up to it. The house's profit stays in it.",
economy.money(economy.pool(POT))
),
];
@@ -1499,7 +1491,7 @@ impl Casino {
}
/// Each tick while something moves (`animating`), and once a second:
/// the frames due, the rounds that end, the hourly sweep.
/// the frames due, the rounds that end, the pot's float (once).
pub fn tick(&mut self, economy: &mut Economy, now: i64) -> Vec<Fx> {
let mut fx = Vec::new();
for (spin, n) in self.roulette.ended(now) {
@@ -1569,9 +1561,9 @@ impl Casino {
});
}
}
if let Some(e) = self.sweep(economy, now) {
if let Some(e) = self.seed(economy) {
fx.push(Fx::Out(Out::All(format!(
"[casino] the pot's sweep failed: {e}"
"[casino] the pot's float failed: {e}"
))));
}
fx
@@ -1662,20 +1654,14 @@ pub(crate) mod tests {
}
let (mut c, problem) = Casino::open(&dir, Config::default(), true);
assert!(problem.is_none());
// Draws the same every run; no sweep unless asked.
// Draws the same every run; the pot as set up.
c.rng = Rng::from_seed([7; 32]);
c.ledger.last_sweep = i64::MAX / 2;
e.set_ext(FLOATED, serde_json::Value::Bool(true));
(e, c, dir)
}
pub fn money(e: &Economy) -> i64 {
e.balance(A)
+ e.balance(B)
+ e.balance(99)
+ e.held(A)
+ e.held(B)
+ e.pool(POT)
+ e.pool(TREASURY)
e.balance(A) + e.balance(B) + e.balance(99) + e.held(A) + e.held(B) + e.pool(POT)
}
#[test]
@@ -1902,20 +1888,29 @@ pub(crate) mod tests {
}
#[test]
fn the_sweep_sends_the_profit_to_the_treasury_and_never_mints() {
let (mut e, mut c, dir) = setup("sweep", 25_000);
let before = money(&e);
c.ledger.last_sweep = 0;
assert_eq!(c.sweep(&mut e, HOUR_MS), None);
assert_eq!((e.pool(POT), e.pool(TREASURY)), (20_000, 5_000));
// Not again within the hour.
e.pool_move("test", POT, TREASURY, 18_000, "test").unwrap();
c.sweep(&mut e, HOUR_MS + 1);
assert_eq!(e.pool(POT), 2000);
// An hour later the Treasury tops the float up with what it holds.
c.sweep(&mut e, 2 * HOUR_MS);
assert_eq!((e.pool(POT), e.pool(TREASURY)), (20_000, 5_000));
assert_eq!(money(&e), before);
fn a_new_pot_gets_its_float_once_and_keeps_the_house_edge() {
let (mut e, mut c, dir) = setup("seed", 0);
e.set_ext(FLOATED, serde_json::Value::Bool(false));
assert_eq!(c.seed(&mut e), None);
assert_eq!(e.pool(POT), 20_000);
assert_eq!(e.flows().minted["casino_float"], 20_000);
// Never again, however low the pot runs.
e.pool_move("test", POT, "elsewhere", 19_000, "test")
.unwrap();
assert_eq!(c.seed(&mut e), None);
assert_eq!(e.pool(POT), 1000);
// Nor after a restart that lost casino.json: the mark is the economy's.
e.save().unwrap();
let mut e = Economy::open(&dir, EconomyConfig::default()).unwrap();
std::fs::remove_file(dir.join("casino.json")).ok();
let (mut c, _) = Casino::open(&dir, Config::default(), true);
assert_eq!(c.seed(&mut e), None);
assert_eq!(e.pool(POT), 1000, "no second float");
// An old pot with money gets only what it lacks.
let (mut e, mut c, _) = setup("seed-old", 25_000);
e.set_ext(FLOATED, serde_json::Value::Bool(false));
c.seed(&mut e);
assert_eq!(e.pool(POT), 25_000, "above the float: nothing made");
std::fs::remove_dir_all(dir).unwrap();
}
+93 -9
View File
@@ -25,6 +25,9 @@ pub struct Config {
pub chunks: Vec<[i64; 2]>,
/// The entities only the owner and trusted players may hit in a claim.
pub protected_entities: Vec<String>,
/// Blocks around 0 0, in every dimension, where no one may claim (free
/// for all); 0 for none. Claims already there are dropped at start.
pub free_radius: i64,
}
impl Default for Config {
@@ -36,6 +39,7 @@ impl Default for Config {
.iter()
.map(|&e| format!("minecraft:{e}"))
.collect(),
free_radius: 0,
}
}
}
@@ -183,6 +187,43 @@ impl Claims {
})
}
/// Whether a chunk touches the free zone around 0 0 (any dimension).
fn in_free_zone(&self, cx: i32, cz: i32) -> bool {
let r = self.config.free_radius;
let near = |c: i32| i64::from(c) * 16 <= r && i64::from(c) * 16 + 15 >= -r;
r > 0 && near(cx) && near(cz)
}
/// Drops the claims inside the free zone (a radius set after they were
/// made): one line each, for the log.
pub fn clear_free_zone(&mut self) -> Vec<String> {
let inside: Vec<(String, i32, i32, u128)> = self
.owners
.iter()
.flat_map(|(d, c)| {
c.iter()
.filter(|((x, z), _)| self.in_free_zone(*x, *z))
.map(|((x, z), u)| (d.clone(), *x, *z, *u))
})
.collect();
let mut lines = Vec::new();
for (dimension, cx, cz, owner) in inside {
if let Some(c) = self.owners.get_mut(&dimension) {
c.remove(&(cx, cz));
}
self.data.chunks.remove(&key(&dimension, cx, cz));
self.changed = true;
lines.push(format!(
"chunk {cx} {cz} (x {}, z {}) in {dimension} of {} is in the free zone: unclaimed",
cx * 16,
cz * 16,
smp_store::uuid_string(owner)
));
}
lines.sort();
lines
}
pub fn save(&mut self) -> Result<(), String> {
if self.changed {
smp_store::write_json(&self.path, &self.data)?;
@@ -271,15 +312,6 @@ impl Claims {
Err(Some(format!("This land is claimed by {}.", name_of(owner))))
}
/// How many chunks each owner holds (the weekly land tax), one pass.
pub fn counts(&self) -> std::collections::HashMap<u128, usize> {
let mut out = std::collections::HashMap::new();
for &u in self.owners.values().flat_map(|c| c.values()) {
*out.entry(u).or_default() += 1;
}
out
}
/// How many chunks `uuid` holds.
pub fn held(&self, uuid: u128) -> usize {
self.owners
@@ -334,6 +366,10 @@ impl Claims {
("claim", []) => match self.chunk_owner(here, cx, cz) {
Some(u) if u == me.uuid => vec![Out::Error("This chunk is already yours.".into())],
Some(u) => vec![Out::Error(format!("This chunk is {}'s.", name_of(u)))],
None if self.in_free_zone(cx, cz) => vec![Out::Error(format!(
"No claims within {} blocks of 0 0: that land is free for all.",
self.config.free_radius
))],
None => {
let (held, limit) = (self.held(me.uuid), self.limit(played));
if held >= limit {
@@ -513,6 +549,54 @@ mod tests {
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn no_claims_in_the_free_zone_and_old_ones_are_dropped() {
let d = dir();
let mut c = Claims::open(&d, Config::default()).unwrap();
let alex = player(A, 0);
for (x, z) in [(8.0, 8.0), (-520.0, 8.0), (600.0, 600.0)] {
c.command(&alex, &at(x, z), 1_000_000, &find, &name_of, "claim")
.unwrap();
}
c.save().unwrap();
let config = Config {
free_radius: 500,
..Config::default()
};
let mut c = Claims::open(&d, config).unwrap();
// Chunk 0 0 and chunk -33 0 (x -528 to -513: outside 500) stay apart.
assert_eq!(c.clear_free_zone().len(), 1);
let ow = "minecraft:overworld";
assert_eq!(c.owner(ow, 8, 8), None);
assert_eq!(c.owner(ow, -520, 8), Some(A));
assert_eq!(c.owner(ow, 600, 600), Some(A));
let out = c
.command(
&alex,
&at(490.0, -490.0),
1_000_000,
&find,
&name_of,
"claim",
)
.unwrap();
assert!(matches!(&out[0], Out::Error(e) if e.contains("free for all")));
// The zone is the same in every dimension.
let nether = Place {
dimension: "minecraft:the_nether".into(),
..at(8.0, 8.0)
};
let out = c
.command(&alex, &nether, 1_000_000, &find, &name_of, "claim")
.unwrap();
assert!(matches!(&out[0], Out::Error(e) if e.contains("free for all")));
// Saved without them.
c.save().unwrap();
let again = Claims::open(&d, Config::default()).unwrap();
assert_eq!(again.owner(ow, 8, 8), None);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn the_world_stays_out_of_a_claim() {
let d = dir();
+274 -233
View File
@@ -42,46 +42,6 @@ pub struct Account {
pub balance: i64,
}
/// What the Treasury paid or got, for `/treasury log`.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Movement {
pub time: i64,
/// `vat`, `land_tax`, `grant`...
pub kind: String,
/// Positive: income; negative: spending.
pub amount: i64,
/// The player's name last seen.
pub player: String,
/// Why, as the operator wrote it (payments); empty for taxes.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub reason: String,
/// The operator who paid it (name).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub by: String,
}
/// The Treasury: the state's account. Every tax, fee and house edge goes in;
/// operators' grants, refunds and prizes (and later bank interest and
/// contracts) come out, never more than it holds. Nobody can make money.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Treasury {
/// Since the Treasury opened, by kind.
pub income: BTreeMap<String, i64>,
pub spent: BTreeMap<String, i64>,
/// Today's (UTC day number), by kind.
pub day: i64,
pub income_today: BTreeMap<String, i64>,
pub spent_today: BTreeMap<String, i64>,
/// The day before `day`'s, kept for the daily report.
pub income_yesterday: BTreeMap<String, i64>,
pub spent_yesterday: BTreeMap<String, i64>,
/// The latest movements, newest last (at most `LOG`).
pub log: Vec<Movement>,
}
/// Movements `/treasury log` keeps.
pub const LOG: usize = 50;
/// Milliseconds in a day.
pub const DAY_MS: i64 = 86_400_000;
@@ -100,16 +60,14 @@ pub struct Accounts {
/// balances, so one write moves both and a crash can't split them.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub ext: BTreeMap<String, serde_json::Value>,
/// System pools, by name: the Treasury (`TREASURY`), its insurance
/// fund (`INSURANCE`), the casino's pot. Money in a pool belongs to no
/// player: never in `accounts()`, `/baltop` or the sidebar.
/// System pools, by name: the casino's pot, the auction house's bids.
/// Money in a pool belongs to no player: never in `accounts()`,
/// `/baltop` or the sidebar.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub pools: BTreeMap<String, i64>,
/// What went into and out of the Treasury pool, for `/treasury`.
#[serde(default)]
pub treasury: Treasury,
/// Money made and destroyed by the server (`adjust`: market sales and
/// purchases, the daily reward), by kind, for `/eco stats`.
/// purchases, the daily reward, operators' `/eco give` and `/eco take`),
/// by kind, for `/eco stats`.
#[serde(default)]
pub flows: Flows,
}
@@ -144,10 +102,10 @@ impl Flows {
}
}
/// The state's pool: taxes, fees and house edges in; grants out.
pub const TREASURY: &str = "treasury";
/// Part of the state: covers what a liquidated position can't.
pub const INSURANCE: &str = "insurance";
/// What 0.30 took out (the Treasury, its insurance fund, the bank, the
/// futures, the jobs, the taxes): their pools and their state in `ext`.
const RETIRED_POOLS: [&str; 4] = ["treasury", "insurance", "bank", "futures"];
const RETIRED_PARTS: [&str; 4] = ["bank", "futures", "jobs", "tax"];
/// A player as a command sees them.
#[derive(Clone, Debug, PartialEq)]
@@ -198,6 +156,7 @@ impl Economy {
revision: 0,
now: 0,
};
economy.retire()?;
// Games the last run left undecided: their stakes go back.
let held: Vec<(String, i64)> = economy.accounts.held.clone().into_iter().collect();
for (key, amount) in held {
@@ -208,6 +167,60 @@ impl Economy {
Ok(economy)
}
/// Once, on a world from before 0.30: bank savings and open futures
/// positions' margins go back to their owners (logged `bank_closed`,
/// `futures_closed`), and the retired parts' pools and state are dropped.
fn retire(&mut self) -> Result<(), String> {
let mut back: Vec<(&str, u128, i64)> = Vec::new();
let ext = &self.accounts.ext;
if let Some(accounts) = ext
.get("bank")
.and_then(|b| b.get("accounts"))
.and_then(|a| a.as_object())
{
for (key, saving) in accounts {
let amount = saving.get("amount").and_then(|a| a.as_i64()).unwrap_or(0);
if let Some(u) = super::smp_store::parse_uuid(key) {
back.push(("bank_closed", u, amount));
}
}
}
if let Some(positions) = ext
.get("futures")
.and_then(|f| f.get("positions"))
.and_then(|p| p.as_array())
{
for p in positions {
let owner = p
.get("owner")
.and_then(|o| o.as_str())
.and_then(super::smp_store::parse_uuid);
let margin = p.get("margin").and_then(|m| m.as_i64()).unwrap_or(0);
if let Some(u) = owner {
back.push(("futures_closed", u, margin));
}
}
}
for (kind, uuid, amount) in back.into_iter().filter(|b| b.2 > 0) {
let entry = self.entry(kind, None, Some(uuid), amount, "");
self.audit.append(&entry)?;
let account = self.accounts.accounts.entry(uuid_string(uuid)).or_default();
account.balance = account.balance.saturating_add(amount);
self.touch();
}
for part in RETIRED_PARTS {
if self.accounts.ext.remove(part).is_some() {
self.touch();
}
}
for pool in RETIRED_POOLS {
if self.accounts.pools.remove(pool).is_some() {
self.touch();
}
}
Ok(())
}
fn touch(&mut self) {
self.dirty = true;
self.revision += 1;
@@ -295,30 +308,23 @@ impl Economy {
Ok(())
}
/// A game decided: each stake leaves held, and `winner` gets `prize`
/// (at most the stakes together), logged as `kind`.
/// A game decided: each stake leaves held, and `winner` gets them all,
/// logged as `kind`.
pub fn award(
&mut self,
kind: &str,
stakes: &[(u128, i64)],
winner: u128,
prize: i64,
) -> Result<(), String> {
let total: i64 = stakes.iter().map(|(_, a)| *a).sum();
if stakes.iter().any(|(u, a)| self.held(*u) < *a) || !(0..=total).contains(&prize) {
if stakes.iter().any(|(u, a)| self.held(*u) < *a) {
return Err("The stakes are not held.".into());
}
let fee = total - prize;
let prize: i64 = stakes.iter().map(|(_, a)| *a).sum();
let mut entry = self.entry(kind, None, Some(winner), prize, "");
entry["stakes"] = stakes
.iter()
.map(|(u, a)| serde_json::json!({ "uuid": uuid_string(*u), "amount": a }))
.collect();
if fee > 0 {
// The house's fee goes to the Treasury, not away.
entry["fee"] = fee.into();
entry["to_pool"] = TREASURY.into();
}
self.audit.append(&entry)?;
for (u, a) in stakes {
self.take_held(&uuid_string(*u), *a);
@@ -329,10 +335,6 @@ impl Economy {
.entry(uuid_string(winner))
.or_default();
account.balance = account.balance.saturating_add(prize);
if fee > 0 {
self.pool_add(TREASURY, fee);
self.count_treasury("gambling_fee", fee, Some(winner), "", "");
}
self.touch();
Ok(())
}
@@ -351,11 +353,6 @@ impl Economy {
self.accounts.pools.get(name).copied().unwrap_or(0)
}
/// What went into and out of the Treasury.
pub fn treasury(&self) -> &Treasury {
&self.accounts.treasury
}
fn pool_add(&mut self, name: &str, delta: i64) {
let p = self.accounts.pools.entry(name.to_owned()).or_default();
*p = p.saturating_add(delta);
@@ -364,61 +361,8 @@ impl Economy {
}
}
/// Counts a Treasury movement (`amount` > 0 income, < 0 spending).
fn count_treasury(
&mut self,
kind: &str,
amount: i64,
player: Option<u128>,
reason: &str,
by: &str,
) {
if amount == 0 {
return;
}
let day = self.now.div_euclid(DAY_MS);
let player = player
.and_then(|u| self.name_of(u).map(str::to_owned))
.unwrap_or_default();
let by = super::smp_store::parse_uuid(by)
.and_then(|u| self.name_of(u).map(str::to_owned))
.unwrap_or_else(|| by.to_owned());
let t = &mut self.accounts.treasury;
if t.day != day {
let next = t.day + 1 == day;
t.income_yesterday = std::mem::take(&mut t.income_today);
t.spent_yesterday = std::mem::take(&mut t.spent_today);
if !next {
t.income_yesterday.clear();
t.spent_yesterday.clear();
}
t.day = day;
}
let (all, today) = if amount > 0 {
(&mut t.income, &mut t.income_today)
} else {
(&mut t.spent, &mut t.spent_today)
};
for map in [all, today] {
let v = map.entry(kind.to_owned()).or_default();
*v = v.saturating_add(amount.abs());
}
t.log.push(Movement {
time: self.now,
kind: kind.to_owned(),
amount,
player,
reason: reason.to_owned(),
by,
});
if t.log.len() > LOG {
let extra = t.log.len() - LOG;
t.log.drain(..extra);
}
}
/// A tax, a fee, a stake lost: `amount` of `uuid`'s balance goes into
/// `pool`, logged as `kind`. An Err when they hold less.
/// A bid, a bet: `amount` of `uuid`'s balance goes into `pool`, logged
/// as `kind`. An Err when they hold less.
pub fn to_pool(
&mut self,
kind: &str,
@@ -463,16 +407,13 @@ impl Economy {
.expect("checked")
.balance -= amount;
self.pool_add(pool, amount);
if pool == TREASURY {
self.count_treasury(kind, amount, Some(uuid), reason, by);
}
self.touch();
Ok(())
}
/// A payment from `pool` to `uuid` (a grant, a refund, a payout),
/// A payment from `pool` to `uuid` (a refund, a payout),
/// logged as `kind`: never more than the pool holds.
#[allow(dead_code)] // the casino, the auction house and the futures
#[allow(dead_code)] // the casino and the auction house
pub fn from_pool(
&mut self,
kind: &str,
@@ -521,16 +462,13 @@ impl Economy {
.expect("checked")
.balance += amount;
self.pool_add(pool, -amount);
if pool == TREASURY {
self.count_treasury(kind, -amount, Some(uuid), reason, by);
}
self.touch();
Ok(())
}
/// Moves `amount` from one pool to another (the casino's profit to the
/// Treasury), logged as `kind`; an Err when `from` holds less.
#[allow(dead_code)] // the casino, the auction house and the futures
/// Moves `amount` from one pool to another, logged as `kind`; an Err
/// when `from` holds less.
#[allow(dead_code)] // the casino and the auction house
pub fn pool_move(
&mut self,
kind: &str,
@@ -557,12 +495,29 @@ impl Economy {
self.audit.append(&entry)?;
self.pool_add(from, -amount);
self.pool_add(to, amount);
if to == TREASURY {
self.count_treasury(kind, amount, None, reason, "");
}
if from == TREASURY {
self.count_treasury(kind, -amount, None, reason, "");
self.touch();
Ok(())
}
/// Money made for a pool (the casino's float, once), logged as `kind`
/// and counted as made.
pub fn mint_pool(
&mut self,
kind: &str,
pool: &str,
amount: i64,
reason: &str,
) -> Result<(), String> {
if amount <= 0 || amount > MAX_AMOUNT {
return Err("The amount must be a positive whole number.".into());
}
let entry = serde_json::json!({
"time": self.now, "kind": kind, "amount": amount,
"to_pool": pool, "pool_before": self.pool(pool), "reason": reason,
});
self.audit.append(&entry)?;
self.pool_add(pool, amount);
self.count_flow(kind, amount);
self.touch();
Ok(())
}
@@ -570,7 +525,7 @@ impl Economy {
/// A bet against a pool decided: `uuid`'s held `stake` goes into
/// `pool`, then `payout` (at most what the pool then holds) comes out
/// to them; one log line. What was paid. Never makes money.
#[allow(dead_code)] // the casino, the auction house and the futures
#[allow(dead_code)] // the casino and the auction house
pub fn settle_pool(
&mut self,
kind: &str,
@@ -595,9 +550,6 @@ impl Economy {
self.pool_add(pool, stake - paid);
let account = self.accounts.accounts.entry(key).or_default();
account.balance = account.balance.saturating_add(paid);
if pool == TREASURY {
self.count_treasury(kind, stake - paid, Some(uuid), "", "");
}
self.touch();
Ok(paid)
}
@@ -768,6 +720,18 @@ impl Economy {
/// Adds (or with a negative `delta`, takes) money from the server: the
/// market, games, operators. The balance never goes below zero.
pub fn adjust(&mut self, kind: &str, uuid: u128, delta: i64, by: &str) -> Result<i64, String> {
self.adjust_why(kind, uuid, delta, by, "")
}
/// [`Self::adjust`] with the operator's reason in the log.
pub fn adjust_why(
&mut self,
kind: &str,
uuid: u128,
delta: i64,
by: &str,
reason: &str,
) -> Result<i64, String> {
let key = uuid_string(uuid);
let Some(a) = self.accounts.accounts.get(&key) else {
return Err("No such account.".into());
@@ -784,13 +748,23 @@ impl Economy {
} else {
(None, Some(uuid))
};
let entry = self.entry(kind, from, to, delta.abs(), by);
let mut entry = self.entry(kind, from, to, delta.abs(), by);
if !reason.is_empty() {
entry["reason"] = reason.into();
}
self.audit.append(&entry)?;
self.accounts
.accounts
.get_mut(&key)
.expect("checked")
.balance = after;
self.count_flow(kind, delta);
self.touch();
Ok(after)
}
/// Counts money made (`delta` > 0) or destroyed, by the kind's first word.
fn count_flow(&mut self, kind: &str, delta: i64) {
let flows = &mut self.accounts.flows;
flows.roll(self.now.div_euclid(DAY_MS));
let family = kind.split(' ').next().unwrap_or(kind).to_owned();
@@ -800,8 +774,6 @@ impl Economy {
&mut flows.burned
};
*map.entry(family).or_default() += delta.abs();
self.touch();
Ok(after)
}
/// Appends a line of another kind to the audit log (a trade's items),
@@ -905,41 +877,56 @@ impl Economy {
out
}
/// `/eco take <player> <amount> [reason]`: an operator takes money from
/// a player (a cheat's gains) into the Treasury. Nobody can make money:
/// `give` and `set` say where grants come from now.
/// `/eco give <player> <amount> <reason>` (made for them: an event
/// prize, a bug refund) and `/eco take <player> <amount> [reason]` (a
/// cheat's gains, destroyed): operators of level 3. A gift is told to
/// everyone, with its reason.
fn eco(&mut self, me: &Player, args: &[&str]) -> Vec<Out> {
if me.level < 3 {
return vec![Out::Error("Only operators of level 3 may use /eco.".into())];
}
const USAGE: &str = "Usage: /eco take <player> <amount> [reason]";
match args {
["give" | "set" | "add", ..] => vec![Out::Error(
"Nobody can make money, operators included. Pay from the Treasury: /treasury pay <player> <amount> <reason>"
.into(),
)],
["take", who, amount, reason @ ..] => {
let Some(amount) = parse_amount(amount) else {
return vec![Out::Error(
"The amount must be a positive whole number.".into(),
)];
};
let Some((uuid, account)) = self.find(who) else {
return vec![Out::Error(format!("No player named {who} has an account."))];
};
let name = account.name.clone();
let reason = reason.join(" ");
let by = uuid_string(me.uuid);
match self.into_pool("eco_take", uuid, TREASURY, amount, &reason, &by) {
Ok(()) => vec![Out::Reply(format!(
"Took {} from {name} into the Treasury. They have {}.",
self.money(amount),
self.money(self.balance(uuid))
))],
Err(e) => vec![Out::Error(e)],
}
const USAGE: &str =
"Usage: /eco give <player> <amount> <reason>, /eco take <player> <amount> [reason]";
let (give, who, amount, reason) = match args {
[verb @ ("give" | "take"), who, amount, reason @ ..] => {
(*verb == "give", *who, *amount, reason.join(" "))
}
_ => vec![Out::Error(USAGE.into())],
_ => return vec![Out::Error(USAGE.into())],
};
let Some(amount) = parse_amount(amount) else {
return vec![Out::Error(
"The amount must be a positive whole number.".into(),
)];
};
let reason = reason.trim().to_owned();
if give && reason.chars().count() < 3 {
return vec![Out::Error(
"Say why: /eco give <player> <amount> <reason> (an event prize, a bug refund...)"
.into(),
)];
}
let Some((uuid, account)) = self.find(who) else {
return vec![Out::Error(format!("No player named {who} has an account."))];
};
let name = account.name.clone();
let by = uuid_string(me.uuid);
let (kind, delta) = if give {
("eco_give", amount)
} else {
("eco_take", -amount)
};
match self.adjust_why(kind, uuid, delta, &by, &reason) {
Ok(_) if give => vec![Out::All(format!(
"{} gave {} to {name}: {reason}.",
me.name,
self.money(amount)
))],
Ok(after) => vec![Out::Reply(format!(
"Took {} from {name}. They have {}.",
self.money(amount),
self.money(after)
))],
Err(e) => vec![Out::Error(e)],
}
}
}
@@ -1067,90 +1054,151 @@ mod tests {
}
#[test]
fn nobody_makes_money_and_eco_take_goes_to_the_treasury() {
fn operators_give_with_a_reason_and_take_and_both_are_logged() {
let (mut e, dir) = economy("eco");
let op = player(ALEX, "Alex", 4);
for line in [
"eco give Steve 5",
"money give * 1000",
"eco set Steve 1234567",
"eco add Steve 1",
] {
assert!(
matches!(&e.command(&op, &[], line).unwrap()[0], Out::Error(s) if s.starts_with("Nobody can make money")),
"{line}"
);
}
assert_eq!(
e.command(&player(ALEX, "Alex", 2), &[], "eco take Steve 5")
e.command(&player(ALEX, "Alex", 2), &[], "eco give Steve 5 a prize")
.unwrap(),
[Out::Error("Only operators of level 3 may use /eco.".into())]
);
assert!(
matches!(&e.command(&op, &[], "eco give Steve 5").unwrap()[0], Out::Error(s) if s.starts_with("Say why")),
"a gift needs its reason"
);
assert!(
matches!(&e.command(&op, &[], "eco set Steve 5").unwrap()[0], Out::Error(s) if s.starts_with("Usage"))
);
assert_eq!(
e.command(&op, &[], "money give Steve 50 build contest")
.unwrap(),
[Out::All(
"Alex gave 50 coins to Steve: build contest.".into()
)]
);
assert_eq!(
e.command(&op, &[], "eco take Steve 200").unwrap(),
[Out::Error("Only 100 coins there.".into())]
[Out::Error("Only 150 coins there.".into())]
);
assert_eq!(
e.command(&op, &[], "eco take Steve 40 duped diamonds")
.unwrap(),
[Out::Reply(
"Took 40 coins from Steve into the Treasury. They have 60 coins.".into()
"Took 40 coins from Steve. They have 110 coins.".into()
)]
);
assert_eq!((e.balance(STEVE), e.pool(TREASURY)), (60, 40));
assert_eq!(e.treasury().income["eco_take"], 40);
let m = e.treasury().log.last().unwrap();
assert_eq!(e.balance(STEVE), 110);
let flows = e.flows();
assert_eq!(
(m.player.as_str(), m.reason.as_str(), m.by.as_str()),
("Steve", "duped diamonds", "Alex")
(flows.minted["eco_give"], flows.burned["eco_take"]),
(50, 40)
);
let log = log_lines(&dir);
let last = log.last().unwrap();
let [.., give, take] = &log[..] else {
panic!("{log:?}")
};
assert_eq!(
(last["kind"].as_str(), last["to_pool"].as_str()),
(Some("eco_take"), Some("treasury"))
(
give["kind"].as_str(),
give["reason"].as_str(),
give["amount"].as_i64()
),
(Some("eco_give"), Some("build contest"), Some(50))
);
assert_eq!(last["by"], uuid_string(ALEX));
// Total money (balances + pools) never changed: 2 accounts of 100.
assert_eq!(e.balance(ALEX) + e.balance(STEVE) + e.pool(TREASURY), 200);
assert_eq!(
(
take["kind"].as_str(),
take["reason"].as_str(),
take["from"].as_str()
),
(
Some("eco_take"),
Some("duped diamonds"),
Some(uuid_string(STEVE).as_str())
)
);
assert_eq!(take["by"], uuid_string(ALEX));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn pools_pay_only_what_they_hold_and_survive_a_restart() {
let (mut e, dir) = economy("pools");
e.to_pool("vat", ALEX, TREASURY, 30, "").unwrap();
e.to_pool("ah_bid", ALEX, "ah", 30, "").unwrap();
assert!(
e.to_pool("vat", ALEX, TREASURY, 71, "").is_err(),
e.to_pool("ah_bid", ALEX, "ah", 71, "").is_err(),
"more than Alex has"
);
assert!(
e.from_pool("grant", TREASURY, STEVE, 31, "event").is_err(),
"more than the Treasury holds"
e.from_pool("ah_sale", "ah", STEVE, 31, "").is_err(),
"more than the pool holds"
);
e.from_pool("grant", TREASURY, STEVE, 10, "event prize")
e.from_pool("ah_sale", "ah", STEVE, 10, "").unwrap();
e.pool_move("test", "ah", "casino", 5, "").unwrap();
e.mint_pool("casino_float", "casino", 100, "the casino's float")
.unwrap();
e.pool_move("insurance_fund", TREASURY, INSURANCE, 5, "")
.unwrap();
assert_eq!((e.pool(TREASURY), e.pool(INSURANCE)), (15, 5));
assert_eq!((e.pool("ah"), e.pool("casino")), (15, 105));
assert_eq!((e.balance(ALEX), e.balance(STEVE)), (70, 110));
let t = e.treasury();
assert_eq!(
(t.income["vat"], t.spent["grant"], t.spent["insurance_fund"]),
(30, 10, 5)
);
assert_eq!(t.income_today["vat"], 30);
assert_eq!(e.flows().minted["casino_float"], 100);
// Pools are nobody's: not in the ranking.
assert_eq!(e.accounts().len(), 2);
e.save().unwrap();
let again = Economy::open(&dir, Config::default()).unwrap();
assert_eq!((again.pool(TREASURY), again.pool(INSURANCE)), (15, 5));
assert_eq!(again.treasury().log.len(), 3);
assert_eq!((again.pool("ah"), again.pool("casino")), (15, 105));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn settle_pool_never_pays_more_than_the_pool_and_award_fees_go_to_the_treasury() {
fn a_world_from_before_0_30_gets_its_savings_and_margins_back() {
let dir = std::env::temp_dir().join(format!("smp-eco-retire-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let (a, s) = (uuid_string(ALEX), uuid_string(STEVE));
let old = serde_json::json!({
"accounts": {a.clone(): {"name": "Alex", "balance": 10}, s.clone(): {"name": "Steve", "balance": 20}},
"pools": {"treasury": 5000, "insurance": 300, "bank": 700, "futures": 90, "casino": 40},
"treasury": {"income": {"vat": 12}, "log": []},
"ext": {
"bank": {"accounts": {a.clone(): {"amount": 500, "low": 500, "earned": 3}, s.clone(): {"amount": 200}}},
"futures": {"positions": [{"id": 1, "owner": s.clone(), "name": "Steve", "item": "minecraft:diamond",
"long": true, "size": 1.0, "entry": 200.0, "margin": 90, "leverage": 2, "opened": 0}]},
"jobs": {"day": 3},
"tax": {"week": 2},
"auction": {"listings": []}
}
});
fs::write(dir.join("economy.json"), old.to_string()).unwrap();
let mut e = Economy::open(&dir, Config::default()).unwrap();
assert_eq!((e.balance(ALEX), e.balance(STEVE)), (510, 310));
assert_eq!(
e.pools().keys().collect::<Vec<_>>(),
["casino"],
"the retired pools are gone"
);
assert!(
e.ext("bank").is_none()
&& e.ext("futures").is_none()
&& e.ext("jobs").is_none()
&& e.ext("tax").is_none()
);
assert!(e.ext("auction").is_some(), "the auction house stays");
let kinds: Vec<_> = log_lines(&dir)
.iter()
.map(|l| l["kind"].as_str().unwrap().to_owned())
.collect();
assert_eq!(kinds, ["bank_closed", "bank_closed", "futures_closed"]);
e.save().unwrap();
let saved = fs::read_to_string(dir.join("economy.json")).unwrap();
assert!(!saved.contains("treasury"), "{saved}");
// Only once.
let again = Economy::open(&dir, Config::default()).unwrap();
assert_eq!((again.balance(ALEX), again.balance(STEVE)), (510, 310));
assert_eq!(log_lines(&dir).len(), 3);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn settle_pool_never_pays_more_than_the_pool_and_award_pays_the_stakes() {
let (mut e, dir) = economy("settle");
e.hold("bet", ALEX, 50).unwrap();
// The pool held nothing: a 1,000 payout is capped at the stake.
@@ -1166,21 +1214,14 @@ mod tests {
e.settle_pool("roulette", ALEX, 10, 0, "casino").is_err(),
"nothing held"
);
// A coinflip with a 10% fee: the fee is the Treasury's.
// A coinflip: the winner gets both stakes, no fee.
e.hold("coinflip_stake", ALEX, 50).unwrap();
e.hold("coinflip_stake", STEVE, 50).unwrap();
e.award("coinflip_win", &[(ALEX, 50), (STEVE, 50)], STEVE, 90)
e.award("coinflip_win", &[(ALEX, 50), (STEVE, 50)], STEVE)
.unwrap();
assert_eq!(
(e.balance(ALEX), e.balance(STEVE), e.pool(TREASURY)),
(10, 140, 10)
);
assert_eq!(e.treasury().income["gambling_fee"], 10);
assert_eq!((e.balance(ALEX), e.balance(STEVE)), (10, 150));
// Nothing made: 200 at the start.
assert_eq!(
e.balance(ALEX) + e.balance(STEVE) + e.pool(TREASURY) + e.pool("casino"),
200
);
assert_eq!(e.balance(ALEX) + e.balance(STEVE) + e.pool("casino"), 200);
fs::remove_dir_all(dir).unwrap();
}
File diff suppressed because it is too large. Load diff
+2 -6
View File
@@ -25,8 +25,6 @@ pub struct Config {
pub cooldown_seconds: i64,
/// How long an offer waits before its stake goes back.
pub offer_seconds: i64,
/// What the server keeps of the pot, in percent (0: the winner takes all).
pub fee_percent: i64,
/// The lottery: a ticket's price, the tickets one player may hold in a
/// draw, and the time from a round's first ticket to its draw.
pub ticket_price: i64,
@@ -42,7 +40,6 @@ impl Default for Config {
max_bet: 1000,
cooldown_seconds: 30,
offer_seconds: 60,
fee_percent: 0,
ticket_price: 10,
max_tickets: 100,
lottery_seconds: 3600,
@@ -284,15 +281,14 @@ impl Games {
return vec![Out::Error(e)];
}
let o = self.offers.remove(i);
let pot = amount * 2;
let prize = pot - pot * self.config.fee_percent.clamp(0, 100) / 100;
let prize = amount * 2;
let (winner, loser, winner_name, loser_name) = if heads() {
(o.from, me.uuid, o.from_name.as_str(), me.name.as_str())
} else {
(me.uuid, o.from, me.name.as_str(), o.from_name.as_str())
};
let stakes = [(o.from, amount), (me.uuid, amount)];
if let Err(e) = economy.award("coinflip_win", &stakes, winner, prize) {
if let Err(e) = economy.award("coinflip_win", &stakes, winner) {
// Not decided: both stakes go back (or, failing that, at the next start).
for (u, a) in stakes {
let _ = economy.release("coinflip_cancel", u, a);
+7 -1
View File
@@ -163,7 +163,9 @@ impl<S: Stack> Invsee<S> {
return Some((vec![Out::Error(format!("Usage: /{name} <player>"))], None));
};
match find(who) {
Some((uuid, _)) if uuid == me.uuid => {
// Your own inventory would move stacks into itself; your own
// ender chest is a second container, so an operator may open it.
Some((uuid, _)) if uuid == me.uuid && kind == Kind::Player => {
Some((vec![Out::Error("That is your own inventory.".into())], None))
}
Some((uuid, real)) => Some((Vec::new(), Some((uuid, real, kind)))),
@@ -496,6 +498,10 @@ mod tests {
i.command(&op(), &find, "invsee Boss").unwrap().0,
[Out::Error("That is your own inventory.".into())]
);
assert_eq!(
i.command(&op(), &find, "endersee Boss").unwrap().1,
Some((1, "Boss".into(), Kind::Ender))
);
assert_eq!(
i.command(&op(), &find, "endersee Nobody").unwrap().0,
[Out::Error("No player named Nobody has played here.".into())]
-571
View File
@@ -1,571 +0,0 @@
// The contracts board (`/jobs`): each day at 00:00 UTC the state posts a few
// contracts ("deliver 64 wheat for 160 coins"), paid from the Treasury: the
// taxes go back to players who gather what the server asks for, never new
// money. A delivery takes plain items from the player's inventory (they
// leave the game: a sink for items) and pays at once. Never AFK income: only
// a command with the items in hand pays; each contract has a few deliveries
// for the whole server, and a player makes at most `max_per_player` a day.
//
// Prices: each item pays its market sell price plus `premium_percent`, and
// never more than `ask_percent` of what the market asks for it (checked
// again at delivery, as the market's prices move): buying from the market to
// deliver never pays. One day's contracts together cost at most
// `budget_percent` of the Treasury when posted. The board lives in
// `economy.json` (`ext.jobs`), written with the money. No game types; the
// market's prices come through a closure. Added to the game as
// `game::smp_jobs`; also a module of this crate, for its tests.
use super::smp_economy::{DAY_MS, Economy, Out, Player, TREASURY};
use super::smp_market::{Bag, item_name};
use super::smp_store::uuid_string;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// `config.json`'s `jobs`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub enabled: bool,
pub contracts_per_day: usize,
/// Items in one delivery.
pub batch: u32,
/// Deliveries of one contract for the whole server.
pub deliveries: u32,
/// Deliveries one player makes in a day, all contracts.
pub max_per_player: u32,
/// Over the market's sell price.
pub premium_percent: i64,
/// Of the market's ask (its buy price), at most.
pub ask_percent: i64,
/// The day's contracts cost at most this share of the Treasury.
pub budget_percent: i64,
/// What contracts may ask for (items the market buys).
pub items: Vec<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: true,
contracts_per_day: 4,
batch: 64,
deliveries: 8,
max_per_player: 2,
premium_percent: 30,
ask_percent: 90,
budget_percent: 10,
items: [
"wheat",
"carrot",
"potato",
"beetroot",
"sugar_cane",
"pumpkin",
"melon_slice",
"cactus",
"oak_log",
"spruce_log",
"birch_log",
"cobblestone",
"coal",
"raw_iron",
"raw_copper",
"redstone",
"lapis_lazuli",
"leather",
"string",
"bone",
"feather",
"gunpowder",
"kelp",
"bamboo",
]
.map(|i| format!("minecraft:{i}"))
.into(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Contract {
pub item: String,
pub count: u32,
/// Coins a delivery pays (at most; less if the market's ask fell).
pub pay: i64,
pub left: u32,
}
/// `ext.jobs`.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Board {
pub day: i64,
pub contracts: Vec<Contract>,
/// Deliveries today, by player.
pub done: BTreeMap<String, u32>,
pub paid_today: i64,
pub paid: i64,
}
pub struct Jobs {
pub config: Config,
pub board: Board,
dirty: bool,
}
/// `(sell, buy)`: what the market pays for one item and what it asks; None
/// when it doesn't trade it.
pub type PriceOf<'a> = &'a dyn Fn(&str) -> Option<(i64, i64)>;
/// A number from `day` and `i`, the same on every run (no world random).
fn pick(day: i64, i: u64) -> u64 {
let mut x =
(day as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ i.wrapping_mul(0xBF58_476D_1CE4_E5B9);
x ^= x >> 31;
x = x.wrapping_mul(0x94D0_49BB_1331_11EB);
x ^ (x >> 29)
}
impl Jobs {
pub fn open(economy: &Economy, config: Config) -> Self {
let board = economy
.ext("jobs")
.and_then(|v| serde_json::from_value::<Board>(v.clone()).ok())
.unwrap_or_default();
Self {
config,
board,
dirty: false,
}
}
pub fn store(&mut self, economy: &mut Economy) {
if self.dirty {
economy.set_ext(
"jobs",
serde_json::to_value(&self.board).unwrap_or_default(),
);
self.dirty = false;
}
}
/// One item's pay: the sell price plus the premium, below the ask.
fn unit(&self, sell: i64, buy: i64) -> i64 {
let mut unit = sell * (100 + self.config.premium_percent) / 100;
if buy > 0 {
unit = unit.min(buy * self.config.ask_percent / 100);
}
unit.max(0)
}
/// Once a second from the glue; at 00:00 UTC, the day's contracts.
pub fn tick(&mut self, economy: &Economy, price: PriceOf, now: i64) -> Vec<Out> {
let day = now.div_euclid(DAY_MS);
if day == self.board.day || !self.config.enabled {
return Vec::new();
}
self.board.day = day;
self.board.done.clear();
self.board.paid_today = 0;
self.board.contracts.clear();
self.dirty = true;
let mut pool: Vec<(String, i64)> = self
.config
.items
.iter()
.filter_map(|i| {
let (sell, buy) = price(i)?;
let unit = self.unit(sell, buy);
(sell > 0 && unit > 0).then(|| (i.clone(), unit))
})
.collect();
let mut budget =
economy.pool(TREASURY).max(0) * self.config.budget_percent.clamp(0, 100) / 100;
for n in 0..self.config.contracts_per_day as u64 {
if pool.is_empty() {
break;
}
let (item, unit) = pool.remove((pick(day, n) % pool.len() as u64) as usize);
let pay = unit * i64::from(self.config.batch);
let left = (budget / pay.max(1)).min(i64::from(self.config.deliveries)) as u32;
if left == 0 {
continue;
}
budget -= pay * i64::from(left);
self.board.contracts.push(Contract {
item,
count: self.config.batch,
pay,
left,
});
}
if self.board.contracts.is_empty() {
return Vec::new();
}
vec![Out::All(format!(
"Today's contracts are up (/jobs): {}.",
self.board
.contracts
.iter()
.map(|c| format!(
"{} {} for {}",
c.count,
item_name(&c.item),
economy.money(c.pay)
))
.collect::<Vec<_>>()
.join(", ")
))]
}
/// `/jobs ...`; None for another command. `trades`: the player is in
/// survival or adventure (a creative player could make the items).
pub fn command(
&mut self,
economy: &mut Economy,
me: &Player,
trades: bool,
bag: &mut dyn Bag,
price: PriceOf,
line: &str,
) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
if words.first() != Some(&"jobs") {
return None;
}
if !self.config.enabled {
return Some(vec![Out::Error(
"There are no contracts on this server.".into(),
)]);
}
Some(match words[1..] {
[] => self.list(economy, me),
["deliver", n] => match n.trim_start_matches('#').parse::<usize>() {
Ok(n) if n >= 1 => self.deliver(economy, me, trades, bag, price, n - 1),
_ => vec![Out::Error("Usage: /jobs deliver <contract number>".into())],
},
_ => vec![Out::Error(
"Usage: /jobs, /jobs deliver <contract number>".into(),
)],
})
}
fn list(&self, economy: &Economy, me: &Player) -> Vec<Out> {
if self.board.contracts.is_empty() {
return vec![Out::Reply(
"No contracts today: the Treasury has nothing to spend.".into(),
)];
}
let done = self
.board
.done
.get(&uuid_string(me.uuid))
.copied()
.unwrap_or(0);
let mut out = vec![Out::Reply(format!(
"Today's contracts, paid by the Treasury (you've done {done} of {} today):",
self.config.max_per_player
))];
for (i, c) in self.board.contracts.iter().enumerate() {
out.push(Out::Reply(format!(
"#{} deliver {} {} for {} ({} left) · /jobs deliver {}",
i + 1,
c.count,
item_name(&c.item),
economy.money(c.pay),
c.left,
i + 1
)));
}
out
}
fn deliver(
&mut self,
economy: &mut Economy,
me: &Player,
trades: bool,
bag: &mut dyn Bag,
price: PriceOf,
i: usize,
) -> Vec<Out> {
let err = |s: String| vec![Out::Error(s)];
if !trades {
return err("Contracts are for players in survival or adventure mode.".into());
}
let Some(c) = self.board.contracts.get(i).cloned() else {
return err(format!("There's no contract #{} today (/jobs).", i + 1));
};
if c.left == 0 {
return err(format!("Contract #{} is done for today.", i + 1));
}
let key = uuid_string(me.uuid);
let done = self.board.done.get(&key).copied().unwrap_or(0);
if done >= self.config.max_per_player {
return err(format!(
"You've made your {} deliveries today; new contracts at 00:00 UTC.",
self.config.max_per_player
));
}
// The market's prices now: buying there to deliver never pays.
let pay = match price(&c.item) {
Some((sell, buy)) => c.pay.min(self.unit(sell, buy) * i64::from(c.count)),
None => c.pay,
};
if pay <= 0 || economy.pool(TREASURY) < pay {
return err("The Treasury can't pay that contract now.".into());
}
if bag.count(&c.item) < c.count {
return err(format!(
"You need {} plain {} in your inventory.",
c.count,
item_name(&c.item)
));
}
let took = bag.take(&c.item, c.count);
if took < c.count {
bag.give(&c.item, took);
return err(format!(
"You need {} plain {} in your inventory.",
c.count,
item_name(&c.item)
));
}
if let Err(e) = economy.from_pool(
&format!("contract {} {}", c.count, c.item),
TREASURY,
me.uuid,
pay,
"",
) {
bag.give(&c.item, took);
return err(e);
}
let contract = &mut self.board.contracts[i];
contract.left -= 1;
self.board.done.insert(key, done + 1);
self.board.paid_today += pay;
self.board.paid += pay;
self.dirty = true;
vec![Out::Reply(format!(
"Delivered {} {}: the Treasury paid you {}.",
c.count,
item_name(&c.item),
economy.money(pay)
))]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::smp_economy::Config as EcoConfig;
use std::collections::HashMap;
const A: u128 = 1;
const B: u128 = 2;
const T0: i64 = 1_793_577_600_000;
struct Inv(HashMap<String, u32>);
impl Bag for Inv {
fn held(&self) -> Option<(String, u32, bool)> {
None
}
fn carried(&self) -> Vec<(String, u32)> {
self.0.iter().map(|(k, v)| (k.clone(), *v)).collect()
}
fn count(&mut self, item: &str) -> u32 {
self.0.get(item).copied().unwrap_or(0)
}
fn take(&mut self, item: &str, count: u32) -> u32 {
let have = self.0.entry(item.into()).or_default();
let t = count.min(*have);
*have -= t;
t
}
fn room(&mut self, _: &str) -> u32 {
u32::MAX
}
fn give(&mut self, item: &str, count: u32) -> u32 {
*self.0.entry(item.into()).or_default() += count;
count
}
}
fn me(u: u128) -> Player {
Player {
uuid: u,
name: format!("P{u}"),
level: 0,
}
}
/// wheat sells 2, asks 3; cobblestone sells 1, asks 4; sand sells 1,
/// asks 1 (no room for a premium: never posted).
fn price(id: &str) -> Option<(i64, i64)> {
match id {
"minecraft:wheat" => Some((2, 3)),
"minecraft:cobblestone" => Some((1, 4)),
"minecraft:sand" => Some((1, 1)),
_ => None,
}
}
fn setup(name: &str, treasury: i64) -> (Economy, Jobs) {
let dir = std::env::temp_dir().join(format!("smp-jobs-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut e = Economy::open(&dir, EcoConfig::default()).unwrap();
e.now = T0;
e.seen(A, "PA");
e.seen(B, "PB");
if treasury > 0 {
e.adjust("sell", B, treasury, "").unwrap();
e.to_pool("seed", B, TREASURY, treasury, "").unwrap();
}
let config = Config {
items: ["wheat", "cobblestone", "dirt", "sand"]
.map(|i| format!("minecraft:{i}"))
.into(),
..Config::default()
};
let j = Jobs::open(&e, config);
(e, j)
}
#[test]
fn contracts_pay_from_the_treasury_below_the_market_ask_within_the_budget() {
let (mut e, mut j) = setup("post", 10_000);
let out = j.tick(&e, &price, T0);
assert_eq!(
j.board.contracts.len(),
2,
"dirt isn't traded, sand leaves no premium"
);
let wheat = j
.board
.contracts
.iter()
.find(|c| c.item == "minecraft:wheat")
.unwrap();
// 2 × 1.3 = 2 (whole coins), under 90% of 3: 128 a delivery.
assert_eq!(wheat.pay, 128);
assert!(matches!(&out[0], Out::All(s) if s.starts_with("Today's contracts are up")));
let cost: i64 = j
.board
.contracts
.iter()
.map(|c| c.pay * i64::from(c.left))
.sum();
assert!(cost <= 1_000, "{cost}");
assert!(j.tick(&e, &price, T0 + 5_000).is_empty(), "once a day");
let _ = &mut e;
}
#[test]
fn a_delivery_takes_the_items_and_pays_once_within_the_limits() {
let (mut e, mut j) = setup("deliver", 10_000);
j.tick(&e, &price, T0);
let n = j
.board
.contracts
.iter()
.position(|c| c.item == "minecraft:wheat")
.unwrap()
+ 1;
let mut bag = Inv(HashMap::from([("minecraft:wheat".to_owned(), 200)]));
let line = format!("jobs deliver {n}");
let out = j
.command(&mut e, &me(A), false, &mut bag, &price, &line)
.unwrap();
assert!(
matches!(&out[0], Out::Error(s) if s.contains("survival")),
"creative can't"
);
let out = j
.command(&mut e, &me(A), true, &mut bag, &price, &line)
.unwrap();
assert_eq!(
out,
[Out::Reply(
"Delivered 64 wheat: the Treasury paid you 128 coins.".into()
)]
);
assert_eq!(
(e.balance(A), e.pool(TREASURY), bag.0["minecraft:wheat"]),
(128, 9_872, 136)
);
j.command(&mut e, &me(A), true, &mut bag, &price, &line)
.unwrap();
let out = j
.command(&mut e, &me(A), true, &mut bag, &price, &line)
.unwrap();
assert!(
matches!(&out[0], Out::Error(s) if s.contains("2 deliveries today")),
"{out:?}"
);
assert_eq!(bag.0["minecraft:wheat"], 72, "nothing taken when refused");
// Too few items: nothing taken, nothing paid.
let mut few = Inv(HashMap::from([("minecraft:wheat".to_owned(), 63)]));
let out = j
.command(&mut e, &me(B), true, &mut few, &price, &line)
.unwrap();
assert!(matches!(&out[0], Out::Error(s) if s.starts_with("You need 64")));
assert_eq!(few.0["minecraft:wheat"], 63);
}
#[test]
fn a_falling_ask_lowers_the_pay_so_buying_to_deliver_never_pays() {
let (mut e, mut j) = setup("ask", 10_000);
j.tick(&e, &price, T0);
let n = j
.board
.contracts
.iter()
.position(|c| c.item == "minecraft:wheat")
.unwrap()
+ 1;
// The market now asks 2 for wheat: a delivery pays 64 × 1 (90% of 2), not 128.
let cheap = |id: &str| {
if id == "minecraft:wheat" {
Some((1, 2))
} else {
price(id)
}
};
let mut bag = Inv(HashMap::from([("minecraft:wheat".to_owned(), 64)]));
j.command(
&mut e,
&me(A),
true,
&mut bag,
&cheap,
&format!("jobs deliver {n}"),
)
.unwrap();
assert!(
e.balance(A) < 64 * 2,
"less than buying them cost: {}",
e.balance(A)
);
}
#[test]
fn an_empty_treasury_posts_nothing_and_the_board_survives_a_restart() {
let (e, mut j) = setup("empty", 0);
assert!(j.tick(&e, &price, T0).is_empty());
assert!(j.board.contracts.is_empty());
let (mut e, mut j) = setup("restart", 10_000);
j.tick(&e, &price, T0);
j.store(&mut e);
e.save().unwrap();
let dir = std::env::temp_dir().join(format!("smp-jobs-restart-{}", std::process::id()));
let e2 = Economy::open(&dir, EcoConfig::default()).unwrap();
let mut j2 = Jobs::open(&e2, Config::default());
assert_eq!(j2.board.contracts, j.board.contracts);
assert!(
j2.tick(&e2, &price, T0 + 1000).is_empty(),
"not posted twice"
);
}
}
+2 -3
View File
@@ -133,9 +133,8 @@ impl Lottery {
ticket < 0
})
.expect("the ticket is below the total");
let pot = total * price;
let prize = pot - pot * config.fee_percent.clamp(0, 100) / 100;
match economy.award("lottery_win", &stakes, winner.0, prize) {
let prize = total * price;
match economy.award("lottery_win", &stakes, winner.0) {
Ok(()) => vec![Out::All(format!(
"Lottery: {} won {} with {} of {total} tickets!",
winner.1,
+16 -118
View File
@@ -12,7 +12,7 @@
// lends the player's items as a `Bag`. Added to the game as
// `game::smp_market`; also a module of this crate, for its tests.
use super::smp_economy::{Economy, MAX_AMOUNT, Out, Player, TREASURY, parse_amount};
use super::smp_economy::{Economy, MAX_AMOUNT, Out, Player, parse_amount};
use super::smp_market_prices::{
Book, Dynamic, MINUTE_MS, Point, Quote, Span, arrow, base_mid, one_price, to_get, to_pay,
};
@@ -328,9 +328,6 @@ pub struct Market {
pub warnings: Vec<String>,
/// Each item made of or into others: those others (`families`).
family: BTreeMap<String, Vec<String>>,
/// Items whose history `open` scaled to their one price, by how much
/// (for the futures' entries).
pub rebased: BTreeMap<String, f64>,
}
/// The market's items made of one another (by `smp_recipes::chains`), in
@@ -412,7 +409,6 @@ impl Market {
book_dirty: !rebased.is_empty(),
warnings: Vec::new(),
family: BTreeMap::new(),
rebased,
};
market.link();
(market, problems)
@@ -431,7 +427,6 @@ impl Market {
book_dirty: false,
warnings: Vec::new(),
family: BTreeMap::new(),
rebased: BTreeMap::new(),
};
m.link();
m
@@ -591,7 +586,7 @@ impl Market {
}
/// What the market asks and pays for one `id` now, and the mid; None
/// when it isn't traded. For futures and charts.
/// when it isn't traded. For the charts.
pub fn spot(&self, id: &str) -> Option<Quote> {
let b = self.config.items.get(id)?;
Some(if self.config.dynamic.enabled {
@@ -610,32 +605,6 @@ impl Market {
})
}
#[allow(
dead_code,
reason = "read by the futures (smp-econ), not the market itself"
)]
/// The time-weighted mean mid of `id` over the last `minutes` minutes.
pub fn twap(&self, id: &str, minutes: u32, now: i64) -> Option<f64> {
self.config
.items
.contains_key(id)
.then(|| self.book.twap(id, minutes, now))
.flatten()
}
#[allow(
dead_code,
reason = "read by the futures (smp-econ), not the market itself"
)]
/// The 10-minute TWAP, rounded, from at least 5 one-minute points.
pub fn mark_price(&self, id: &str, now: i64) -> Option<i64> {
self.config
.items
.contains_key(id)
.then(|| self.book.mark_price(id, now))
.flatten()
}
/// Items of `id` traded (both sides) in the last `hours`.
pub fn volume(&self, id: &str, now: i64, hours: u32) -> u64 {
self.book.volume(id, now, hours)
@@ -804,12 +773,6 @@ impl Market {
"{name}: {} each, {ways}.",
each_money(economy, self.each(&id))
))];
if self.config.dynamic.enabled && self.config.dynamic.tax_percent > 0.0 {
out.push(Out::Reply(format!(
"Tax: {}% of each trade, to the Treasury. Big trades move the price as they go.",
self.config.dynamic.tax_percent
)));
}
if self.config.dynamic.enabled {
out.push(Out::Reply(format!(
"Moves: {}; traded in 24 h: {}. Chart: /market {}",
@@ -943,23 +906,13 @@ impl Market {
match economy.adjust(&kind, me.uuid, pay, "") {
Ok(_) => {
self.count_sale(me.uuid, now, &id, taken, pay);
let due = self.tax_on(pay);
let tax = self.tax(economy, me.uuid, due, &format!("sell {taken} {id}"));
self.moved(&id, taken, false);
total = total.saturating_add(pay - tax);
total = total.saturating_add(pay);
let each = each_money(economy, pay as f64 / f64::from(taken));
out.push(Out::Reply(format!(
"Sold {taken} {name} at {each} each: {}.",
economy.money(pay)
)));
if tax > 0 {
out.push(Out::Reply(format!(
"Tax {}%: -{}. You get {}.",
self.config.dynamic.tax_percent,
economy.money(tax),
economy.money(pay - tax)
)));
}
if taken < limited {
let hours = self.config.dynamic.round_trip_hours;
out.push(Out::Reply(format!(
@@ -1030,16 +983,10 @@ impl Market {
if cost > MAX_AMOUNT {
return vec![Out::Error("That costs too much at once.".into())];
}
let tax = self.tax_on(cost);
if economy.balance(me.uuid) < cost + tax {
if economy.balance(me.uuid) < cost {
return vec![Out::Error(format!(
"{count} {name} cost {}{}; you have {}.",
"{count} {name} cost {}; you have {}.",
economy.money(cost),
if tax > 0 {
format!(" + {} tax", economy.money(tax))
} else {
String::new()
},
economy.money(economy.balance(me.uuid))
))];
}
@@ -1059,12 +1006,6 @@ impl Market {
} else {
self.value(&id, given, true).0
};
let tax = if given > 0 {
let due = self.tax_on(paid);
self.tax(economy, me.uuid, due, &format!("buy {given} {id}"))
} else {
0
};
if given > 0 {
self.moved(&id, given, true);
let now = economy.now;
@@ -1076,14 +1017,6 @@ impl Market {
"Bought {given} {name} at {each} each: {}.",
economy.money(paid)
))];
if tax > 0 {
out.push(Out::Reply(format!(
"Tax {}%: -{}. You paid {}.",
self.config.dynamic.tax_percent,
economy.money(tax),
economy.money(paid + tax)
)));
}
if count < wanted {
out.push(Out::Reply(format!(
"Only {count} of {wanted}: the price moved {}% this minute, as far as it may.",
@@ -1117,34 +1050,6 @@ impl Market {
}
impl Market {
/// The tax on a trade of `amount` coins (0 without moving prices).
pub fn tax_on(&self, amount: i64) -> i64 {
if self.config.dynamic.enabled {
self.config.dynamic.tax(amount)
} else {
0
}
}
/// Takes `tax` from `uuid` into the Treasury (logged as `market_tax`):
/// what it took (0 when the log refused).
fn tax(&mut self, economy: &mut Economy, uuid: u128, tax: i64, what: &str) -> i64 {
let tax = tax.min(economy.balance(uuid));
if tax <= 0 {
return 0;
}
match economy.to_pool("market_tax", uuid, TREASURY, tax, what) {
Ok(()) => {
self.book.taxes = self.book.taxes.saturating_add(tax);
tax
}
Err(e) => {
self.warnings.push(format!("market tax: {e}"));
0
}
}
}
/// A trade's move of the price (at once, with its family's).
fn moved(&mut self, id: &str, n: u32, buy: bool) {
if !self.config.dynamic.enabled {
@@ -1288,8 +1193,8 @@ impl WebView {
"generated": now,
"currency": currency,
"moving": self.config.enabled && d.enabled,
"tax_percent": d.tax_percent,
"taxes_collected": self.book.taxes,
// No tax since 0.30: kept at 0 for older readers.
"tax_percent": 0,
"daily_summary": {
"day": self.book.summary_day * DAY_MS,
"lines": self.book.last_summary,
@@ -1742,7 +1647,7 @@ mod tests {
fn no_trades_on_moving_prices_ever_earn_once_the_items_are_back() {
// Five players pump and dump every item for 2,000 minutes, with the
// drift between; then every position is closed. On every item the
// market ends up with the money, before any tax.
// market ends up with the money.
let c = moving();
let mut m = Market::new(c.clone());
let t0 = 1_800_000_000_000i64;
@@ -1888,13 +1793,11 @@ mod tests {
"{} >= {start}",
e.balance(me.uuid)
);
// The Treasury got the tax on every trade.
assert!(e.pool(TREASURY) > 0);
fs::remove_dir_all(dir).ok();
}
#[test]
fn moving_prices_tax_the_receipt_and_keep_bought_items_from_coming_back() {
fn moving_prices_pay_the_whole_receipt_and_keep_bought_items_from_coming_back() {
let mut c = Config::default();
let _ = validate(&mut c);
let (mut e, _, me, dir) = setup("dyn");
@@ -1906,21 +1809,16 @@ mod tests {
// One price, 200; three diamonds move it 1.5% as they go.
let out = m.sell(&mut e, &me, &mut b, &["diamond"]);
assert_eq!(
out[..2],
[
Out::Reply("Sold 3 diamond at 198 coins each: 595 coins.".into()),
Out::Reply("Tax 2%: -12 coins. You get 583 coins.".into())
]
out[0],
Out::Reply("Sold 3 diamond at 198 coins each: 595 coins.".into())
);
assert_eq!(e.pool(TREASURY), 12);
assert_eq!(e.balance(me.uuid), 10_000 + 595, "no tax taken");
let out = m.buy(&mut e, &me, &mut b, &["iron_ingot", "10"]);
assert_eq!(
out[..2],
[
Out::Reply("Bought 10 iron ingot at 15 coins each: 151 coins.".into()),
Out::Reply("Tax 2%: -3 coins. You paid 154 coins.".into())
]
out[0],
Out::Reply("Bought 10 iron ingot at 15 coins each: 151 coins.".into())
);
assert_eq!(e.balance(me.uuid), 10_000 + 595 - 151);
let out = m.sell(&mut e, &me, &mut b, &["iron_ingot"]);
assert_eq!(
out[0],
@@ -1934,7 +1832,7 @@ mod tests {
"{out:?}"
);
assert_eq!(
out[2],
out[1],
Out::Reply("10 iron ingot kept: bought from the market in the last 24 h.".into())
);
b.items.insert("minecraft:iron_ingot".into(), 10);
+7 -71
View File
@@ -5,7 +5,7 @@
// (rare, dear items move faster than common ones). A trade of n items costs
// the area under the price along the way it moves the price (`fill`): the
// same curve both ways, so a sale exactly retraces a purchase, and a round
// trip earns nothing but the tax, whoever trades and however the price was
// trip earns nothing, whoever trades and however the price was
// pushed. Within a minute the price moves at most `max_minute_move_percent`
// from where the minute began: the rest of a trade waits for the next minute
// (never priced flat: buying slowly while the price drifts back, then
@@ -15,8 +15,7 @@
// 1 (mean reversion). Items made of one another (`smp_recipes`) share one
// factor, with an elasticity by value, so crafting can't beat the market. The
// history: one point a minute for 24 h, one an hour for 30 days
// (`market_prices.json`), the time-weighted mean for futures (`twap`,
// `mark_price`), the day's movers and news (`summary`). No game types. Added
// (`market_prices.json`), the day's movers and news (`summary`). No game types. Added
// to the game as `game::smp_market_prices`; also a module of this crate, for
// its tests.
@@ -44,9 +43,6 @@ pub struct Dynamic {
pub max_player_impact_percent: f64,
/// How far the price may move within one minute (either way).
pub max_minute_move_percent: f64,
/// The share of each trade that goes to the Treasury (a money sink for
/// the players, shown on the receipt).
pub tax_percent: f64,
/// What a player bought from the market can't be sold back to it for
/// this long (that many of that item).
pub round_trip_hours: i64,
@@ -68,7 +64,6 @@ impl Default for Dynamic {
max_trade_impact_percent: 2.0,
max_player_impact_percent: 2.0,
max_minute_move_percent: 5.0,
tax_percent: 2.0,
round_trip_hours: 24,
depth: BTreeMap::new(),
news_percent: 30.0,
@@ -103,16 +98,9 @@ impl Dynamic {
50.0,
d.max_minute_move_percent,
);
self.tax_percent = ok(self.tax_percent, 0.0, 50.0, d.tax_percent);
self.round_trip_hours = self.round_trip_hours.clamp(0, 24 * 30);
self.depth.retain(|_, v| v.is_finite() && *v > 0.0);
}
/// The tax on `amount` coins (rounded to the nearest coin).
pub fn tax(&self, amount: i64) -> i64 {
let bp = (self.tax_percent * 100.0).round() as i64;
(amount.saturating_mul(bp) + 5_000) / 10_000
}
}
/// One point of history: its start (ms), the mid price then, the items
@@ -190,8 +178,6 @@ pub struct Book {
/// What each player bought (item, when, how many), for the round-trip
/// rule.
pub bought: BTreeMap<String, Vec<(String, i64, u32)>>,
/// The Treasury's take so far (display).
pub taxes: i64,
/// The last daily summary's lines (for the web page and Kira).
pub last_summary: Vec<String>,
/// The base price each item's history is quoted in (since 0.29).
@@ -366,7 +352,7 @@ impl Book {
/// At load: each minute's band opens where its price is, and a book
/// from before 0.29 (no `bases`), whose history is quoted in the old
/// mid of buy and sell (`old`), gets it scaled to the one price of now
/// (`bases`), so the charts, the moves and the futures' TWAP see no
/// (`bases`), so the charts and the moves see no
/// cliff. The items it scaled, by how much.
pub fn settle(
&mut self,
@@ -500,46 +486,6 @@ impl Book {
}
}
#[allow(
dead_code,
reason = "read by the futures (smp-econ), not the market itself"
)]
/// The minute points of `id` within the last `minutes` minutes.
fn recent(&self, id: &str, minutes: u32, now: i64) -> impl Iterator<Item = &Point> {
let since = now - i64::from(minutes) * MINUTE_MS;
self.items
.get(id)
.into_iter()
.flat_map(move |t| t.minutes.iter().rev().take_while(move |p| p.t >= since))
}
#[allow(
dead_code,
reason = "read by the futures (smp-econ), not the market itself"
)]
/// The time-weighted mean mid over the last `minutes` complete minutes
/// (each point is one minute); None without a point there.
pub fn twap(&self, id: &str, minutes: u32, now: i64) -> Option<f64> {
let (n, sum) = self
.recent(id, minutes, now)
.fold((0u32, 0.0), |(n, s), p| (n + 1, s + p.price));
(n > 0).then(|| sum / f64::from(n))
}
#[allow(
dead_code,
reason = "read by the futures (smp-econ), not the market itself"
)]
/// The 10-minute TWAP, rounded, from at least 5 points: a mark price
/// one player can't move much.
pub fn mark_price(&self, id: &str, now: i64) -> Option<i64> {
let n = self.recent(id, 10, now).count();
(n >= 5)
.then(|| self.twap(id, 10, now))
.flatten()
.map(|p| p.round() as i64)
}
/// Items of `id` traded in the last `hours` (minutes for a day, hours
/// beyond).
pub fn volume(&self, id: &str, now: i64, hours: u32) -> u64 {
@@ -732,8 +678,7 @@ mod tests {
}
#[test]
fn a_round_trip_earns_nothing_but_the_tax_for_1_64_and_past_the_band() {
let c = Dynamic::default();
fn a_round_trip_earns_nothing_for_1_64_and_past_the_band() {
// 1, 64, and 2,000 (far past the minute's 5% band: only what fits
// goes, and the same count comes back).
for n in [1u32, 64, 2000] {
@@ -742,11 +687,9 @@ mod tests {
let (a, done) = deal(&mut b, n, first_buy);
let (z, back) = deal(&mut b, done, !first_buy);
assert_eq!(back, done);
let net = a + z - c.tax(a.abs()) - c.tax(z.abs());
assert!(a + z <= 0, "{n} {first_buy}: {a} {z}");
assert!(net < 0, "{n} {first_buy}: {net}");
// Within the band the curve is retraced exactly: only the
// rounding of whole coins (at most 2) and the tax remain.
// rounding of whole coins (at most 2) remains.
if n <= 8 {
assert!(a + z >= -2, "{n} {first_buy}: {a} {z}");
}
@@ -839,18 +782,14 @@ mod tests {
}
#[test]
fn twap_mark_price_volume_and_history() {
fn volume_and_history() {
let c = Dynamic::default();
let mut b = book();
assert_eq!(b.mark_price("minecraft:diamond", T0), None);
for m in 1..=12 {
b.trade(&c, D, &[], 250.0, delta(), 2, true);
b.tick(&c, &mids(), T0 + m * MINUTE_MS);
}
let now = T0 + 12 * MINUTE_MS;
let mark = b.mark_price("minecraft:diamond", now).unwrap();
let spot = b.quote(&c, D, 250.0, delta()).mid;
assert!(mark as f64 <= spot && mark > 250, "{mark} {spot}");
assert_eq!(b.volume("minecraft:diamond", now, 24), 24);
assert_eq!(b.history("minecraft:diamond", Span::Minute).len(), 12);
let h = b.history("minecraft:diamond", Span::Minute);
@@ -904,7 +843,7 @@ mod tests {
}
#[test]
fn the_round_trip_window_and_the_tax() {
fn the_round_trip_window_and_one_price() {
let c = Dynamic::default();
let mut b = book();
b.bought("u", "minecraft:diamond", 5, T0);
@@ -918,9 +857,6 @@ mod tests {
0
);
assert!(b.bought.is_empty());
assert_eq!(c.tax(100), 2);
assert_eq!(c.tax(25), 1);
assert_eq!(c.tax(24), 0);
// One price from the old two: their geometric mean, whole.
assert_eq!(one_price(400, 100), 200);
assert_eq!(one_price(30, 8), 15);
+82 -73
View File
@@ -30,33 +30,10 @@ pub struct Config {
pub homes: Vec<[i64; 2]>,
/// `/rules`, a line each.
pub rules: Vec<String>,
/// What each kind of teleport costs (coins, to the Treasury), paid when
/// it happens: a called-off one costs nothing.
pub fees: Fees,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Fees {
/// Free: a lost or new player can always get back.
pub spawn: i64,
pub home: i64,
pub warp: i64,
pub back: i64,
/// Paid by the one who moves.
pub tpa: i64,
}
impl Default for Fees {
fn default() -> Self {
Self {
spawn: 0,
home: 5,
warp: 10,
back: 10,
tpa: 10,
}
}
/// `/spawn` lands on a random safe column within this many blocks of the
/// spawn point, like vanilla's `respawn_radius` for a first join or a
/// respawn (0: exactly the spawn point).
pub spawn_radius: i64,
}
impl Default for Config {
@@ -71,7 +48,7 @@ impl Default for Config {
"No griefing, no stealing, no cheats.".into(),
"The currency is earned in game only: never bought or sold for real money.".into(),
],
fees: Fees::default(),
spawn_radius: 250,
}
}
}
@@ -112,6 +89,8 @@ struct Data {
enum To {
/// A place, and what it is (`home "base"`).
Place(Place, String),
/// The spawn point: `/spawn` fuzzes it by `spawn_radius`.
Spawn(Place),
/// A player, wherever they are when the warmup ends.
Player(u128, String),
}
@@ -121,7 +100,6 @@ enum To {
pub struct Pending {
pub uuid: u128,
to: To,
fee: i64,
from: Place,
health: f32,
due: i64,
@@ -143,13 +121,30 @@ pub struct Teleport {
pub to: Place,
/// What the player is told once there.
pub done: String,
/// What it costs, and what for (the audit log's reason).
pub fee: i64,
/// Where to, in words (`where you died`).
pub what: String,
/// Land on a safe column within this many blocks of `to` instead of on it
/// (0: on it). Only `/spawn` sets it.
pub spread: i64,
/// The cooldown and `/back` before it: `call_off` puts them back.
undo: (Option<i64>, Option<(Place, bool)>),
}
impl Teleport {
/// The command that moves the player from the point `to` to a safe column
/// within `spread` blocks of it: vanilla's `spreadplayers` (a column that
/// is not fluid, fire or magma and has solid ground, found by retrying),
/// the check behind `respawn_radius`. None when `spread` is 0.
pub fn spread_line(&self) -> Option<String> {
(self.spread >= 1).then(|| {
format!(
"execute in {} run spreadplayers {:.3} {:.3} 0 {} false {}",
self.to.dimension, self.to.x, self.to.z, self.spread, self.name
)
})
}
}
/// The places' commands.
pub const COMMANDS: [&str; 16] = [
"spawn", "setspawn", "home", "sethome", "delhome", "homes", "warp", "warps", "setwarp",
@@ -271,22 +266,7 @@ impl Places {
.unwrap_or(0)
}
/// What the command in `line` will cost its player (`/tpaccept`: the
/// one who asked pays, when they move).
pub fn fee(&self, line: &str) -> i64 {
let f = &self.config.fees;
let fee = match line.split_whitespace().next() {
Some("spawn") => f.spawn,
Some("home") => f.home,
Some("warp") => f.warp,
Some("back") => f.back,
Some("tpa") => f.tpa,
_ => 0,
};
fee.max(0)
}
/// A teleport the game couldn't make or the player couldn't pay: no
/// A teleport the game couldn't make: no
/// cooldown, and `/back` goes where it went before.
pub fn call_off(&mut self, t: &Teleport) {
match t.undo.0 {
@@ -301,14 +281,6 @@ impl Places {
/// Starts a teleport's warmup, or says why not.
fn start(&mut self, me: &Body, to: To, now: i64) -> Vec<Out> {
let fee = match &to {
To::Player(..) => self.config.fees.tpa,
To::Place(_, what) if what == "the spawn" => self.config.fees.spawn,
To::Place(_, what) if what.starts_with("home") => self.config.fees.home,
To::Place(_, what) if what.starts_with("warp") => self.config.fees.warp,
To::Place(..) => self.config.fees.back,
}
.max(0);
if let Some(&t) = self.last.get(&me.uuid) {
let wait = t + self.config.cooldown_seconds * 1000 - now;
if wait > 0 {
@@ -322,19 +294,13 @@ impl Places {
self.pending.push(Pending {
uuid: me.uuid,
to,
fee,
from: me.at.clone(),
health: me.health,
due: now + self.config.warmup_seconds.max(0) * 1000,
});
let cost = if fee > 0 {
format!(" (fee {fee} coins)")
} else {
String::new()
};
if self.config.warmup_seconds > 0 {
vec![Out::Reply(format!(
"Teleporting in {} s{cost}: don't move.",
"Teleporting in {} s: don't move.",
self.config.warmup_seconds
))]
} else {
@@ -395,7 +361,7 @@ impl Places {
out
}
("spawn", []) => match self.data.spawn.clone() {
Some(p) => self.start(body, To::Place(p, "the spawn".into()), now),
Some(p) => self.start(body, To::Spawn(p), now),
None => vec![Out::Error(
"No spawn yet: an operator sets it with /setspawn.".into(),
)],
@@ -616,8 +582,13 @@ impl Places {
} else if now < p.due {
self.pending.push(p);
} else {
let mut spread = 0;
let (to, what) = match &p.to {
To::Place(place, what) => (Some(place.clone()), what.clone()),
To::Spawn(place) => {
spread = self.config.spawn_radius.max(0);
(Some(place.clone()), "the spawn".to_owned())
}
To::Player(u, name) => (
bodies.iter().find(|b| b.uuid == *u).map(|b| b.at.clone()),
name.clone(),
@@ -634,8 +605,8 @@ impl Places {
name: me.name.clone(),
to,
done: format!("Teleported to {what}."),
fee: p.fee,
what,
spread,
undo,
});
}
@@ -808,7 +779,7 @@ mod tests {
let out = p.command(&player(B, 0), &both, 1_000, "tpaccept").unwrap();
assert_eq!(
out[1],
Out::Tell(A, "Teleporting in 3 s (fee 10 coins): don't move.".into())
Out::Tell(A, "Teleporting in 3 s: don't move.".into())
);
// Steve walked on meanwhile: Alex lands where Steve is then.
let later = [body(A, 0.0), body(B, 120.0)];
@@ -846,19 +817,14 @@ mod tests {
dead.health = 0.0;
p.tick(&[dead], 0);
let alive = [body(A, 0.0)];
assert_eq!(p.fee("back"), 10);
assert_eq!(p.fee("spawn"), 0);
assert_eq!(p.fee("tpaccept"), 0, "the one who moves pays");
let out = p.command(&player(A, 0), &alive, 1_000, "back").unwrap();
assert_eq!(
out,
vec![Out::Reply(
"Teleporting in 3 s (fee 10 coins): don't move.".into()
)]
vec![Out::Reply("Teleporting in 3 s: don't move.".into())]
);
let (_, go) = p.tick(&alive, 4_000);
assert_eq!((go[0].fee, go[0].what.as_str()), (10, "where you died"));
// Couldn't pay: /back still goes where they died, at once.
assert_eq!(go[0].what, "where you died");
// The game couldn't make it: /back still goes where they died, at once.
p.call_off(&go[0]);
p.command(&player(A, 0), &alive, 4_000, "back").unwrap();
let (_, go) = p.tick(&alive, 7_000);
@@ -886,8 +852,51 @@ mod tests {
(go[0].to.x, go[0].done.as_str()),
(7.0, "Teleported to the spawn.")
);
assert_eq!(go[0].spread, 250, "the spawn is fuzzed by default");
let out = p.command(&player(A, 0), &here, 0, "rules").unwrap();
assert_eq!(out.len(), 4);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn only_the_spawn_is_spread_over_a_safe_spot() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
let here = [body(A, 7.0)];
p.command(&player(A, 3), &here, 0, "setspawn").unwrap();
p.command(&player(A, 3), &here, 0, "setwarp shop").unwrap();
p.command(&player(A, 3), &here, 0, "sethome").unwrap();
let far = [body(A, 50.0)];
p.command(&player(A, 0), &far, 0, "spawn").unwrap();
let (_, go) = p.tick(&far, 3_000);
assert_eq!(go[0].to, place(7.0));
assert_eq!(
go[0].spread_line().as_deref(),
Some("execute in minecraft:overworld run spreadplayers 7.000 0.000 0 250 false Alex")
);
for (i, line) in ["warp shop", "home"].into_iter().enumerate() {
let t = 100_000 * (i as i64 + 1);
p.command(&player(A, 0), &far, t, line).unwrap();
let (_, go) = p.tick(&far, t + 3_000);
assert_eq!(go.len(), 1, "{line}");
assert_eq!(go[0].spread_line(), None, "{line} is exact");
}
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn a_zero_radius_sends_players_to_the_exact_spawn() {
let d = dir();
let config = Config {
spawn_radius: 0,
..Config::default()
};
let mut p = Places::open(&d, config).unwrap();
let here = [body(A, 7.0)];
p.command(&player(A, 3), &here, 0, "setspawn").unwrap();
p.command(&player(A, 0), &here, 0, "spawn").unwrap();
let (_, go) = p.tick(&here, 3_000);
assert_eq!(go[0].spread_line(), None);
let _ = std::fs::remove_dir_all(d);
}
}
+31 -3
View File
@@ -98,6 +98,9 @@ pub struct Presence {
/// Seconds each player was active today (UTC day), toward the reward;
/// not kept over a restart.
played_today: HashMap<u128, (String, i64)>,
/// The vanished players (the glue sets it): their first join and AFK
/// are told to them only, never announced.
pub quiet: HashSet<u128>,
}
impl Presence {
@@ -123,6 +126,7 @@ impl Presence {
afk: HashSet::new(),
recent: HashMap::new(),
played_today: HashMap::new(),
quiet: HashSet::new(),
})
}
@@ -165,7 +169,7 @@ impl Presence {
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() {
if !self.config.first_join.is_empty() && !self.quiet.contains(&p.uuid) {
out.push(Out::All(fill(&self.config.first_join)));
}
out.extend(
@@ -235,7 +239,7 @@ impl Presence {
fn away(&mut self, uuid: u128, name: &str, acts: &mut Vec<Act>) -> Out {
acts.push(Act::Sleep(uuid, true));
if self.config.afk_announce {
if self.config.afk_announce && !self.quiet.contains(&uuid) {
Out::All(format!("{name} is AFK."))
} else {
Out::Tell(
@@ -247,7 +251,7 @@ impl Presence {
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 {
vec![if self.config.afk_announce && !self.quiet.contains(&uuid) {
Out::All(format!("{name} is back."))
} else {
Out::Tell(uuid, "You are back.".into())
@@ -331,6 +335,30 @@ mod tests {
(Presence::open(&dir, Config::default(), known).unwrap(), dir)
}
#[test]
fn a_vanished_player_is_never_announced() {
let (mut p, _dir) = open(vec![]);
p.config.afk_announce = true;
p.quiet.insert(A);
let (out, _) = p.tick(&[seen(A, 0.0)], "2026-10-31", 0, &[]);
assert!(
!out.iter().any(|o| matches!(o, Out::All(_))),
"no first join: {out:?}"
);
let afk = p.config.afk_minutes * 60_000;
let (out, acts) = p.tick(&[seen(A, 0.0)], "2026-10-31", afk, &[]);
assert!(
matches!(&out[..], [Out::Tell(A, _)]),
"AFK told to them only: {out:?}"
);
assert!(acts.contains(&Act::Sleep(A, true)));
let (out, _) = p.tick(&[seen(A, 5.0)], "2026-10-31", afk + 1_000, &[]);
assert!(
matches!(&out[..], [Out::Tell(A, _)]),
"back told to them only: {out:?}"
);
}
#[test]
fn a_first_join_is_welcomed_once_and_known_players_are_not() {
let (mut p, dir) = open(vec![B]);
+49 -137
View File
@@ -1,16 +1,16 @@
// The economy in the open: `/eco stats` (the money supply and where it
// sits, what the server made and destroyed today, the Treasury's day, how
// evenly money is spread), a report to everyone at 00:00 UTC on the day
// sits, what the server made and destroyed today, how evenly money is
// spread), a report to everyone at 00:00 UTC on the day
// before, and `web/economy.json` for the site's /hub (written once a minute,
// off the tick thread, by the glue). One snapshot a day is kept (60 days) in
// `economy.json` (`ext.report`) for the curves. Reading only: this part
// moves no money. No game types. Added to the game as `game::smp_report`;
// also a module of this crate, for its tests.
use super::smp_economy::{DAY_MS, Economy, INSURANCE, Out, TREASURY};
use super::smp_economy::{DAY_MS, Economy, Out};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashMap};
use std::collections::BTreeMap;
/// `config.json`'s `report`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -41,16 +41,11 @@ pub struct Day {
pub day: i64,
/// All the money there is, at the end of the day.
pub supply: i64,
/// Players' money: wallets, savings, money held (bids, stakes, margins).
/// Players' money: wallets, money held (bids, stakes).
pub players: i64,
pub treasury: i64,
pub insurance: i64,
/// Made and destroyed by the server that day.
pub minted: i64,
pub burned: i64,
/// The Treasury's income and spending that day.
pub taxes: i64,
pub spent: i64,
/// Over players with money: Gini (0 to 1, in thousandths) and the
/// richest ten's share (percent).
pub gini_milli: i64,
@@ -73,8 +68,8 @@ pub struct Report {
minute: i64,
}
/// Pools that are the state's, not players' money.
const STATE_POOLS: [&str; 3] = [TREASURY, INSURANCE, "casino"];
/// Pools that aren't players' money: the casino's pot.
const HOUSE_POOLS: [&str; 1] = ["casino"];
/// Gini (thousandths) and the top ten's share (percent) of `wealth`.
pub fn spread(mut wealth: Vec<i64>) -> (i64, i64) {
@@ -152,83 +147,53 @@ impl Report {
}
}
/// The day's numbers now. `savings`: what each player has in the bank.
pub fn snapshot(economy: &mut Economy, savings: &HashMap<u128, i64>, day: i64) -> Day {
/// The day's numbers now.
pub fn snapshot(economy: &mut Economy, day: i64) -> Day {
let accounts = economy.accounts();
let wallets: i64 = accounts.iter().map(|a| a.2).sum();
let pools: i64 = economy.pools().values().sum();
let state: i64 = STATE_POOLS.iter().map(|p| economy.pool(p)).sum();
let house: i64 = HOUSE_POOLS.iter().map(|p| economy.pool(p)).sum();
let supply = wallets + economy.held_total() + pools;
let mut wealth: HashMap<u128, i64> = accounts.iter().map(|a| (a.0, a.2)).collect();
for (u, s) in savings {
*wealth.entry(*u).or_default() += s;
}
let (gini_milli, top10_percent) = spread(wealth.into_values().collect());
let (gini_milli, top10_percent) = spread(accounts.iter().map(|a| a.2).collect());
let today = economy.now.div_euclid(DAY_MS);
let (minted, burned, taxes, spent) = {
let t = economy.treasury();
let (inc, out) = if t.day == day {
(sum(&t.income_today), sum(&t.spent_today))
} else if t.day == day + 1 {
(sum(&t.income_yesterday), sum(&t.spent_yesterday))
} else {
(0, 0)
};
let f = economy.flows();
let (m, b) = if day == today {
(sum(&f.minted), sum(&f.burned))
} else if day + 1 == today {
(sum(&f.minted_yesterday), sum(&f.burned_yesterday))
} else {
(0, 0)
};
(m, b, inc, out)
let f = economy.flows();
let (minted, burned) = if day == today {
(sum(&f.minted), sum(&f.burned))
} else if day + 1 == today {
(sum(&f.minted_yesterday), sum(&f.burned_yesterday))
} else {
(0, 0)
};
Day {
day,
supply,
players: supply - state,
treasury: economy.pool(TREASURY),
insurance: economy.pool(INSURANCE),
players: supply - house,
minted,
burned,
taxes,
spent,
gini_milli,
top10_percent,
}
}
/// Runs `/eco stats` (or `/money stats`); None for another command.
pub fn command(
&self,
economy: &mut Economy,
savings: &HashMap<u128, i64>,
line: &str,
) -> Option<Vec<Out>> {
pub fn command(&self, economy: &mut Economy, line: &str) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
if !matches!(words.as_slice(), ["eco" | "money", "stats"]) {
return None;
}
let today = economy.now.div_euclid(DAY_MS);
let d = Self::snapshot(economy, savings, today);
let d = Self::snapshot(economy, today);
let since = self.state.days.last().map(|y| d.supply - y.supply);
let pools: Vec<String> = economy
.pools()
.iter()
.filter(|(k, v)| **v > 0 && !STATE_POOLS.contains(&k.as_str()))
.filter(|(k, v)| **v > 0 && !HOUSE_POOLS.contains(&k.as_str()))
.map(|(k, v)| format!("{} {}", k.replace('_', " "), economy.money(*v)))
.collect();
let flows = economy.flows().clone();
let t = economy.treasury().clone();
let (inc, out) = if t.day == today {
(t.income_today, t.spent_today)
} else {
Default::default()
};
let mut lines = vec![
Some(vec![
Out::Reply(format!(
"Money supply: {}{}. Players hold {} (of it in pools: {}), the Treasury {}, the insurance fund {}.",
"Money supply: {}{}. Players hold {} (of it in pools: {}), the casino's pot {}.",
economy.money(d.supply),
since.map_or(String::new(), |s| format!(
" ({}{} since midnight)",
@@ -241,8 +206,7 @@ impl Report {
} else {
pools.join(", ")
},
economy.money(d.treasury),
economy.money(d.insurance)
economy.money(d.supply - d.players)
)),
Out::Reply(format!(
"Today the server made {} ({}) and destroyed {} ({}).",
@@ -251,27 +215,16 @@ impl Report {
economy.money(sum(&flows.burned)),
list(economy, &flows.burned, 3)
)),
Out::Reply(format!(
"Taxes and fees today: {} ({}). The Treasury paid out {}.",
economy.money(sum(&inc)),
list(economy, &inc, 4),
economy.money(sum(&out))
)),
Out::Reply(format!(
"Spread: Gini {}.{:03} (0 equal, 1 one player has all), the richest ten hold {}%.",
d.gini_milli / 1000,
d.gini_milli % 1000,
d.top10_percent
)),
];
lines.push(Out::Reply(
"The Treasury: /treasury. The taxes: /eco taxes.".into(),
));
Some(lines)
])
}
/// Whether `tick` has work at `now` (a new minute or day): the glue
/// gathers the savings only then.
/// Whether `tick` has work at `now` (a new minute or day).
pub fn due(&self, now: i64) -> bool {
now.div_euclid(60_000) != self.minute || now.div_euclid(DAY_MS) > self.state.day
}
@@ -279,12 +232,7 @@ impl Report {
/// Once a second from the glue. At the day's turn: the day before is
/// kept and, if on, told to everyone. Returns the lines, and whether
/// `web/economy.json` is due (once a minute).
pub fn tick(
&mut self,
economy: &mut Economy,
savings: &HashMap<u128, i64>,
now: i64,
) -> (Vec<Out>, bool) {
pub fn tick(&mut self, economy: &mut Economy, now: i64) -> (Vec<Out>, bool) {
let minute = now.div_euclid(60_000);
let web = self.config.web_json && minute != self.minute;
self.minute = minute;
@@ -296,7 +244,7 @@ impl Report {
self.state.day = today;
self.dirty = true;
// The supply now is yesterday's end, give or take a second.
let d = Self::snapshot(economy, savings, yesterday);
let d = Self::snapshot(economy, yesterday);
let before = self
.state
.days
@@ -318,7 +266,7 @@ impl Report {
fn daily_line(&self, economy: &Economy, d: &Day, before: Option<i64>) -> String {
format!(
"Economy yesterday: money supply {}{}; the server made {} and destroyed {}; taxes and fees {} to the Treasury (now {}), which paid out {}. /eco stats",
"Economy yesterday: money supply {}{}; the server made {} and destroyed {}. /eco stats",
economy.money(d.supply),
before.map_or(String::new(), |b| {
let c = d.supply - b;
@@ -329,27 +277,15 @@ impl Report {
)
}),
economy.money(d.minted),
economy.money(d.burned),
economy.money(d.taxes),
economy.money(d.treasury),
economy.money(d.spent)
economy.money(d.burned)
)
}
/// `web/economy.json`: aggregates, the Treasury's every movement (public
/// in game too), the tax code, the days kept.
pub fn web(
&self,
economy: &mut Economy,
savings: &HashMap<u128, i64>,
taxes: Value,
now: i64,
) -> Value {
/// `web/economy.json`: aggregates and the days kept.
pub fn web(&self, economy: &mut Economy, now: i64) -> Value {
let today = now.div_euclid(DAY_MS);
let d = Self::snapshot(economy, savings, today);
let d = Self::snapshot(economy, today);
let flows = economy.flows().clone();
let t = economy.treasury().clone();
let fresh = t.day == today;
let pools: BTreeMap<String, i64> = economy.pools().clone();
let daily = self.state.days.last().map(|y| {
self.daily_line(
@@ -372,16 +308,6 @@ impl Report {
"pools": pools,
"minted_today": flows.minted,
"burned_today": flows.burned,
"treasury": {
"balance": economy.pool(TREASURY),
"insurance": economy.pool(INSURANCE),
"income": t.income,
"spent": t.spent,
"income_today": if fresh { t.income_today } else { BTreeMap::new() },
"spent_today": if fresh { t.spent_today } else { BTreeMap::new() },
"log": t.log,
},
"taxes": taxes,
"days": self.state.days,
"daily_report": daily,
})
@@ -419,15 +345,13 @@ mod tests {
}
#[test]
fn stats_show_what_was_made_destroyed_and_taxed_today() {
fn stats_show_what_was_made_and_destroyed_today() {
let (mut e, r) = setup("stats");
e.adjust("sell 20 minecraft:wheat", A, 600, "").unwrap();
e.adjust("daily_reward", B, 50, "").unwrap();
e.adjust("buy 5 minecraft:torch", A, -100, "").unwrap();
e.to_pool("vat", A, TREASURY, 25, "").unwrap();
let out = r
.command(&mut e, &HashMap::from([(B, 0)]), "eco stats")
.unwrap();
e.to_pool("ah_bid", A, "auction", 25, "").unwrap();
let out = r.command(&mut e, "eco stats").unwrap();
let text: Vec<String> = out
.iter()
.map(|o| match o {
@@ -435,52 +359,40 @@ mod tests {
o => panic!("{o:?}"),
})
.collect();
assert!(
text[0].starts_with("Money supply: 550 coins. Players hold 525 coins"),
"{}",
text[0]
assert_eq!(
text[0],
"Money supply: 550 coins. Players hold 550 coins (of it in pools: auction 25 coins), the casino's pot 0 coins."
);
assert_eq!(
text[1],
"Today the server made 650 coins (market sales 600 coins, daily reward 50 coins) and destroyed 100 coins (market purchases 100 coins)."
);
assert!(
text[2].starts_with("Taxes and fees today: 25 coins (vat 25 coins)"),
"{}",
text[2]
);
assert!(r.command(&mut e, &HashMap::new(), "eco give A 5").is_none());
assert!(!text.iter().any(|l| l.contains("Treasury")), "{text:?}");
assert!(r.command(&mut e, "eco give A 5").is_none());
}
#[test]
fn midnight_reports_the_day_before_once_and_keeps_it_through_a_restart() {
let (mut e, mut r) = setup("daily");
e.adjust("sell 1 minecraft:diamond", A, 100, "").unwrap();
e.to_pool("vat", A, TREASURY, 5, "").unwrap();
let (out, web) = r.tick(&mut e, &HashMap::new(), T0 + 2000);
let (out, web) = r.tick(&mut e, T0 + 2000);
assert!(out.is_empty() && web, "same day; the JSON's first minute");
assert!(
!r.tick(&mut e, &HashMap::new(), T0 + 3000).1,
"once a minute"
);
assert!(!r.tick(&mut e, T0 + 3000).1, "once a minute");
e.now = T0 + DAY_MS + 500;
let now = e.now;
let (out, _) = r.tick(&mut e, &HashMap::new(), now);
let (out, _) = r.tick(&mut e, now);
assert_eq!(
out,
[Out::All(
"Economy yesterday: money supply 100 coins; the server made 100 coins and destroyed 0 coins; taxes and fees 5 coins to the Treasury (now 5 coins), which paid out 0 coins. /eco stats"
"Economy yesterday: money supply 100 coins; the server made 100 coins and destroyed 0 coins. /eco stats"
.into()
)]
);
assert!(
r.tick(&mut e, &HashMap::new(), now + 1000).0.is_empty(),
"once"
);
let json = r.web(&mut e, &HashMap::new(), json!({}), now);
assert!(r.tick(&mut e, now + 1000).0.is_empty(), "once");
let json = r.web(&mut e, now);
assert_eq!(json["schema"], 1);
assert_eq!(json["days"][0]["minted"], 100);
assert_eq!(json["treasury"]["balance"], 5);
assert!(json.get("treasury").is_none());
assert!(
json["daily_report"]
.as_str()
+497
View File
@@ -0,0 +1,497 @@
// The SMP's emergency switches: an operator of level 3 turns parts of the SMP
// off (`/smp off [part] [reason]`) and on again (`/smp on [part]`) while the
// server runs, kept in `switches.json` so a restart keeps them off. A part
// that is off takes no command and no menu click, and its timed work (daily
// rewards, auctions ending, the lottery's draw) waits; nothing held is lost: it goes on when the part is on
// again. `/smp off` alone turns off every part but land protection, which an
// operator turns off by name (`/smp off claims`). No game types. Added to the
// game as `game::smp_switch`; 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;
use std::path::{Path, PathBuf};
/// The operator level that turns parts off and on.
pub const LEVEL: u8 = 3;
pub const COMMANDS: [&str; 1] = ["smp"];
/// A part of the SMP that can be turned off.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Part {
/// Balances, `/pay`, `/eco` and the daily reward.
Economy,
/// `/worth`, `/sell`, `/buy`, `/shop`, `/market`.
Market,
/// `/ah`.
Auction,
/// `/trade`.
Trades,
/// `/coinflip`, `/lottery`, `/casino`.
Gambling,
/// Spawn, homes, warps, `/tpa`, `/back`.
Places,
/// Land protection and `/claim`.
Claims,
/// The sidebar and the Tab list's header and footer.
Board,
/// Welcomes, AFK and the commands' rate limit.
Presence,
/// Nicknames.
Nicks,
/// `/invsee`, `/endersee`.
Invsee,
}
impl Part {
pub const ALL: [Part; 11] = [
Part::Economy,
Part::Market,
Part::Auction,
Part::Trades,
Part::Gambling,
Part::Places,
Part::Claims,
Part::Board,
Part::Presence,
Part::Nicks,
Part::Invsee,
];
pub fn name(self) -> &'static str {
match self {
Part::Economy => "economy",
Part::Market => "market",
Part::Auction => "auction",
Part::Trades => "trades",
Part::Gambling => "gambling",
Part::Places => "places",
Part::Claims => "claims",
Part::Board => "board",
Part::Presence => "presence",
Part::Nicks => "nicks",
Part::Invsee => "invsee",
}
}
pub fn named(name: &str) -> Option<Part> {
Part::ALL
.into_iter()
.find(|p| p.name().eq_ignore_ascii_case(name))
}
/// The part a command belongs to (its first word); None for `/smp` and
/// what isn't the SMP's.
pub fn of_command(word: &str) -> Option<Part> {
Some(match word {
"balance" | "bal" | "pay" | "baltop" | "eco" | "money" => Part::Economy,
"worth" | "sell" | "buy" | "shop" | "market" => Part::Market,
"ah" => Part::Auction,
"trade" => Part::Trades,
"coinflip" | "lottery" | "casino" => Part::Gambling,
"claim" | "unclaim" | "claims" | "trust" | "untrust" => Part::Claims,
"sidebar" => Part::Board,
"afk" => Part::Presence,
"nick" | "realname" => Part::Nicks,
"invsee" | "endersee" => Part::Invsee,
"spawn" | "setspawn" | "home" | "sethome" | "delhome" | "homes" | "warp" | "warps"
| "setwarp" | "delwarp" | "tpa" | "tpaccept" | "tpdeny" | "rules" | "back"
| "playtime" => Part::Places,
_ => return None,
})
}
}
/// Why and by whom a part is off.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Off {
pub by: String,
/// When (ms).
pub at: i64,
pub reason: String,
}
/// `switches.json`: the parts that are off, by name.
#[derive(Default, Serialize, Deserialize)]
#[serde(default)]
struct Data {
off: BTreeMap<String, Off>,
}
pub struct Switches {
path: PathBuf,
data: Data,
changed: bool,
}
/// What a `/smp` command changed, for the glue to carry out.
#[derive(Debug, Default, PartialEq)]
pub struct Changed {
pub off: Vec<Part>,
pub on: Vec<Part>,
}
impl Switches {
/// `switches.json` in `dir`. A file that can't be read turns nothing off
/// (and is logged by the caller): the switches are for emergencies, not a
/// reason for the SMP to stop.
pub fn open(dir: &Path) -> (Self, Option<String>) {
let path = dir.join("switches.json");
let (data, problem) = match smp_store::read_json(&path) {
Ok(d) => (d, None),
Err(e) => (Data::default(), Some(e)),
};
let mut s = Self {
path,
data,
changed: false,
};
// Names of parts this version doesn't know are dropped.
let before = s.data.off.len();
s.data.off.retain(|name, _| Part::named(name).is_some());
s.changed = s.data.off.len() != before;
(s, problem)
}
pub fn save(&mut self) -> Result<(), String> {
if self.changed {
smp_store::write_json(&self.path, &self.data)?;
self.changed = false;
}
Ok(())
}
pub fn on(&self, part: Part) -> bool {
!self.data.off.contains_key(part.name())
}
/// Why a part is off, as the operator said.
pub fn why(&self, part: Part) -> Option<&str> {
self.data.off.get(part.name()).map(|o| o.reason.as_str())
}
/// `/smp`, `/smp status`, `/smp off [part|all] [reason]`,
/// `/smp on [part|all]`: the answer and what changed. None for another
/// command.
pub fn command(&mut self, me: &Player, now: i64, line: &str) -> Option<(Vec<Out>, Changed)> {
let words: Vec<&str> = line.split_whitespace().collect();
if words.first() != Some(&"smp") {
return None;
}
let none = Changed::default();
if me.level < LEVEL {
return Some((
vec![Out::Error(
"Only operators turn the SMP's parts off and on.".into(),
)],
none,
));
}
let usage = || {
Out::Error(format!(
"Usage: /smp status, /smp off [part|all] [reason], /smp on [part|all]. Parts: {}.",
Part::ALL.map(Part::name).join(", ")
))
};
let (turn_off, rest) = match words[1..] {
[] | ["status"] => return Some((self.status(), none)),
["off", ref rest @ ..] => (true, rest),
["on", ref rest @ ..] => (false, rest),
_ => return Some((vec![usage()], none)),
};
// The part, or every one: `all`, or none named.
let (parts, reason): (Vec<Part>, &[&str]) = match rest.first() {
None => (everything(turn_off), &[]),
Some(w) if w.eq_ignore_ascii_case("all") => (everything(turn_off), &rest[1..]),
Some(w) => match Part::named(w) {
Some(p) => (vec![p], &rest[1..]),
// `/smp off <reason>` turns off everything (but claims).
None if turn_off => (everything(true), rest),
None => return Some((vec![usage()], none)),
},
};
let mut changed = Changed::default();
for p in parts {
if turn_off && self.on(p) {
let off = Off {
by: me.name.clone(),
at: now,
reason: reason.join(" "),
};
self.data.off.insert(p.name().into(), off);
changed.off.push(p);
} else if !turn_off && self.data.off.remove(p.name()).is_some() {
changed.on.push(p);
}
}
self.changed |= !changed.off.is_empty() || !changed.on.is_empty();
let names = |ps: &[Part]| ps.iter().map(|p| p.name()).collect::<Vec<_>>().join(", ");
let out = match (turn_off, changed.off.is_empty() && changed.on.is_empty()) {
(true, true) => vec![Out::Reply("Already off.".into())],
(false, true) => vec![Out::Reply("Already on.".into())],
(true, false) => {
let why = if reason.is_empty() {
String::new()
} else {
format!(" ({})", reason.join(" "))
};
let mut out = vec![Out::Reply(format!(
"Turned off: {}{why}. /smp on turns them on again.",
names(&changed.off)
))];
if !self.on(Part::Claims) || changed.off.contains(&Part::Claims) {
out.push(Out::Reply(
"Land is not protected while claims are off.".into(),
));
} else {
out.push(Out::Reply(
"Land protection stays on (/smp off claims turns it off too).".into(),
));
}
out
}
(false, false) => vec![Out::Reply(format!(
"Turned on again: {}.",
names(&changed.on)
))],
};
Some((out, changed))
}
/// What is off, by whom, since when and why.
pub fn status(&self) -> Vec<Out> {
if self.data.off.is_empty() {
return vec![Out::Reply("Every part of the SMP is on.".into())];
}
let mut out = vec![Out::Reply(format!(
"Off: {} of {} parts.",
self.data.off.len(),
Part::ALL.len()
))];
for (name, off) in &self.data.off {
let when = utc_minute(off.at);
let why = if off.reason.is_empty() {
String::new()
} else {
format!(": {}", off.reason)
};
out.push(Out::Reply(format!(
"- {name}, by {} at {when} UTC{why}",
off.by
)));
}
out
}
}
/// The answer to a command of a part that is off.
pub fn refusal(part: Part) -> Out {
Out::Error(format!(
"{} is turned off for now by the server's staff.",
match part {
Part::Economy => "The economy",
Part::Market => "The market",
Part::Auction => "The auction house",
Part::Trades => "Trading",
Part::Gambling => "Gambling",
Part::Places => "Teleporting",
Part::Claims => "Claiming",
Part::Board => "The sidebar",
Part::Presence => "AFK",
Part::Nicks => "Nicknames",
Part::Invsee => "Inventory viewing",
}
))
}
/// Every part, but land protection when turning off.
fn everything(turn_off: bool) -> Vec<Part> {
Part::ALL
.into_iter()
.filter(|&p| !turn_off || p != Part::Claims)
.collect()
}
/// `2026-11-02 14:05` from ms since the epoch (UTC).
fn utc_minute(ms: i64) -> String {
let secs = ms.div_euclid(1000);
let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
// Howard Hinnant's civil_from_days.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!(
"{y:04}-{m:02}-{d:02} {:02}:{:02}",
rem / 3600,
rem % 3600 / 60
)
}
#[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-switch-{}-{n}", std::process::id()))
}
fn op() -> Player {
Player {
uuid: 1,
name: "luzW".into(),
level: 3,
}
}
fn texts(out: &[Out]) -> String {
out.iter()
.map(|o| match o {
Out::Reply(s) | Out::Error(s) | Out::Tell(_, s) | Out::All(s) => s.as_str(),
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn every_command_of_the_smp_belongs_to_a_part() {
// The glue's own (its `COMMANDS`), then each part's.
let glue = [
"balance", "bal", "pay", "baltop", "eco", "money", "worth", "sell", "buy", "shop",
"market", "trade", "coinflip", "lottery", "ah", "casino",
];
let parts = [
&super::super::smp_places::COMMANDS[..],
&super::super::smp_claims::COMMANDS,
&super::super::smp_board::COMMANDS,
&super::super::smp_presence::COMMANDS,
&super::super::smp_nick::COMMANDS,
&super::super::smp_invsee::COMMANDS,
];
for word in glue.into_iter().chain(parts.into_iter().flatten().copied()) {
assert!(Part::of_command(word).is_some(), "{word}");
}
assert_eq!(
Part::of_command("smp"),
None,
"the switch itself is never off"
);
assert_eq!(
Part::of_command("tp"),
None,
"vanilla's commands are not the SMP's"
);
}
#[test]
fn off_alone_turns_off_everything_but_claims_and_survives_a_restart() {
let d = dir();
let (mut s, problem) = Switches::open(&d);
assert!(problem.is_none());
let (out, changed) = s
.command(&op(), 1_762_092_300_000, "smp off market exploit")
.unwrap();
assert_eq!(changed.off, vec![Part::Market], "a named part alone");
assert!(
texts(&out).contains("Turned off: market (exploit)"),
"{}",
texts(&out)
);
let (out, changed) = s
.command(&op(), 1_762_092_300_000, "smp off duping bug")
.unwrap();
assert_eq!(
changed.off.len(),
Part::ALL.len() - 2,
"all but claims and the market already off"
);
assert!(!changed.off.contains(&Part::Claims));
assert!(
texts(&out).contains("Land protection stays on"),
"{}",
texts(&out)
);
assert!(s.on(Part::Claims) && !s.on(Part::Economy) && !s.on(Part::Invsee));
s.save().unwrap();
let (s, _) = Switches::open(&d);
assert!(!s.on(Part::Market) && !s.on(Part::Gambling) && s.on(Part::Claims));
let status = texts(&s.status());
assert!(
status.contains("- market, by luzW at 2025-11-02 14:05 UTC: exploit"),
"{status}"
);
assert!(
status.contains("- economy, by luzW at 2025-11-02 14:05 UTC: duping bug"),
"{status}"
);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn claims_go_off_only_by_name_and_on_with_the_rest() {
let d = dir();
let (mut s, _) = Switches::open(&d);
s.command(&op(), 0, "smp off all");
assert!(s.on(Part::Claims));
let (out, changed) = s.command(&op(), 0, "smp off claims grief").unwrap();
assert_eq!(changed.off, vec![Part::Claims]);
assert!(
texts(&out).contains("Land is not protected"),
"{}",
texts(&out)
);
let (_, changed) = s.command(&op(), 0, "smp on").unwrap();
assert_eq!(changed.on.len(), Part::ALL.len());
assert!(Part::ALL.iter().all(|&p| s.on(p)));
let (out, changed) = s.command(&op(), 0, "smp on market").unwrap();
assert_eq!(changed, Changed::default());
assert_eq!(texts(&out), "Already on.");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn players_below_level_three_change_nothing() {
let d = dir();
let (mut s, _) = Switches::open(&d);
let player = Player {
uuid: 2,
name: "Steve".into(),
level: 2,
};
let (out, changed) = s.command(&player, 0, "smp off").unwrap();
assert_eq!(changed, Changed::default());
assert!(matches!(&out[0], Out::Error(_)));
assert!(Part::ALL.iter().all(|&p| s.on(p)));
assert!(s.command(&player, 0, "balance").is_none());
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn a_damaged_file_turns_nothing_off_and_is_reported() {
let d = dir();
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("switches.json"), "{ not json").unwrap();
let (s, problem) = Switches::open(&d);
assert!(problem.is_some());
assert!(Part::ALL.iter().all(|&p| s.on(p)));
// A part this version doesn't know is dropped.
std::fs::write(
d.join("switches.json"),
r#"{"off": {"rockets": {}, "market": {"by": "x"}}}"#,
)
.unwrap();
let (s, _) = Switches::open(&d);
assert!(!s.on(Part::Market));
assert_eq!(s.data.off.len(), 1);
let _ = std::fs::remove_dir_all(d);
}
}
-676
View File
@@ -1,676 +0,0 @@
// The tax code and the Treasury's commands. Every tax, fee and house edge
// goes into the Treasury pool (smp_economy's `TREASURY`), never away, and
// operators pay only from it (`/treasury pay`): nobody makes money. Weekly
// taxes (land per claimed chunk, a light progressive wealth tax) are
// collected on Monday 00:00 UTC, looked at once a second for an O(1) "due?".
// `/eco taxes` explains the rates and a player's own bill; `/treasury` shows
// the balance, income by tax and spending; `/treasury log` the last
// movements. The tax state (the week collected, unpaid land tax) is kept in
// `economy.json` with the balances (`Economy::ext("tax")`). No game types.
// Added to the game as `game::smp_tax`; also a module of this crate, for its
// tests.
use super::smp_economy::{DAY_MS, Economy, INSURANCE, Out, Player, TREASURY, parse_amount};
use super::smp_store::uuid_string;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
/// `config.json`'s `taxes`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// Weekly taxes on (land, wealth); the per-sale ones are their parts'.
pub enabled: bool,
/// VAT on sales between players (the auction house), paid by the seller
/// from the price, in percent.
pub vat_percent: i64,
/// Claimed chunks nobody pays for.
pub land_free_chunks: usize,
/// Land tax a week per claimed chunk past the free ones.
pub land_tax_per_chunk: i64,
/// `[from, percent]`: each week, `percent` of the part of a balance
/// above `from` (up to the next bracket's `from`).
pub wealth_brackets: Vec<[i64; 2]>,
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: true,
vat_percent: 5,
land_free_chunks: 4,
land_tax_per_chunk: 5,
wealth_brackets: vec![[50_000, 1], [250_000, 2], [1_000_000, 3]],
}
}
}
/// One weekly collection, for `/treasury` and the report.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Collection {
pub week: i64,
pub land: i64,
pub wealth: i64,
/// Land tax players couldn't pay (added to their arrears).
pub unpaid: i64,
pub payers: usize,
}
/// The tax state, in `economy.json` (`ext.tax`).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct State {
/// The last week collected (Monday-based week number).
pub week: i64,
/// Land tax owed, by UUID: no new claim until it's paid.
pub arrears: BTreeMap<String, i64>,
pub last: Option<Collection>,
}
/// The week number of `now` (ms), weeks starting Monday 00:00 UTC (1970-01-01
/// was a Thursday).
pub fn week(now: i64) -> i64 {
(now.div_euclid(DAY_MS) + 3).div_euclid(7)
}
/// When the week after `week` starts (ms).
pub fn week_start(week: i64) -> i64 {
(week * 7 - 3) * DAY_MS
}
pub struct Taxes {
pub config: Config,
pub state: State,
dirty: bool,
/// What each player has in the bank (the glue sets it): the wealth tax
/// counts it with the balance, so saving doesn't hide wealth.
pub savings: HashMap<u128, i64>,
}
/// `12d 3h`, `3h 05m`, `4m`.
pub fn span(ms: i64) -> String {
let m = ms.max(0) / 60_000;
let (d, h, m) = (m / 1440, m / 60 % 24, m % 60);
if d > 0 {
format!("{d}d {h}h")
} else if h > 0 {
format!("{h}h {m:02}m")
} else {
format!("{m}m")
}
}
impl Taxes {
/// The taxes, their state read from the economy (none yet: the first
/// collection is next Monday, never weeks back).
pub fn open(economy: &Economy, config: Config) -> Self {
let state = economy
.ext("tax")
.and_then(|v| serde_json::from_value::<State>(v.clone()).ok());
let dirty = state.is_none();
let state = state.unwrap_or_else(|| State {
week: week(economy.now),
..State::default()
});
Self {
config,
state,
dirty,
savings: HashMap::new(),
}
}
/// Writes the state into the economy (saved with the balances).
pub fn store(&mut self, economy: &mut Economy) {
if self.dirty {
economy.set_ext("tax", serde_json::to_value(&self.state).unwrap_or_default());
self.dirty = false;
}
}
/// The land tax a week for `chunks` claimed chunks.
pub fn land_bill(&self, chunks: usize) -> i64 {
let taxed = chunks.saturating_sub(self.config.land_free_chunks) as i64;
taxed.saturating_mul(self.config.land_tax_per_chunk.max(0))
}
/// The wealth tax a week on `balance`.
pub fn wealth_bill(&self, balance: i64) -> i64 {
let mut brackets = self.config.wealth_brackets.clone();
brackets.sort();
let mut bill: i128 = 0;
for (i, [from, percent]) in brackets.iter().enumerate() {
let to = brackets.get(i + 1).map_or(i64::MAX, |b| b[0]);
let part = balance.min(to) - from;
if part > 0 {
bill += i128::from(part) * i128::from((*percent).clamp(0, 100)) / 100;
}
}
bill as i64
}
/// What `uuid` owes in land tax.
pub fn arrears(&self, uuid: u128) -> i64 {
self.state
.arrears
.get(&uuid_string(uuid))
.copied()
.unwrap_or(0)
}
/// Whether `uuid` may claim more land: not while they owe land tax.
pub fn may_claim(&self, economy: &Economy, uuid: u128) -> Result<(), String> {
match self.arrears(uuid) {
0 => Ok(()),
owed => Err(format!(
"You owe {} of land tax: no new claim until it's paid (/eco taxes pay).",
economy.money(owed)
)),
}
}
/// Whether a weekly collection is due (cheap: once a second).
pub fn due(&self, now: i64) -> bool {
self.config.enabled && week(now) > self.state.week
}
/// Collects the week's land and wealth taxes into the Treasury. `chunks`:
/// claimed chunks by owner. Unpaid land tax becomes arrears; the wealth
/// tax takes only what is there (it never exceeds the balance).
pub fn collect(
&mut self,
economy: &mut Economy,
chunks: &HashMap<u128, usize>,
now: i64,
) -> Vec<Out> {
let this = week(now);
if this <= self.state.week {
return Vec::new();
}
self.state.week = this;
self.dirty = true;
let mut c = Collection {
week: this,
..Collection::default()
};
let mut out = Vec::new();
let mut owners: Vec<(u128, usize)> = chunks.iter().map(|(u, n)| (*u, *n)).collect();
owners.sort();
for (uuid, n) in owners {
let key = uuid_string(uuid);
let owed = self.land_bill(n) + self.arrears(uuid);
if owed <= 0 {
continue;
}
let paid = owed.min(economy.balance(uuid));
if paid > 0
&& economy
.to_pool("land_tax", uuid, TREASURY, paid, "")
.is_err()
{
continue;
}
c.land += paid;
c.payers += 1;
let left = owed - paid;
if left > 0 {
c.unpaid += left;
self.state.arrears.insert(key, left);
out.push(Out::Tell(
uuid,
format!(
"Land tax: you paid {} and still owe {}. No new claim until it's paid (/eco taxes pay).",
economy.money(paid),
economy.money(left)
),
));
} else {
self.state.arrears.remove(&key);
out.push(Out::Tell(
uuid,
format!("Land tax for {n} chunks: {}.", economy.money(paid)),
));
}
}
for (uuid, _, balance) in economy.accounts() {
let saved = self.savings.get(&uuid).copied().unwrap_or(0);
let bill = self.wealth_bill(balance.saturating_add(saved)).min(balance);
if bill > 0
&& economy
.to_pool("wealth_tax", uuid, TREASURY, bill, "")
.is_ok()
{
c.wealth += bill;
out.push(Out::Tell(
uuid,
format!("Wealth tax this week: {}.", economy.money(bill)),
));
}
}
out.push(Out::All(format!(
"Weekly taxes collected: land {}, wealth {}. Treasury: {}.",
economy.money(c.land),
economy.money(c.wealth),
economy.money(economy.pool(TREASURY))
)));
self.state.last = Some(c);
out
}
/// Runs `/treasury ...` and `/eco taxes ...`; None for another command.
/// `my_chunks`: the chunks `me` holds.
pub fn command(
&mut self,
economy: &mut Economy,
me: &Player,
my_chunks: usize,
line: &str,
) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
Some(match words.as_slice() {
["treasury"] => self.treasury(economy),
["treasury", "log"] => self.log(economy),
["treasury", "pay", who, amount, reason @ ..] => {
self.pay(economy, me, who, amount, &reason.join(" "))
}
["treasury", ..] => vec![Out::Error(
"Usage: /treasury, /treasury log, /treasury pay <player> <amount> <reason>".into(),
)],
["eco" | "taxes", "taxes"] | ["taxes"] => self.explain(economy, me, my_chunks),
["eco" | "taxes", "taxes", "pay"] | ["taxes", "pay"] => self.pay_arrears(economy, me),
_ => return None,
})
}
fn treasury(&self, economy: &Economy) -> Vec<Out> {
let t = economy.treasury();
let list = |m: &BTreeMap<String, i64>| -> String {
let mut v: Vec<(&String, &i64)> = m.iter().collect();
v.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
let parts: Vec<String> = v
.iter()
.map(|(k, a)| format!("{} {}", k.replace('_', " "), economy.money(**a)))
.collect();
if parts.is_empty() {
"nothing".into()
} else {
parts.join(", ")
}
};
let today = t.day == economy.now.div_euclid(DAY_MS);
let empty = BTreeMap::new();
let (inc, out) = if today {
(&t.income_today, &t.spent_today)
} else {
(&empty, &empty)
};
let mut lines = vec![
Out::Reply(format!(
"Treasury: {} (insurance fund {}). Taxes, fees and house edges go in; grants, refunds and prizes come out. Nobody can make money.",
economy.money(economy.pool(TREASURY)),
economy.money(economy.pool(INSURANCE))
)),
Out::Reply(format!("Today in: {}. Out: {}.", list(inc), list(out))),
Out::Reply(format!(
"Since it opened, in: {}. Out: {}.",
list(&t.income),
list(&t.spent)
)),
];
if let Some(c) = &self.state.last {
lines.push(Out::Reply(format!(
"Last weekly taxes: land {}, wealth {}, {} unpaid.",
economy.money(c.land),
economy.money(c.wealth),
economy.money(c.unpaid)
)));
}
lines.push(Out::Reply(
"Every payment: /treasury log. The rates: /eco taxes.".into(),
));
lines
}
fn log(&self, economy: &Economy) -> Vec<Out> {
let t = economy.treasury();
if t.log.is_empty() {
return vec![Out::Reply("The Treasury hasn't moved yet.".into())];
}
let mut out = vec![Out::Reply(
"The Treasury's last movements (newest first):".into(),
)];
for m in t.log.iter().rev().take(10) {
let sign = if m.amount >= 0 { "+" } else { "-" };
let mut line = format!(
"{} ago: {sign}{} {}",
span(economy.now - m.time),
economy.money(m.amount.abs()),
m.kind.replace('_', " ")
);
if !m.player.is_empty() {
line.push_str(&format!(" ({})", m.player));
}
if !m.reason.is_empty() {
line.push_str(&format!(": {}", m.reason));
}
if !m.by.is_empty() {
line.push_str(&format!(", by {}", m.by));
}
out.push(Out::Reply(line));
}
out
}
/// `/treasury pay`: an operator pays a player from the Treasury, with a
/// reason everyone sees.
fn pay(
&mut self,
economy: &mut Economy,
me: &Player,
who: &str,
amount: &str,
reason: &str,
) -> Vec<Out> {
if me.level < 3 {
return vec![Out::Error(
"Only operators of level 3 may pay from the Treasury.".into(),
)];
}
let Some(amount) = parse_amount(amount) else {
return vec![Out::Error(
"The amount must be a positive whole number.".into(),
)];
};
let reason = reason.trim();
if reason.chars().count() < 3 {
return vec![Out::Error(
"Say why: /treasury pay <player> <amount> <reason> (an event prize, a bug refund...)".into(),
)];
}
let Some((uuid, account)) = economy.find(who) else {
return vec![Out::Error(format!("No player named {who} has an account."))];
};
let name = account.name.clone();
match economy.out_of_pool(
"grant",
TREASURY,
uuid,
amount,
reason,
&uuid_string(me.uuid),
) {
Ok(()) => vec![Out::All(format!(
"The Treasury paid {} to {name}: {reason} (by {}). Treasury: {}.",
economy.money(amount),
me.name,
economy.money(economy.pool(TREASURY))
))],
Err(e) => vec![Out::Error(e)],
}
}
/// `/eco taxes`: the tax code, and the player's own bill.
fn explain(&self, economy: &Economy, me: &Player, chunks: usize) -> Vec<Out> {
let c = &self.config;
let mut brackets = c.wealth_brackets.clone();
brackets.sort();
let wealth: Vec<String> = brackets
.iter()
.map(|[from, p]| format!("{p}% above {}", economy.money(*from)))
.collect();
let next = week_start(self.state.week + 1) - economy.now;
let land = self.land_bill(chunks);
let saved = self.savings.get(&me.uuid).copied().unwrap_or(0);
let wealth_bill = self.wealth_bill(economy.balance(me.uuid).saturating_add(saved));
let mut out = vec![
Out::Reply("Taxes: all of them go to the Treasury (/treasury), which pays grants, prizes and refunds.".into()),
Out::Reply(format!(
"VAT: {}% of each auction house sale, paid by the seller.",
c.vat_percent
)),
Out::Reply(format!(
"Land tax, weekly: {} a claimed chunk past your first {}. Unpaid tax stops new claims.",
economy.money(c.land_tax_per_chunk),
c.land_free_chunks
)),
Out::Reply(format!("Wealth tax, weekly, on your balance and bank savings: {}.", wealth.join(", "))),
Out::Reply("Games of chance: the house's fee goes to the Treasury.".into()),
];
if !c.enabled {
out.push(Out::Reply(
"The weekly taxes are off on this server.".into(),
));
} else {
out.push(Out::Reply(format!(
"Your bill on Monday (in {}): land {} ({chunks} chunks), wealth {}{}.",
span(next),
economy.money(land),
economy.money(wealth_bill),
match self.arrears(me.uuid) {
0 => String::new(),
a => format!(", owed {}", economy.money(a)),
}
)));
}
out
}
/// `/eco taxes pay`: pays the land tax owed, as much as the balance
/// allows.
fn pay_arrears(&mut self, economy: &mut Economy, me: &Player) -> Vec<Out> {
let owed = self.arrears(me.uuid);
if owed == 0 {
return vec![Out::Reply("You owe no tax.".into())];
}
let paid = owed.min(economy.balance(me.uuid));
if paid <= 0 {
return vec![Out::Error(format!(
"You owe {} and have nothing to pay it with.",
economy.money(owed)
))];
}
if let Err(e) = economy.to_pool("land_tax", me.uuid, TREASURY, paid, "arrears") {
return vec![Out::Error(e)];
}
self.dirty = true;
let key = uuid_string(me.uuid);
if paid == owed {
self.state.arrears.remove(&key);
vec![Out::Reply(format!(
"Paid {} of land tax: you owe nothing.",
economy.money(paid)
))]
} else {
self.state.arrears.insert(key, owed - paid);
vec![Out::Reply(format!(
"Paid {}; you still owe {}.",
economy.money(paid),
economy.money(owed - paid)
))]
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::smp_economy::Config as EcoConfig;
use std::fs;
const ALEX: u128 = 1;
const STEVE: u128 = 2;
const RICH: u128 = 3;
/// Monday 2026-11-02 00:00 UTC.
const MONDAY: i64 = 1_793_577_600_000;
fn setup(name: &str) -> (Economy, Taxes, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("smp-tax-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let mut e = Economy::open(&dir, EcoConfig::default()).unwrap();
e.now = MONDAY - 3_600_000;
for (u, n) in [(ALEX, "Alex"), (STEVE, "Steve"), (RICH, "Rich")] {
e.seen(u, n);
}
e.adjust("sell", ALEX, 1_000, "").unwrap();
e.adjust("sell", STEVE, 20, "").unwrap();
e.adjust("sell", RICH, 300_000, "").unwrap();
let t = Taxes::open(&e, Config::default());
(e, t, dir)
}
fn me(uuid: u128, level: u8) -> Player {
Player {
uuid,
name: "x".into(),
level,
}
}
#[test]
fn weeks_start_on_monday() {
assert_eq!(week(MONDAY) - week(MONDAY - 1), 1);
assert_eq!(week_start(week(MONDAY)), MONDAY);
assert_eq!(week(MONDAY + 6 * DAY_MS), week(MONDAY));
}
#[test]
fn bills_are_progressive() {
let t = Taxes {
config: Config::default(),
state: State::default(),
dirty: false,
savings: HashMap::new(),
};
assert_eq!(t.land_bill(4), 0);
assert_eq!(t.land_bill(16), 60);
assert_eq!(t.wealth_bill(50_000), 0);
assert_eq!(t.wealth_bill(60_000), 100);
// 1% of 200k + 2% of 50k.
assert_eq!(t.wealth_bill(300_000), 3_000);
// 1% of 200k + 2% of 750k + 3% of 1M.
assert_eq!(t.wealth_bill(2_000_000), 2_000 + 15_000 + 30_000);
}
#[test]
fn bank_savings_count_for_the_wealth_tax() {
let (mut e, mut t, _dir) = setup("savings");
// Alex: 1,000 in the wallet, 59,000 saved: taxed on 60,000.
t.savings.insert(ALEX, 59_000);
let now = MONDAY;
t.collect(&mut e, &HashMap::new(), now);
assert_eq!(e.balance(ALEX), 900);
}
#[test]
fn monday_collects_once_into_the_treasury_and_unpaid_land_tax_blocks_claims() {
let (mut e, mut t, dir) = setup("collect");
let chunks = HashMap::from([(ALEX, 10usize), (STEVE, 14), (RICH, 2)]);
assert!(!t.due(e.now), "not before Monday");
assert!(
{
let now = e.now;
t.collect(&mut e, &chunks, now)
}
.is_empty()
);
let total =
|e: &Economy| e.balance(ALEX) + e.balance(STEVE) + e.balance(RICH) + e.pool(TREASURY);
let before = total(&e);
e.now = MONDAY + 1000;
assert!(t.due(e.now));
let out = {
let now = e.now;
t.collect(&mut e, &chunks, now)
};
assert!(matches!(out.last(), Some(Out::All(_))));
// Alex 6 chunks taxed (30), Steve 10 (50, has 20), Rich wealth 3,000.
assert_eq!(
(e.balance(ALEX), e.balance(STEVE), e.balance(RICH)),
(970, 0, 297_000)
);
assert_eq!(e.pool(TREASURY), 30 + 20 + 3_000);
assert_eq!(total(&e), before, "taxes move money, never make or burn it");
assert_eq!(t.arrears(STEVE), 30);
assert!(t.may_claim(&e, STEVE).is_err() && t.may_claim(&e, ALEX).is_ok());
// Twice the same week: nothing.
assert!(!t.due(e.now));
assert!(
{
let now = e.now + 1000;
t.collect(&mut e, &chunks, now)
}
.is_empty()
);
// Steve earns and pays what he owes.
e.adjust("sell", STEVE, 100, "").unwrap();
let out = t
.command(&mut e, &me(STEVE, 0), 14, "eco taxes pay")
.unwrap();
assert_eq!(
out,
[Out::Reply(
"Paid 30 coins of land tax: you owe nothing.".into()
)]
);
assert!(t.may_claim(&e, STEVE).is_ok());
let tr = e.treasury();
assert_eq!(
(tr.income["land_tax"], tr.income["wealth_tax"]),
(80, 3_000)
);
// The state survives a restart with the balances.
t.store(&mut e);
e.save().unwrap();
let mut again = Economy::open(&dir, EcoConfig::default()).unwrap();
again.now = e.now;
let t2 = Taxes::open(&again, Config::default());
assert_eq!(t2.state.week, week(MONDAY));
assert!(!t2.due(again.now), "a restart doesn't collect again");
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn only_operators_pay_from_the_treasury_and_never_more_than_it_holds() {
let (mut e, mut t, dir) = setup("pay");
e.to_pool("vat", RICH, TREASURY, 500, "").unwrap();
let steve = me(STEVE, 0);
assert!(matches!(
&t.command(&mut e, &steve, 0, "treasury pay Steve 10 because")
.unwrap()[0],
Out::Error(_)
));
let op = Player {
uuid: ALEX,
name: "Alex".into(),
level: 3,
};
assert!(
matches!(&t.command(&mut e, &op, 0, "treasury pay Steve 10").unwrap()[0], Out::Error(s) if s.starts_with("Say why"))
);
assert!(matches!(
&t.command(&mut e, &op, 0, "treasury pay Steve 501 too much")
.unwrap()[0],
Out::Error(_)
));
assert_eq!(
t.command(&mut e, &op, 0, "treasury pay Steve 200 bug refund #12").unwrap(),
[Out::All("The Treasury paid 200 coins to Steve: bug refund #12 (by Alex). Treasury: 300 coins.".into())]
);
assert_eq!(e.balance(STEVE), 220);
let log = t.command(&mut e, &steve, 0, "treasury log").unwrap();
assert!(
matches!(&log[1], Out::Reply(s) if s.contains("-200 coins grant (Steve): bug refund #12, by Alex")),
"{log:?}"
);
let shown = t.command(&mut e, &steve, 0, "treasury").unwrap();
assert!(matches!(&shown[0], Out::Reply(s) if s.starts_with("Treasury: 300 coins")));
let taxes = t.command(&mut e, &steve, 16, "eco taxes").unwrap();
assert!(
taxes
.iter()
.any(|o| matches!(o, Out::Reply(s) if s.contains("land 60 coins (16 chunks)"))),
"{taxes:?}"
);
fs::remove_dir_all(dir).unwrap();
}
}
+226
View File
@@ -0,0 +1,226 @@
// `/vanish` for operators: a vanished operator is out of the other players'
// Tab list and world (whatever their game mode), and nobody reads them
// joining or leaving; operators of `LEVEL` or more still see them. The game
// hides them (`native_hide_player`); this keeps who is vanished in
// `vanish.json`, so it lasts over a restart and a relog, and says what the
// game must be told. The vanished operator reads a subtitle the whole time.
// No game types. Added to the game as `game::smp_vanish`; 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, HashSet};
use std::path::{Path, PathBuf};
/// Who may vanish, and who still sees a vanished player.
pub const LEVEL: u8 = 3;
pub const COMMANDS: [&str; 1] = ["vanish"];
/// The subtitle a vanished operator reads the whole time.
pub const SUBTITLE: &str = "Vanished: only operators see you";
/// `vanish.json`: the vanished players by UUID, with the name last seen.
#[derive(Default, Serialize, Deserialize)]
#[serde(default)]
struct Data {
vanished: BTreeMap<String, String>,
}
pub struct Vanish {
path: PathBuf,
data: Data,
changed: bool,
/// What the game was told this run (hidden or not), by UUID: a player
/// whose entry differs from `data` is told again.
told: HashMap<u128, bool>,
}
impl Vanish {
/// `vanish.json` in `dir`; an error when it exists but can't be read.
pub fn open(dir: &Path) -> Result<Self, String> {
let path = dir.join("vanish.json");
let data = smp_store::read_json(&path)?;
Ok(Self {
path,
data,
changed: false,
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(())
}
pub fn is(&self, uuid: u128) -> bool {
self.data
.vanished
.contains_key(&smp_store::uuid_string(uuid))
}
/// The vanished players, by UUID.
pub fn quiet(&self) -> HashSet<u128> {
self.data
.vanished
.keys()
.filter_map(|k| smp_store::parse_uuid(k))
.collect()
}
/// Whether a player of `level` sees the player `uuid`.
pub fn sees(&self, level: u8, uuid: u128) -> bool {
level >= LEVEL || !self.is(uuid)
}
/// What the game must be told now: (UUID, hidden) for each vanished
/// player not yet hidden this run, and each one shown again.
pub fn changes(&mut self) -> Vec<(u128, bool)> {
let mut out = Vec::new();
for key in self.data.vanished.keys() {
if let Some(u) = smp_store::parse_uuid(key)
&& self.told.get(&u) != Some(&true)
{
out.push((u, true));
}
}
for (&u, &hidden) in &self.told {
if hidden && !self.is(u) {
out.push((u, false));
}
}
for &(u, hidden) in &out {
self.told.insert(u, hidden);
}
self.told.retain(|_, hidden| *hidden);
out
}
/// `/vanish` (toggles), `/vanish list`: the answer, and the lines for the
/// other operators on (`ops`). None for another command.
pub fn command(&mut self, me: &Player, ops: &[u128], line: &str) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
if words.first() != Some(&"vanish") {
return None;
}
if me.level < LEVEL {
return Some(vec![Out::Error("Only operators can vanish.".into())]);
}
let key = smp_store::uuid_string(me.uuid);
match words[1..] {
[] => {}
["list"] => {
if self.data.vanished.is_empty() {
return Some(vec![Out::Reply("Nobody is vanished.".into())]);
}
let names: Vec<&str> = self.data.vanished.values().map(String::as_str).collect();
return Some(vec![Out::Reply(format!("Vanished: {}.", names.join(", ")))]);
}
_ => return Some(vec![Out::Error("Usage: /vanish, /vanish list".into())]),
}
self.changed = true;
let (reply, staff) = if self.data.vanished.remove(&key).is_some() {
(
"You are visible again.",
format!("[Staff] {} is visible again.", me.name),
)
} else {
self.data.vanished.insert(key, me.name.clone());
(
"You vanished: other players can't see you, in the Tab list or the world; operators still can.",
format!("[Staff] {} vanished.", me.name),
)
};
let mut out = vec![Out::Reply(reply.into())];
out.extend(
ops.iter()
.filter(|&&u| u != me.uuid)
.map(|&u| Out::Tell(u, staff.clone())),
);
Some(out)
}
}
#[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-vanish-{}-{n}", std::process::id()))
}
fn player(uuid: u128, level: u8) -> Player {
Player {
uuid,
name: format!("P{uuid}"),
level,
}
}
#[test]
fn an_operator_vanishes_and_comes_back_and_the_game_is_told_once_each() {
let d = dir();
let mut v = Vanish::open(&d).unwrap();
let out = v.command(&player(1, 3), &[1, 2], "vanish").unwrap();
assert!(
matches!(&out[0], Out::Reply(s) if s.starts_with("You vanished")),
"{out:?}"
);
assert_eq!(
out[1],
Out::Tell(2, "[Staff] P1 vanished.".into()),
"the other operators are told"
);
assert!(v.is(1));
assert!(
!v.sees(0, 1) && !v.sees(2, 1),
"players below level 3 don't see them"
);
assert!(v.sees(3, 1) && v.sees(4, 1), "operators do");
assert!(v.sees(0, 2), "nobody else is hidden");
assert_eq!(v.changes(), vec![(1, true)]);
assert!(v.changes().is_empty(), "told once");
v.command(&player(1, 4), &[], "vanish").unwrap();
assert!(!v.is(1));
assert_eq!(v.changes(), vec![(1, false)]);
assert!(v.changes().is_empty());
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn a_vanish_lasts_over_a_restart_and_the_game_is_told_again() {
let d = dir();
let mut v = Vanish::open(&d).unwrap();
v.command(&player(1, 3), &[], "vanish").unwrap();
v.changes();
v.save().unwrap();
let mut v = Vanish::open(&d).unwrap();
assert!(v.is(1));
assert_eq!(
v.changes(),
vec![(1, true)],
"a new run hides them again, on or off"
);
let out = v.command(&player(5, 3), &[], "vanish list").unwrap();
assert_eq!(out, vec![Out::Reply("Vanished: P1.".into())]);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn players_below_level_three_cant_vanish() {
let d = dir();
let mut v = Vanish::open(&d).unwrap();
let out = v.command(&player(1, 2), &[], "vanish").unwrap();
assert!(matches!(&out[0], Out::Error(_)));
assert!(!v.is(1) && v.changes().is_empty());
assert!(v.command(&player(1, 3), &[], "balance").is_none());
let _ = std::fs::remove_dir_all(d);
}
}
+122 -60
View File
@@ -3,9 +3,8 @@ Steve, `horizon-ctl --offscreen`) join it. Checked from the clients' own chat an
- the economy opens an account for each player (config.json written with its defaults, then economy.json);
- /balance, /pay (the payer's answer and the payee's line), refused payments (too much, past what the payee may receive today, to oneself, unknown name);
- the Treasury: an operator (made one from the server's console) pays a player from it with a reason (/treasury pay),
which everyone sees in /treasury and /treasury log; /eco give is refused to everyone (nobody can make money), and a
non-operator can't pay from the Treasury; /eco taxes explains the tax code;
- money from an operator (made one from the server's console): /eco give with a reason everyone reads; a
non-operator can't; /treasury, /bank, /jobs, /futures and /eco taxes are gone, and the old Treasury pool is dropped;
- /baltop;
- the commands are in each player's command tree (the chat box's suggestions);
- the market (Steve in survival, items given from the console): /worth, /sell <item>, /sell hand, /buy, with the
@@ -19,16 +18,18 @@ Steve, `horizon-ctl --offscreen`) join it. Checked from the clients' own chat an
- the lottery: two players' tickets held, one drawn after `lottery_seconds`, the winner announced to everyone and paid
the pot;
- after the server stops cleanly and starts again, the balances are the same, and audit.log holds every change;
- a Treasury payment for an operator, then the /shop menu: a category, one item bought by a click and a stack by
- an operator's /eco give, then the /shop menu: a category, one item bought by a click and a stack by
a shift-click, a stack of the player's own sold by clicking it, "Sell all" stopped by the daily limit (the rest
kept, the limit shown in the menu and in /worth, the day's sales in market.json), the close button;
- after the restart, prices moving: a purchase and a sale taxed (receipts, the Treasury), an item bought can't be sold
- after the restart, prices moving: a purchase and a sale with no tax (receipts), an item bought can't be sold
back while one found can, one price for diamond, moved down by a sale and up by a purchase as they go, /market's item
page buying, the 24 h chart given as a map and drawn in the player's hand (market-chart.png), web/market.json, and
market_prices.json written when the world closes;
- the web pages (web/market.json, web/economy.json) sent gzipped with the token to a stand-in for Kira;
- /invsee: an operator takes a stack from an offline player's saved inventory, logged before the move and summed up in
staff.log;
- the emergency switches: an operator turns the market off (its commands refuse), then everything but land
protection (the economy refuses, the sidebar goes), then everything on again, each in audit.log;
- nicknames, for operators only: a player can't pick one; an operator's refused ones (a player's name, too short, a
bad character), the one given in the other's chat and Tab list, /realname, someone else's taken, the player taking
it off.
@@ -453,7 +454,7 @@ def world_guard(server, alex, steve):
def board(alex, steve, out):
"""The scoreboard sidebar (the player's own block, the richest, the address) and the Tab list; /sidebar off
"""The scoreboard sidebar (the player's own block, then the address) and the Tab list; /sidebar off
hides it, on shows it again."""
r = {}
side = lambda c: c.run("status").get("sidebar")
@@ -463,9 +464,8 @@ def board(alex, steve, out):
break
time.sleep(0.5)
assert r["sidebar"]["objective"] == "horizon:native_sidebar" and r["sidebar"]["title"] == "Horizon SMP", r
# Steve's name, balance, playtime and the players on; a gap, "Richest", Alex and Steve (the two accounts); a gap,
# the address.
assert len(r["sidebar"]["scores"]) == 10, r
# Steve's name, balance, playtime and the players on; a gap, the address. No ranking of the richest.
assert len(r["sidebar"]["scores"]) == 6, 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")
@@ -514,6 +514,37 @@ def polish(server, alex, steve, out):
return r
def switches(alex, data):
"""/smp off and on (Alex is an operator): a part turned off refuses its commands, everything (but land
protection) off takes the sidebar away, and on again brings it all back; switches.json keeps what is off."""
r = {}
side = lambda: alex.run("status").get("sidebar")
r["off_market"] = alex.say("/smp off market proof")
assert any("Turned off: market (proof)" in l for l in r["off_market"]), r
r["worth"] = alex.say("/worth diamond")
assert any("The market is turned off for now" in l for l in r["worth"]), r
r["balance"] = alex.say("/balance")
assert any("Balance:" in l for l in r["balance"]), r
r["status"] = alex.say("/smp status")
assert any("- market, by Alex" in l and ": proof" in l for l in r["status"]), r
r["off_all"] = alex.say("/smp off")
assert any("Land protection stays on" in l for l in r["off_all"]), r
r["balance_off"] = alex.say("/balance")
assert any("The economy is turned off for now" in l for l in r["balance_off"]), r
time.sleep(1.5)
assert not side(), "the sidebar is taken away"
r["on"] = alex.say("/smp on")
assert any("Turned on again:" in l and "claims" not in l for l in r["on"]), r
r["balance_on"] = alex.say("/balance")
assert any("Balance:" in l for l in r["balance_on"]), r
for _ in range(10):
if side():
break
time.sleep(0.5)
assert side(), "the sidebar is back"
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."""
@@ -603,16 +634,14 @@ def nicks(server, alex, steve, out):
def shop(server, alex):
"""Alex alone, op, in survival: paid from the Treasury (/eco give refused), then the /shop menu: a category, buying one and a stack,
"""Alex alone, op, in survival: given money (/eco give), then the /shop menu: a category, buying one and a stack,
selling a stack of hers, "Sell all" up to the daily limit, closing. Returns the coins it added, and the lines."""
results = {}
# Only what the step gives her: "Sell all" would sell the rest too.
server.console("clear Alex")
time.sleep(1)
results["money"] = alex.say("/treasury pay Alex 2010 proof: shop funds")
assert any("The Treasury paid 2,010 coins to Alex: proof: shop funds (by Alex)." in l for l in results["money"]), results
results["no_printing"] = alex.say("/eco give Alex 10")
assert any("Nobody can make money" in l for l in results["no_printing"]), results
results["money"] = alex.say("/eco give Alex 2010 proof: shop funds")
assert any("Alex gave 2,010 coins to Alex: proof: shop funds." in l for l in results["money"]), results
assert alex.count("minecraft:iron_ingot") == 0
alex.say("/shop")
menu = alex.menu()
@@ -624,17 +653,17 @@ def shop(server, alex):
alex.click(300 + 2) # one iron ingot
assert alex.count("minecraft:iron_ingot") == 1, alex.run("inventory")
results["bought"] = alex.chat()[-3:]
# One price, 15 (2% of it rounds to no tax).
# One price, 15.
assert any("Bought 1 iron ingot at 15 coins each: 15 coins." in l for l in results["bought"]), results
alex.click(300 + 45) # back
alex.click(300 + 14) # Building
alex.run("click-slot", 300 + 0, "shift") # a stack of cobblestone
time.sleep(1)
assert alex.count("minecraft:cobblestone") == 64, alex.run("inventory")
# Along the price as the stack moves it: 64 at 1 coin come to 64.05, 65 whole coins, + 1 tax.
# Along the price as the stack moves it: 64 at 1 coin come to 64.05, 65 whole coins.
results["stack"] = alex.chat()[-4:] # an advancement may come after
assert any("Bought 64 cobblestone at 1.02 coins each: 65 coins." in l for l in results["stack"]), results
assert any("Tax 2%: -1 coins. You paid 66 coins." in l for l in results["stack"]), results
assert not any("Tax" in l for l in results["stack"]), results
# Her own stack of iron, clicked: the ingot she just bought stays (no buying to sell back), one she found sells.
alex.click(alex.slot_of("minecraft:iron_ingot"))
results["round_trip"] = alex.chat()[-1:]
@@ -655,7 +684,7 @@ def shop(server, alex):
alex.click(300 + 49)
results["sell_all"] = alex.chat()[-8:]
assert any("Sold 9 diamond at 196 coins each: 1,760 coins." in l for l in results["sell_all"]), results
assert any("Tax 2%: -35 coins. You get 1,725 coins." in l for l in results["sell_all"]), results
assert not any("Tax" in l for l in results["sell_all"]), results
assert any("21 diamond kept: today's selling limit." in l for l in results["sell_all"]), results
assert alex.count("minecraft:diamond") == 21, alex.run("inventory")
limits = alex.menu()[50]
@@ -666,8 +695,7 @@ def shop(server, alex):
assert not alex.menu()
results["worth"] = alex.say("/worth diamond")
assert any("You may sell 631 more diamond today." in l for l in results["worth"]), results
# The tax: 1 on the cobblestone, 35 on the diamonds, to the Treasury.
return 2000 + 10 - 15 - 65 - 1 + 15 + 1760 - 35, results
return 2000 + 10 - 15 - 65 + 15 + 1760, results
def invsee(alex, data):
@@ -725,9 +753,8 @@ def market(server, alex, out, data):
results["bought"] = alex.chat()[-4:]
# One diamond moves the price 0.5% as it is bought: 191.7, 192 whole coins.
paid = 192
tax = (paid * 200 + 5000) // 10000
assert any(f"Bought 1 diamond at {paid} coins each: {paid} coins." in l for l in results["bought"]), results
assert any(f"Tax 2%: -{tax} coins. You paid {paid + tax} coins." in l for l in results["bought"]), results
assert not any("Tax" in l for l in results["bought"]), results
alex.click(300 + 39) # the 24 h chart
assert alex.count("minecraft:filled_map") == 1, alex.run("inventory")
alex.click(300 + 53) # close
@@ -751,8 +778,7 @@ def market(server, alex, out, data):
# One price, which buying and selling both use.
assert 190 < diamond["price"] < 200 and diamond["buy"] == diamond["sell"] == diamond["price"], results["web"]
assert diamond["volume_24h"] == 10 and diamond["day"]["price"], results["web"]
results["tax"] = tax
return paid + tax, results
return paid, results
def trades(server, alex, steve, port, ctl, resources, root):
@@ -833,6 +859,46 @@ def trades(server, alex, steve, port, ctl, resources, root):
return results, steve
def vanish(server, alex, steve, out):
"""/vanish: Alex (an operator) leaves Steve's world and Tab list, and reads a subtitle the whole time; Steve can't
reach her by name; /vanish again brings her back. Steve can't vanish."""
r = {}
players = lambda c: [e for e in c.run("entities", 64, "player")["entities"]]
subtitle = lambda c: c.run("messages", 1).get("subtitle") or ""
r["seen_before"] = len(players(steve))
r["refused"] = steve.say("/vanish")
assert any("Unknown" in l or "Only operators" in l or "permission" in l for l in r["refused"]), r
r["vanish"] = alex.say("/vanish")
assert any("You vanished" in l for l in r["vanish"]), r
time.sleep(2.5)
# Over a few seconds, not just once: the subtitle never lapses.
r["subtitles"] = []
for _ in range(4):
r["subtitles"].append(subtitle(alex))
time.sleep(1)
assert all(s == "Vanished: only operators see you" for s in r["subtitles"]), r
assert "Vanished" not in subtitle(steve), r
if r["seen_before"]:
assert not players(steve), "Steve no longer sees Alex"
alex.run("screenshot", str(Path(out).resolve() / "vanish-subtitle.png"))
steve.run("key", "tab", "down")
time.sleep(1)
steve.run("screenshot", str(Path(out).resolve() / "vanish-tab.png"))
steve.run("key", "tab", "up")
r["tpa"] = steve.say("/tpa Alex")
assert any("Alex is not on." in l or "Alex isn't on." in l for l in r["tpa"]), r
r["list"] = alex.say("/vanish list")
assert any("Vanished: Alex." in l for l in r["list"]), r
r["back"] = alex.say("/vanish")
assert any("You are visible again." in l for l in r["back"]), r
time.sleep(3)
assert "Vanished" not in subtitle(alex), r
if r["seen_before"]:
assert players(steve), "Steve sees Alex again"
time.sleep(6) # the spam guard's window, for the steps after
return r
def run(ctl, resources, out):
if os.environ.get("SMP_PROOF") == "casino":
# The casino's own proof (proof_casino.py), apart: it plays its games for a few minutes.
@@ -855,23 +921,20 @@ def run(ctl, resources, out):
data.mkdir(parents=True)
(data / "config.json").write_text(json.dumps({"gambling": {"enabled": True, "cooldown_seconds": 20,
"lottery_seconds": 30},
# Free teleports and an instant reward: the sums below
# stay as they were (fees and the reward's delay have
# unit tests).
"places": {"warmup_seconds": 2, "cooldown_seconds": 0,
"fees": {"spawn": 0, "home": 0, "warp": 0,
"back": 0, "tpa": 0}},
# Quick teleports and an instant reward (the reward's
# delay has unit tests).
"places": {"warmup_seconds": 2, "cooldown_seconds": 0},
# Paid once, after the restart, not to change the sums.
"presence": {"daily_reward": 0, "reward_after_minutes": 0},
# Steve plays a few minutes before his nickname.
"nicks": {"min_playtime_minutes": 1},
# Fixed prices, no tax, for the first run's sums; the
# Fixed prices for the first run's sums; the
# moving market is on after the restart.
"market": {"dynamic": {"enabled": False}},
# The casino's pot takes no float from the Treasury here (its
# own proof, proof_casino.py, covers it): the sums stay.
# The casino's pot gets no float here (its own proof,
# proof_casino.py, covers it): the sums stay.
"casino": {"float": 0}}))
# A Treasury with money, the only place operators may pay from (nobody can make money).
# A world from before 0.30, with a Treasury: its pool is dropped at the first start.
(data / "economy.json").write_text(json.dumps({"accounts": {}, "pools": {"treasury": 5000}}))
# A nickname an older version let through, taken off at the first start.
(data / "nicks.json").write_text(json.dumps({"nicks": {"00000000-0000-0000-0000-00000000abcd": {
@@ -885,23 +948,22 @@ def run(ctl, resources, out):
time.sleep(2)
config = json.loads((data / "config.json").read_text())
results["config_keys"] = sorted(config)
assert results["config_keys"] == ["auction", "bank", "board", "casino", "claims", "currency", "futures", "gambling", "jobs",
"market", "nicks", "places", "presence", "report", "shop",
"starting_balance", "taxes", "web"], config
assert results["config_keys"] == ["auction", "board", "casino", "claims", "currency", "gambling", "market", "nicks", "places", "presence", "report", "shop",
"starting_balance", "web"], config
results["welcome"] = steve.chat()
assert any("Welcome to the server, Steve!" in l for l in results["welcome"]), results["welcome"]
assert any("1. Be respectful to everyone." in l for l in results["welcome"]), results["welcome"]
assert any("Steve joined for the first time" in l for l in alex.chat()), alex.chat()
assert config["gambling"] == {"enabled": True, "min_bet": 10, "max_bet": 1000, "cooldown_seconds": 20,
"offer_seconds": 60, "fee_percent": 0, "ticket_price": 10,
"offer_seconds": 60, "ticket_price": 10,
"max_tickets": 100, "lottery_seconds": 30}, config
# One price, to buy and to sell.
assert config["market"]["items"]["minecraft:diamond"] == {"buy": 200, "sell": 200}, config
server.console("op Alex")
time.sleep(1)
results["grant"] = alex.say("/treasury pay Alex 500 proof funding")
assert any("The Treasury paid 500 coins to Alex: proof funding (by Alex). Treasury: 4,500 coins." in l
for l in results["grant"]), results
results["grant"] = alex.say("/eco give Alex 500 proof funding")
assert any("Alex gave 500 coins to Alex: proof funding." in l for l in results["grant"]), results
assert any("Alex gave 500 coins to Alex: proof funding." in l for l in steve.chat()[-3:]), "everyone reads it"
results["balance"] = alex.say("/balance")
assert any("Balance: 500 coins" in l for l in results["balance"]), results
results["pay"] = alex.say("/pay Steve 120")
@@ -917,17 +979,13 @@ def run(ctl, resources, out):
"You can't pay yourself.",
"No player named Nobody has an account.")):
assert any(error in l for l in lines), results["refused"]
results["eco_refused"] = steve.say("/eco give Steve 1000")
assert not any("now has" in l for l in results["eco_refused"]), results
results["treasury_refused"] = steve.say("/treasury pay Steve 1000 because")
assert not any("The Treasury paid" in l for l in results["treasury_refused"]), results
results["treasury"] = steve.say("/treasury")
assert any("Treasury: 4,500 coins" in l for l in results["treasury"]), results
assert any("grant 500 coins" in l for l in results["treasury"]), results
results["treasury_log"] = steve.say("/treasury log")
assert any("-500 coins grant (Alex): proof funding, by Alex" in l for l in results["treasury_log"]), results
results["taxes"] = steve.say("/eco taxes")
assert any("Land tax, weekly: 5 coins a claimed chunk past your first 4." in l for l in results["taxes"]), results
results["eco_refused"] = steve.say("/eco give Steve 1000 because")
assert not any("gave" in l for l in results["eco_refused"]), results
# The parts taken out: their commands are gone.
for line in ("/treasury", "/bank", "/jobs", "/futures", "/eco taxes"):
results[line] = steve.say(line)
refused = ("Unknown or incomplete command", "Incorrect argument", "Usage", "Only operators")
assert any(any(r in l for r in refused) for l in results[line]), results
results["baltop"] = steve.say("/baltop")
assert any("1. Alex: 380 coins" in l for l in results["baltop"]), results
assert any("2. Steve: 120 coins" in l for l in results["baltop"]), results
@@ -964,6 +1022,7 @@ def run(ctl, resources, out):
results["polish"] = polish(server, alex, steve, out)
results["presence"] = presence(server, alex, steve)
results["nicks"] = nicks(server, alex, steve, out)
results["vanish"] = vanish(server, alex, steve, out)
results["coinflip"], alex_wins = coinflips(alex, steve)
results["lottery"], alex_lotto = lottery(alex, steve, alex_wins)
alex_after = 415 + (100 if alex_wins else -100) + (20 if alex_lotto else -30)
@@ -985,7 +1044,7 @@ def run(ctl, resources, out):
assert balances["Alex"] == alex_after and sum(balances.values()) + held == 815, economy
audit = [json.loads(l) for l in (data / "audit.log").read_text().splitlines()]
results["audit"] = [a["kind"] for a in audit]
assert results["audit"] == ["open", "open", "grant", "pay", "sell 60 minecraft:wheat",
assert results["audit"] == ["open", "open", "eco_give", "pay", "sell 60 minecraft:wheat",
"sell 1 minecraft:diamond", "buy 5 minecraft:torch", "trade", "trade_failed",
"trade", "trade_money", "pay", "pay", "coinflip_stake", "coinflip_stake", "coinflip_win",
"coinflip_stake", "lottery_ticket", "lottery_ticket", "lottery_win"] + (
@@ -1013,6 +1072,7 @@ def run(ctl, resources, out):
added, results["shop"] = shop(server, alex)
spent, results["market"] = market(server, alex, out, data)
results["invsee"] = invsee(alex, data)
results["switches"] = switches(alex, data)
sent = {path: (auth, doc) for path, auth, doc in receiver.got}
results["pushed"] = sorted(sent)
assert {"/smp/ingest/market.json", "/smp/ingest/economy.json"} <= set(sent), receiver.got[:2]
@@ -1027,16 +1087,18 @@ def run(ctl, resources, out):
economy = json.loads((data / "economy.json").read_text())
total = sum(a["balance"] for a in economy["accounts"].values())
assert "held" not in economy and total == 815 + 7 + added, economy
# Every coin an operator gave came out of the Treasury.
# The market's tax went in: 36 in the shop, and on her diamond.
assert economy["pools"]["treasury"] == 5000 - 500 - 2010 + 36 + results["market"]["tax"], economy["pools"]
# The old Treasury pool is gone; no fee or tax took anything.
assert "treasury" not in economy.get("pools", {}), economy.get("pools")
audit = [json.loads(l) for l in (data / "audit.log").read_text().splitlines()]
took = [a for a in audit if a["kind"] == "invsee_take"]
assert [(a["item"], a["count"], a["inventory"]) for a in took] == [("minecraft:torch", 5, "inventory")], took
kinds = [a["kind"] for a in audit if not a["kind"].startswith("invsee_")]
shop_kinds = ["grant", "buy 1 minecraft:iron_ingot", "buy 64 minecraft:cobblestone", "market_tax",
"sell 1 minecraft:iron_ingot", "sell 9 minecraft:diamond", "market_tax",
"buy 1 minecraft:diamond", "market_tax"]
switched = [(a["kind"], a["parts"]) for a in audit if a["kind"].startswith("switch_")]
assert [k for k, _ in switched] == ["switch_off", "switch_off", "switch_on"], switched
assert switched[0][1] == ["market"] and "claims" not in switched[1][1], switched
assert not json.loads((data / "switches.json").read_text())["off"], "everything on again"
kinds = [a["kind"] for a in audit if not a["kind"].startswith(("invsee_", "switch_"))]
shop_kinds = ["eco_give", "buy 1 minecraft:iron_ingot", "buy 64 minecraft:cobblestone",
"sell 1 minecraft:iron_ingot", "sell 9 minecraft:diamond", "buy 1 minecraft:diamond"]
assert kinds[-len(shop_kinds):] == shop_kinds, kinds[-8:]
kinds = kinds[:-len(shop_kinds)]
assert kinds[-2] in ("held_returned", "coinflip_cancel") and kinds[-1] == "daily_reward", kinds[-3:]
+7 -7
View File
@@ -5,7 +5,7 @@ Run it with `SMP_PROOF=casino python3 native/tools/native.py proof --resources P
Checked from the clients' own menus, chat, titles and action bar, and from the files the server wrote:
- the pot is funded from the Treasury at the first start (no coin made), and `/casino help` shows every game's odds;
- the pot gets its float at the first start (made once), and `/casino help` shows every game's odds;
- `/casino` opens the lobby (its title, the four games, the pot); a click opens a game;
- roulette: a bet on Red, the stake raised, the wheel turns (several different pictures while it spins), the round
is paid and the table shows the result; a second spin with the menu closed mid-spin is still paid, once;
@@ -15,7 +15,7 @@ Checked from the clients' own menus, chat, titles and action bar, and from the f
- blackjack: a hand dealt and stood (or settled at once on a blackjack);
- the bet limits: the most is `max_bet`;
- after the server stops: every bet held is settled exactly once in audit.log, nothing is left held, and the money
in the game (balances + pools) is what the Treasury held at the start; casino.json holds the day's totals; no
in the game (balances + pools) is the float and the operator's two `/eco give`s; casino.json holds the day's totals; no
console command the casino ran was refused.
"""
import importlib.util
@@ -31,7 +31,8 @@ _spec = importlib.util.spec_from_file_location("smp_proof_harness", Path(__file_
harness = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(harness)
TREASURY = 50_000
FLOAT = 20_000
GIVEN = 2 * 3000
NAME = re.compile(r'"?text"?\s*:\s*"((?:[^"\\]|\\.)*)"')
GAMES = ("casino_roulette", "casino_slots", "casino_crash", "casino_blackjack")
@@ -225,8 +226,7 @@ def run(ctl, resources, out):
data.mkdir(parents=True)
(data / "config.json").write_text(json.dumps({
"gambling": {"enabled": True}, "starting_balance": 0, "presence": {"daily_reward": 0},
"casino": {"cooldown_ms": 500, "float": 20_000}}))
(data / "economy.json").write_text(json.dumps({"accounts": {}, "pools": {"treasury": TREASURY}}))
"casino": {"cooldown_ms": 500, "float": FLOAT}}))
server.start()
for line in ("difficulty peaceful", "gamerule spawn_monsters false"):
server.console(line)
@@ -235,7 +235,7 @@ def run(ctl, resources, out):
server.console("op Alex")
time.sleep(2)
for who in ("Alex", "Steve"):
assert any("The Treasury paid" in l for l in alex.say(f"/treasury pay {who} 3000 casino proof"))
assert any(f"Alex gave 3,000 coins to {who}: casino proof." in l for l in alex.say(f"/eco give {who} 3000 casino proof"))
results["help"] = alex.say("/casino help")
text = "\n".join(results["help"])
for want in ("bets 10 coins to 1,000 coins", "pot (20,000 coins now)", "European", "94.2%", "96%", "3 to 2"):
@@ -273,7 +273,7 @@ def run(ctl, resources, out):
results["economy"] = economy
held = sum(economy.get("held", {}).values())
money = sum(a["balance"] for a in economy["accounts"].values()) + held + sum(economy["pools"].values())
assert held == 0 and money == TREASURY, economy
assert held == 0 and money == FLOAT + GIVEN, economy
ledger = json.loads((data / "casino.json").read_text())
results["casino.json"] = ledger
assert sum(t["rounds"] for t in ledger["totals"].values()) == 7, ledger
+4 -8
View File
@@ -3,8 +3,6 @@
//! `super::smp_store` reaches the same code in both.
#[path = "../mixins/smp/smp_auction.rs"]
pub mod smp_auction;
#[path = "../mixins/smp/smp_bank.rs"]
pub mod smp_bank;
#[path = "../mixins/smp/smp_board.rs"]
pub mod smp_board;
#[path = "../mixins/smp/smp_casino.rs"]
@@ -21,14 +19,10 @@ pub mod smp_casino_slots;
pub mod smp_claims;
#[path = "../mixins/smp/smp_economy.rs"]
pub mod smp_economy;
#[path = "../mixins/smp/smp_futures.rs"]
pub mod smp_futures;
#[path = "../mixins/smp/smp_games.rs"]
pub mod smp_games;
#[path = "../mixins/smp/smp_invsee.rs"]
pub mod smp_invsee;
#[path = "../mixins/smp/smp_jobs.rs"]
pub mod smp_jobs;
#[path = "../mixins/smp/smp_lottery.rs"]
pub mod smp_lottery;
#[path = "../mixins/smp/smp_market.rs"]
@@ -53,7 +47,9 @@ pub mod smp_report;
pub mod smp_shop;
#[path = "../mixins/smp/smp_store.rs"]
pub mod smp_store;
#[path = "../mixins/smp/smp_tax.rs"]
pub mod smp_tax;
#[path = "../mixins/smp/smp_switch.rs"]
pub mod smp_switch;
#[path = "../mixins/smp/smp_trade.rs"]
pub mod smp_trade;
#[path = "../mixins/smp/smp_vanish.rs"]
pub mod smp_vanish;
+10 -4
View File
@@ -170,7 +170,9 @@ def cargo(stage_dir, action):
def package(m):
"""A deterministic .hnmod: horizon-native.json and the bundle's .rs and .toml files."""
"""A deterministic .hnmod: horizon-native.json, the bundle's .rs and .toml files (its crates' too) and the mod's
LICENSE as mixins/<id>/LICENSE, so the licence's notice travels with the sources (Horizon's bundle reader
accepts LICENSE* files there)."""
manifest = {"schema": 1, "id": m.id, "version": m.version, "game": m.compat["game"],
"horizon": m.compat["horizon_revision"], "mixins": m.compat["horizon_mixins_revision"],
"hooks": m.compat["hooks"]}
@@ -178,9 +180,13 @@ def package(m):
if pinned != m.compat["horizon_revision"]:
sys.exit(f"{m.id}: horizon-mixin.toml pins {pinned}, compatibility.json {m.compat['horizon_revision']}")
files = {"horizon-native.json": (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode()}
for path in sorted(m.bundle.iterdir()):
if path.suffix in (".rs", ".toml"):
files[f"mixins/{m.id}/{path.name}"] = path.read_bytes()
# The helpers and modules, and the sources of the bundle's own crates (horizon-mixins 0.6.0).
for path in sorted(m.bundle.rglob("*")):
if path.is_file() and path.suffix in (".rs", ".toml"):
files[f"mixins/{m.id}/{path.relative_to(m.bundle).as_posix()}"] = path.read_bytes()
licence = m.root / "LICENSE"
if licence.is_file():
files.setdefault(f"mixins/{m.id}/LICENSE", licence.read_bytes())
out = TARGET / f"{m.id}-{m.version}.hnmod"
TARGET.mkdir(exist_ok=True)
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as archive:
+252
View File
@@ -0,0 +1,252 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cc"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "find-msvc-tools"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[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 = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"libc",
"untrusted",
"windows-sys",
]
[[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 = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[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 = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "voicechat_server_relay"
version = "1.0.0"
dependencies = [
"ring",
"serde",
"serde_json",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+4
View File
@@ -0,0 +1,4 @@
# The relay crate the bundle adds to the game (mixins/voicechat_server/relay), with its tests.
[workspace]
members = ["mixins/voicechat_server/relay"]
resolver = "3"
+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.
+53
View File
@@ -0,0 +1,53 @@
# Voice chat server (Horizon native mod)
The server half of proximity voice chat on a Horizon dedicated server. Players who have a voice chat client mod
(azetech's Complex Voice Chat, any client that speaks Simple Voice Chat 2.6.x's protocol over Horizon's mod
messages) talk to the players near them, or to their group from anywhere. Players without one join and play as on
any server: the server sends them nothing.
- **The handshake** goes over Horizon's mod messages (`native_net_from_client` / `native_net_send_to_player`, the
`horizon:mod_net` channel) on `voicechat:` channel names, the same messages and layouts as Simple Voice Chat 2.6.x
(compatibility version 20). A client gets a fresh 16-byte secret when it asks; nothing is sent to anyone before.
- **Voices** go over UDP, through the server, never from player to player: no player learns another's address.
Each player's packets are sealed with their own secret (AES-128-GCM). The server relays a voice to the players
in the same dimension within `distance` blocks (`whisper_distance` for a whisper), and to the speaker's group
wherever they are. Groups: normal (members aren't heard outside but hear who is near), open (heard and hearing
nearby), isolated (members only).
- **Limits:** 75 voice packets a second a player (burst 30), Opus frames up to 1275 bytes, 128 groups, names and
passwords up to 24 characters, group and state changes 2 a second a player (burst 10), one secret a second a
player (more requests meanwhile get one answer), 24 messages a player a tick and at most 512 waiting.
- **Replays:** a packet that doesn't open with the player's key is dropped without an answer. A secret belongs to
the first address that authenticates with it: a handshake copied and sent from elsewhere moves nothing. Exact
copies are dropped too (the last 64 control packets' nonces, and voice packets' sequence numbers). A client the
server stops hearing for 10 keep-alives (its NAT rebound, say) gets a fresh secret over the game connection and
comes back from its new address with it; the old key then opens nothing.
- **No port is opened** until the first voice chat client asks. Then the relay binds `bind:port` (UDP).
## Config
`config/voicechat-server.json`, beside `server.properties`, written with its defaults the first time a client asks:
| Key | Default | |
|---|---|---|
| `enabled` | `true` | `false`: no port, no answer to clients |
| `port` | `24454` | the UDP port. A host that gives one port for the game usually forwards UDP on it too: use the game's port |
| `bind` | `0.0.0.0` | |
| `voice_host` | `""` | where clients send UDP if not the game server's address: a port, a host, or host:port |
| `distance` | `48.0` | blocks a voice carries |
| `whisper_distance` | `24.0` | |
| `groups` | `true` | players may make and join groups |
| `keep_alive_ms` | `1000` | a client silent 10 times this long shows as disconnected |
| `mtu` | `1024` | |
## Files
| File | What it is |
|---|---|
| `mixins/voicechat_server/horizon-mixin.toml` | The bundle: the relay crate, the glue module and four HEAD hooks (mod messages, server tick, player logged out, world closing) |
| `mixins/voicechat_server/relay/` | The relay crate (`voicechat_server_relay`): the wire format (`proto.rs`), the handshake, groups and UDP relay (`lib.rs`) and its tests over real UDP sockets (`tests.rs`). Depends on `ring` (AES-GCM) and `serde`, both in Horizon's `Cargo.lock` |
| `mixins/voicechat_server/glue.rs` | Added to the game as `game::voicechat_server`: the config, starting the relay, the players each tick |
It needs horizon-mixins 0.6.0 (bundle crates) and hook points API 57. Tests: `cargo test` here.
Not made with or endorsed by Simple Voice Chat or its author: it speaks the same protocol, written from its packet
layouts, with none of its code.
@@ -0,0 +1,8 @@
{
"game": "26.3",
"hooks": 57,
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "f4833621f9037e08c487b1779e8c030487e65cc2",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
@@ -0,0 +1,143 @@
// Voice chat's server half in the game (`game::voicechat_server`): the relay
// (crate voicechat_server_relay) opens its UDP port when the first voice chat
// client asks for a secret, hears the clients' messages through Horizon's mod
// messages, and learns each tick where every player is. A player without a
// voice chat client is never sent anything.
use crate::game::Game;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use voicechat_server_relay::{Config, Player, Server, proto};
/// Messages to one player a tick at most (mod messages allow 32, shared with other mods).
const PER_TICK: usize = 24;
/// The config file in the server's `config/` folder.
const FILE: &str = "voicechat-server.json";
/// After the port failed to open, wait this long before trying again.
const RETRY: Duration = Duration::from_secs(60);
enum Relay {
Off,
Running(Server),
Failed(Instant),
}
static RELAY: Mutex<Relay> = Mutex::new(Relay::Off);
fn relay() -> std::sync::MutexGuard<'static, Relay> {
RELAY.lock().unwrap_or_else(|e| e.into_inner())
}
fn players(game: &Game<'_>) -> Vec<Player> {
game.native_server_players()
.into_iter()
.map(|p| Player {
uuid: p.uuid,
name: p.name,
dimension: p.dimension,
position: p.position,
})
.collect()
}
/// `config/voicechat-server.json`, written with its defaults the first time
/// and again with the keys a newer version adds (values kept). A file that
/// doesn't read is left alone and the defaults apply.
fn config() -> Config {
let Ok(dir) = Game::native_config_dir() else {
return Config::default();
};
let path = dir.join(FILE);
let config = match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<Config>(&text) {
Ok(c) => c,
Err(e) => {
log::warn!(
"Voice chat: {} doesn't read ({e}); using the defaults",
path.display()
);
return Config::default();
}
},
Err(_) => Config::default(),
};
if let Ok(text) = serde_json::to_string_pretty(&config) {
let _ = std::fs::write(&path, text + "\n");
}
config
}
/// A message from a player's client: only `voicechat:` channels are ours.
pub(super) fn message(game: &mut Game<'_>, player: u128, channel: &str, data: &[u8]) {
if !channel.starts_with("voicechat:") {
return;
}
let mut relay = relay();
if !matches!(*relay, Relay::Running(_)) {
if channel != proto::REQUEST_SECRET {
return;
}
if let Relay::Failed(at) = *relay
&& at.elapsed() < RETRY
{
return;
}
let config = config();
match Server::start(config.clone()) {
Ok(server) => {
let at = server
.local_addr()
.map(|a| a.to_string())
.unwrap_or_default();
log::info!("Voice chat: relaying voices on UDP {at}");
*relay = Relay::Running(server);
}
Err(e) => {
log::warn!(
"Voice chat: not started on UDP {}:{}: {e}",
config.bind,
config.port
);
*relay = Relay::Failed(Instant::now());
return;
}
}
}
let Relay::Running(server) = &*relay else {
return;
};
// A player who joined since the last tick (or a relay that just started):
// learn who is on first. Not for every message: it lists every player.
if !server.knows(player) {
server.tick(players(game));
}
server.message(player, channel, data);
}
/// Each server tick, once the relay runs.
pub(super) fn tick(game: &mut Game<'_>) {
let out = {
let relay = relay();
let Relay::Running(server) = &*relay else {
return;
};
server.tick(players(game));
server.outgoing(PER_TICK)
};
for (player, channel, data) in out {
if let Err(e) = game.native_net_send_to_player(player, &channel, &data) {
log::debug!("Voice chat: {channel} not sent: {e}");
}
}
}
pub(super) fn left(player: u128) {
if let Relay::Running(server) = &*relay() {
server.left(player);
}
}
/// The world closed: the port closes too (it opens again on the next request).
pub(super) fn stop() {
*relay() = Relay::Off;
}
@@ -0,0 +1,60 @@
[mod]
id = "voicechat_server"
version = "1.0.0"
priority = 100
# The commit it was tested on (the official server's engine).
[compatibility]
horizon_revision = "f4833621f9037e08c487b1779e8c030487e65cc2"
# The handshake, the groups and the UDP relay: plain Rust with its tests.
[[crate]]
id = "relay"
path = "relay"
# The edges between the game and the relay.
[[module]]
id = "glue"
file = "crates/horizon-client/src/game.rs"
name = "voicechat_server"
source = "glue.rs"
[[injection]]
id = "net"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_net_from_client"
helper = "net.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "player", ty = "u128", value = "player" },
{ name = "channel", ty = "&str", value = "channel" },
{ name = "data", ty = "&[u8]", value = "data" },
]
[[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 = "logged_out"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_player_logged_out"
helper = "logged_out.rs"
bindings = [
{ name = "player", ty = "u128", value = "player" },
]
[[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" },
]
@@ -0,0 +1,10 @@
// The world closing: the relay closes its UDP port.
fn voicechat_server_lifecycle(phase: crate::game::native_lifecycle::WorldPhase) {
use crate::game::native_lifecycle::WorldPhase;
if matches!(
phase,
WorldPhase::WorldCloseFinal | WorldPhase::WorldCloseFailed
) {
crate::game::voicechat_server::stop();
}
}
@@ -0,0 +1,4 @@
// A player left: the relay forgets them and tells the others.
fn voicechat_server_logged_out(player: u128) {
crate::game::voicechat_server::left(player);
}
@@ -0,0 +1,9 @@
// A voice chat client's message (`voicechat:` channels) to the relay.
fn voicechat_server_net(
game: &mut crate::game::Game<'_>,
player: u128,
channel: &str,
data: &[u8],
) {
crate::game::voicechat_server::message(game, player, channel, data);
}
@@ -0,0 +1,14 @@
[package]
name = "voicechat_server_relay"
version = "1.0.0"
edition = "2024"
license = "MIT"
publish = false
description = "Horizon's voice chat server: the handshake for voice chat clients and the UDP relay of their voices."
[dependencies]
ring = "0.17"
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
serde_json = "1"
@@ -0,0 +1,788 @@
//! The server half of proximity voice chat on Horizon: it answers the voice
//! chat clients' handshake (sent over Horizon's mod messages) and relays their
//! voices over UDP to the players who should hear them, by distance and
//! group. Players without a voice chat client never hear of it: nothing is
//! sent to a player until their client asks.
//!
//! Voices go through the server, never player to player, so nobody learns
//! another player's address. Each player's packets are sealed with their own
//! key (AES-128-GCM), which they get over the game connection.
pub mod proto;
use proto::{FromClient, GroupKind, PlayerState, Request, ToClient};
use ring::rand::{SecureRandom, SystemRandom};
use std::collections::{HashMap, HashSet, VecDeque};
use std::io;
use std::net::{SocketAddr, UdpSocket};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
/// `config/voicechat-server.json`.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Config {
/// Off: no port is opened and clients get no answer.
pub enabled: bool,
/// The UDP port voices use. A host that gives one port for the game
/// (TCP) usually forwards UDP on it too: use the game's port then.
pub port: u16,
/// The address the UDP socket binds.
pub bind: String,
/// Where clients send UDP, when not the game server's own address:
/// a port, a host, or host:port.
pub voice_host: String,
/// How far a voice carries, in blocks.
pub distance: f64,
/// How far a whisper carries.
pub whisper_distance: f64,
/// Players may make and join groups.
pub groups: bool,
pub keep_alive_ms: u32,
pub mtu: u32,
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: true,
port: 24454,
bind: "0.0.0.0".into(),
voice_host: String::new(),
distance: 48.0,
whisper_distance: 24.0,
groups: true,
keep_alive_ms: 1000,
mtu: 1024,
}
}
}
/// A player on the game server, as the last tick saw them.
#[derive(Clone, Debug, PartialEq)]
pub struct Player {
pub uuid: u128,
pub name: String,
pub dimension: String,
pub position: [f64; 3],
}
/// A message for a player's client: (player, channel, data).
pub type Message = (u128, String, Vec<u8>);
/// Groups at most, and voice packets a second a player may send.
const MAX_GROUPS: usize = 128;
const MIC_PER_SECOND: f64 = 75.0;
const MIC_BURST: f64 = 30.0;
/// Group and voice state changes a second a player may make.
const OPS_PER_SECOND: f64 = 2.0;
const OPS_BURST: f64 = 10.0;
/// Secrets a player's own requests get at most this often; more requests
/// meanwhile are answered once, when the time is up.
const SECRET_EVERY: Duration = Duration::from_secs(1);
/// Messages waiting for one player at most; more are dropped.
const MAX_QUEUED: usize = 512;
/// The nonces of a player's last control packets remembered, to drop exact
/// replays; and how far behind the newest a voice packet's sequence may be.
const SEEN_NONCES: usize = 64;
const MIC_WINDOW: i128 = 64;
/// A player's voice connection, for the life of one secret.
///
/// The first address that authenticates holds the secret: no other address
/// can use it, so a handshake captured and sent again from elsewhere moves
/// nothing. A client that moves (a NAT that rebinds) gets a fresh secret
/// over the game connection, which the game session authenticates: when the
/// server stops hearing it, or when it asks again. The old key then opens
/// nothing.
struct Client {
secret: [u8; 16],
cipher: proto::Cipher,
addr: Option<SocketAddr>,
/// Answered the connection check: its voice connection works.
connected: bool,
last_keep_alive: Instant,
disabled: bool,
group: Option<u128>,
tokens: f64,
refilled: Instant,
ops: f64,
ops_refilled: Instant,
/// When this secret was given, and whether a request for another waits.
issued: Instant,
asked_again: bool,
/// Nonces of the control packets already taken (the newest last).
seen: VecDeque<[u8; 12]>,
/// The newest voice sequence, and which of the 64 before it arrived.
mic_top: Option<i64>,
mic_seen: u64,
}
impl Client {
fn new(secret: [u8; 16], now: Instant) -> Self {
Self {
secret,
cipher: proto::Cipher::new(&secret),
addr: None,
connected: false,
last_keep_alive: now,
disabled: false,
group: None,
tokens: MIC_BURST,
refilled: now,
ops: OPS_BURST,
ops_refilled: now,
issued: now,
asked_again: false,
seen: VecDeque::new(),
mic_top: None,
mic_seen: 0,
}
}
/// A control packet's nonce: false if it was already taken.
fn fresh_nonce(&mut self, nonce: [u8; 12]) -> bool {
if self.seen.contains(&nonce) {
return false;
}
if self.seen.len() >= SEEN_NONCES {
self.seen.pop_front();
}
self.seen.push_back(nonce);
true
}
/// A voice packet's sequence: false if it was already relayed or is too
/// old (a replay). A client counts up from 0 for each secret.
fn fresh_sequence(&mut self, sequence: i64) -> bool {
let Some(top) = self.mic_top else {
self.mic_top = Some(sequence);
self.mic_seen = 1;
return true;
};
let ahead = i128::from(sequence) - i128::from(top);
if ahead > 0 {
self.mic_seen = if ahead >= MIC_WINDOW {
1
} else {
(self.mic_seen << ahead) | 1
};
self.mic_top = Some(sequence);
return true;
}
let back = -ahead;
if back >= MIC_WINDOW {
return false;
}
let bit = 1u64 << back;
if self.mic_seen & bit != 0 {
return false;
}
self.mic_seen |= bit;
true
}
/// One group or state change, if the player has one left.
fn spend_op(&mut self, now: Instant) -> bool {
self.ops = (self.ops
+ now.duration_since(self.ops_refilled).as_secs_f64() * OPS_PER_SECOND)
.min(OPS_BURST);
self.ops_refilled = now;
if self.ops < 1.0 {
return false;
}
self.ops -= 1.0;
true
}
}
struct Group {
name: String,
password: Option<String>,
kind: GroupKind,
}
struct State {
config: Config,
/// The UDP port the socket got.
port: u16,
clients: HashMap<u128, Client>,
players: HashMap<u128, Player>,
groups: HashMap<u128, Group>,
/// Players whose state changed since the last tick, to tell everyone.
changed: HashSet<u128>,
outbox: HashMap<u128, VecDeque<(String, Vec<u8>)>>,
}
struct Shared {
state: Mutex<State>,
socket: UdpSocket,
rng: SystemRandom,
running: AtomicBool,
}
/// The running voice server: its UDP socket and thread. Dropping it stops them.
pub struct Server {
shared: Arc<Shared>,
thread: Option<JoinHandle<()>>,
}
fn lock(m: &Mutex<State>) -> MutexGuard<'_, State> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
impl Server {
/// Opens the UDP port and starts relaying.
pub fn start(config: Config) -> io::Result<Self> {
if !config.enabled {
return Err(io::Error::other("voice chat is turned off in its config"));
}
let socket = UdpSocket::bind((config.bind.as_str(), config.port))?;
socket.set_read_timeout(Some(Duration::from_millis(100)))?;
let port = socket.local_addr()?.port();
let shared = Arc::new(Shared {
state: Mutex::new(State {
config,
port,
clients: HashMap::new(),
players: HashMap::new(),
groups: HashMap::new(),
changed: HashSet::new(),
outbox: HashMap::new(),
}),
socket,
rng: SystemRandom::new(),
running: AtomicBool::new(true),
});
let worker = shared.clone();
let thread = std::thread::Builder::new()
.name("voicechat-relay".into())
.spawn(move || relay(&worker))?;
Ok(Self {
shared,
thread: Some(thread),
})
}
/// The UDP address voices arrive on.
pub fn local_addr(&self) -> io::Result<SocketAddr> {
self.shared.socket.local_addr()
}
/// Each server tick: the players on the server now. Voice chat forgets
/// the ones who left, and tells everyone what changed.
pub fn tick(&self, players: Vec<Player>) {
let mut st = lock(&self.shared.state);
st.players = players.into_iter().map(|p| (p.uuid, p)).collect();
let gone: Vec<u128> = st
.clients
.keys()
.filter(|u| !st.players.contains_key(u))
.copied()
.collect();
for uuid in gone {
st.forget(uuid);
}
// Requests that came too soon after a secret get one now.
let due: Vec<u128> = st
.clients
.iter()
.filter(|(_, c)| c.asked_again && c.issued.elapsed() >= SECRET_EVERY)
.map(|(u, _)| *u)
.collect();
for uuid in due {
st.give_secret(&self.shared.rng, uuid, false);
}
let changed: Vec<u128> = st.changed.drain().collect();
for uuid in changed {
if let Some(s) = st.player_state(uuid) {
st.tell_all(proto::STATE, proto::state(&s));
}
}
}
/// Whether `player` was on the server at the last tick.
pub fn knows(&self, player: u128) -> bool {
lock(&self.shared.state).players.contains_key(&player)
}
/// A player left the game.
pub fn left(&self, player: u128) {
lock(&self.shared.state).forget(player);
}
/// A message from `player`'s client on a `voicechat:` channel.
pub fn message(&self, player: u128, channel: &str, data: &[u8]) {
let Some(Ok(request)) = Request::read(channel, data) else {
return;
};
let mut st = lock(&self.shared.state);
if !st.players.contains_key(&player) {
return;
}
match request {
Request::Secret { version } => {
if version != proto::COMPATIBILITY_VERSION {
return;
}
if let Some(c) = st.clients.get_mut(&player)
&& c.issued.elapsed() < SECRET_EVERY
{
c.asked_again = true;
return;
}
st.give_secret(&self.shared.rng, player, false);
}
Request::State { disabled } => {
if let Some(c) = st.clients.get_mut(&player)
&& c.spend_op(Instant::now())
&& c.disabled != disabled
{
c.disabled = disabled;
st.changed.insert(player);
}
}
Request::CreateGroup {
name,
password,
kind,
} => {
let name = name.trim().to_owned();
if !st.config.groups
|| name.is_empty()
|| st.groups.len() >= MAX_GROUPS
|| !st.spend_op(player)
{
return;
}
let mut id = [0u8; 16];
if self.shared.rng.fill(&mut id).is_err() {
return;
}
let id = u128::from_be_bytes(id);
let password = password
.map(|p| p.trim().to_owned())
.filter(|p| !p.is_empty());
let add = proto::add_group(id, &name, password.is_some(), kind);
st.groups.insert(
id,
Group {
name,
password,
kind,
},
);
st.tell_all(proto::ADD_GROUP, add);
st.join(player, id);
}
Request::JoinGroup { group, password } => {
if !st.config.groups || !st.groups.contains_key(&group) || !st.spend_op(player) {
return;
}
let Some(g) = st.groups.get(&group) else {
return;
};
let ok = g.password.is_none()
|| g.password.as_deref() == password.as_deref().map(str::trim);
if ok {
st.join(player, group);
} else {
let current = st.clients.get(&player).and_then(|c| c.group);
st.tell(
player,
proto::JOINED_GROUP,
proto::joined_group(current, true),
);
}
}
Request::LeaveGroup => {
let Some(c) = st.clients.get_mut(&player) else {
return;
};
if c.group.is_none() || !c.spend_op(Instant::now()) {
return;
}
let Some(g) = c.group.take() else { return };
st.tell(
player,
proto::JOINED_GROUP,
proto::joined_group(None, false),
);
st.changed.insert(player);
st.leave_group_cleanup(g);
}
}
}
/// What to send to the players' clients now, at most `per_player` messages each (the rest wait for the
/// next call).
pub fn outgoing(&self, per_player: usize) -> Vec<Message> {
let mut st = lock(&self.shared.state);
let mut out = Vec::new();
st.outbox.retain(|player, queue| {
for _ in 0..per_player {
let Some((channel, data)) = queue.pop_front() else {
break;
};
out.push((*player, channel, data));
}
!queue.is_empty()
});
out
}
}
impl Drop for Server {
fn drop(&mut self) {
self.shared.running.store(false, Ordering::Relaxed);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
impl State {
/// Queues a message for `player`'s client (dropped when MAX_QUEUED wait).
fn tell(&mut self, player: u128, channel: &str, data: Vec<u8>) {
let queue = self.outbox.entry(player).or_default();
if queue.len() < MAX_QUEUED {
queue.push_back((channel.to_owned(), data));
}
}
fn spend_op(&mut self, player: u128) -> bool {
self.clients
.get_mut(&player)
.is_some_and(|c| c.spend_op(Instant::now()))
}
/// A fresh secret for `player`: the old one opens nothing any more.
/// `rekey`: the server's own, for a client it stopped hearing (it keeps its
/// group and state, and gets only the secret). Otherwise the client asked:
/// it starts over and gets everyone's state and the groups, instead of
/// whatever still waited for it.
fn give_secret(&mut self, rng: &SystemRandom, player: u128, rekey: bool) {
let mut secret = [0u8; 16];
if rng.fill(&mut secret).is_err() {
return;
}
let mut client = Client::new(secret, Instant::now());
let old = self.clients.remove(&player);
if rekey && let Some(old) = &old {
client.group = old.group;
client.disabled = old.disabled;
client.ops = old.ops;
client.ops_refilled = old.ops_refilled;
}
self.clients.insert(player, client);
if !rekey && let Some(g) = old.and_then(|o| o.group) {
self.leave_group_cleanup(g);
}
let c = &self.config;
let body = proto::secret(&proto::SecretInfo {
secret: &secret,
port: self.port,
player,
mtu: c.mtu.min(i32::MAX as u32) as i32,
distance: c.distance,
keep_alive_ms: c.keep_alive_ms.clamp(100, 60_000) as i32,
groups: c.groups,
voice_host: &c.voice_host,
});
self.changed.insert(player);
if rekey {
// Past the cap: the secret is what gets the client back.
self.outbox
.entry(player)
.or_default()
.push_back((proto::SECRET.to_owned(), body));
return;
}
self.outbox.remove(&player);
self.tell(player, proto::SECRET, body);
let all: Vec<PlayerState> = self
.clients
.keys()
.filter_map(|u| self.player_state(*u))
.collect();
self.tell(player, proto::STATES, proto::states(&all));
let groups: Vec<Vec<u8>> = self
.groups
.iter()
.map(|(id, g)| proto::add_group(*id, &g.name, g.password.is_some(), g.kind))
.collect();
for g in groups {
self.tell(player, proto::ADD_GROUP, g);
}
}
/// To every player with a voice chat client.
fn tell_all(&mut self, channel: &str, data: Vec<u8>) {
let all: Vec<u128> = self.clients.keys().copied().collect();
for p in all {
self.tell(p, channel, data.clone());
}
}
fn player_state(&self, uuid: u128) -> Option<PlayerState> {
let c = self.clients.get(&uuid)?;
Some(PlayerState {
uuid,
name: self
.players
.get(&uuid)
.map(|p| p.name.clone())
.unwrap_or_default(),
disabled: c.disabled,
disconnected: !c.connected,
group: c.group,
})
}
fn join(&mut self, player: u128, group: u128) {
let Some(c) = self.clients.get_mut(&player) else {
return;
};
let old = c.group.replace(group);
self.tell(
player,
proto::JOINED_GROUP,
proto::joined_group(Some(group), false),
);
self.changed.insert(player);
if let Some(old) = old
&& old != group
{
self.leave_group_cleanup(old);
}
}
/// An empty group goes away.
fn leave_group_cleanup(&mut self, group: u128) {
if self.clients.values().any(|c| c.group == Some(group)) {
return;
}
if self.groups.remove(&group).is_some() {
self.tell_all(proto::REMOVE_GROUP, proto::remove_group(group));
}
}
fn forget(&mut self, uuid: u128) {
self.outbox.remove(&uuid);
self.changed.remove(&uuid);
let Some(c) = self.clients.remove(&uuid) else {
return;
};
self.tell_all(proto::REMOVE_STATE, proto::remove_state(uuid));
if let Some(g) = c.group {
self.leave_group_cleanup(g);
}
}
/// Who hears `sender`, and how: (listener, address, positional range or None for the group's voice).
fn listeners(&self, sender: u128, whispering: bool) -> Vec<(u128, SocketAddr, Option<f64>)> {
let Some(from) = self.clients.get(&sender) else {
return Vec::new();
};
let from_kind = from.group.and_then(|g| self.groups.get(&g)).map(|g| g.kind);
let at = self.players.get(&sender);
let range = if whispering {
self.config.whisper_distance
} else {
self.config.distance
};
let mut out = Vec::new();
for (uuid, c) in &self.clients {
if *uuid == sender || c.disabled || !c.connected {
continue;
}
let Some(addr) = c.addr else { continue };
if from.group.is_some() && from.group == c.group {
out.push((*uuid, addr, None));
continue;
}
let to_kind = c.group.and_then(|g| self.groups.get(&g)).map(|g| g.kind);
// Normal and isolated groups aren't heard outside; isolated ones hear nobody outside.
if matches!(from_kind, Some(GroupKind::Normal | GroupKind::Isolated))
|| to_kind == Some(GroupKind::Isolated)
{
continue;
}
let (Some(a), Some(b)) = (at, self.players.get(uuid)) else {
continue;
};
if a.dimension != b.dimension {
continue;
}
let d = (0..3)
.map(|i| (a.position[i] - b.position[i]).powi(2))
.sum::<f64>()
.sqrt();
if d <= range {
out.push((*uuid, addr, Some(range)));
}
}
out
}
}
/// The UDP side: handshakes, keep-alives and voices.
fn relay(shared: &Shared) {
let mut buf = vec![0u8; 4096];
let mut last_keep_alive = Instant::now();
while shared.running.load(Ordering::Relaxed) {
match shared.socket.recv_from(&mut buf) {
Ok((n, from)) => datagram(shared, &buf[..n], from),
Err(e)
if matches!(
e.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) => {}
// Never spin on an error that comes back at once.
Err(_) => std::thread::sleep(Duration::from_millis(10)),
}
let every = Duration::from_millis(u64::from(
lock(&shared.state).config.keep_alive_ms.clamp(100, 60_000),
));
if last_keep_alive.elapsed() >= every {
last_keep_alive = Instant::now();
keep_alive(shared, every);
}
}
}
fn send(shared: &Shared, cipher: &proto::Cipher, to: SocketAddr, packet: &ToClient) {
if let Some(sealed) = cipher.seal(&shared.rng, &packet.write()) {
let _ = shared.socket.send_to(&proto::server_datagram(&sealed), to);
}
}
/// Keep-alives to every authenticated client. One that stopped answering
/// gets a fresh secret over the game connection: if it moved (its NAT
/// rebound), it comes back from its new address with that secret.
fn keep_alive(shared: &Shared, every: Duration) {
let mut st = lock(&shared.state);
let mut lost = Vec::new();
for (uuid, c) in st.clients.iter_mut() {
let Some(addr) = c.addr else { continue };
if c.connected && c.last_keep_alive.elapsed() > every * 10 {
c.connected = false;
lost.push(*uuid);
continue;
}
send(shared, &c.cipher, addr, &ToClient::KeepAlive);
}
for uuid in lost {
st.give_secret(&shared.rng, uuid, true);
}
}
fn datagram(shared: &Shared, datagram: &[u8], from: SocketAddr) {
let Ok((player, sealed)) = proto::client_datagram(datagram) else {
return;
};
let mut st = lock(&shared.state);
let Some(c) = st.clients.get_mut(&player) else {
return;
};
let Some(plain) = c.cipher.open(sealed) else {
return;
};
let Ok(packet) = FromClient::read(&plain) else {
return;
};
// It opened, so it holds at least a nonce and a tag.
let mut nonce = [0u8; 12];
nonce.copy_from_slice(&sealed[..12]);
let now = Instant::now();
if let FromClient::Authenticate {
player: who,
secret,
} = packet
{
if who != player || !constant_eq(&secret, &c.secret) {
return;
}
// The secret belongs to the first address that authenticated with it.
if c.addr.is_some_and(|a| a != from) || !c.fresh_nonce(nonce) {
return;
}
c.addr = Some(from);
send(shared, &c.cipher, from, &ToClient::AuthenticateAck);
return;
}
// Everything else only from the address that authenticated.
if c.addr != Some(from) {
return;
}
if let FromClient::Mic { sequence, .. } = packet {
if !c.fresh_sequence(sequence) {
return;
}
} else if !c.fresh_nonce(nonce) {
return;
}
match packet {
FromClient::ConnectionCheck => {
c.last_keep_alive = now;
if !c.connected {
c.connected = true;
st.changed.insert(player);
}
let c = &st.clients[&player];
send(shared, &c.cipher, from, &ToClient::ConnectionCheckAck);
}
FromClient::KeepAlive => {
c.last_keep_alive = now;
if !c.connected {
c.connected = true;
st.changed.insert(player);
}
}
// The server never pings, so this isn't an answer: not echoed (a
// client that echoes pings would otherwise bounce it forever).
FromClient::Ping { .. } => {}
FromClient::Mic {
data,
sequence,
whispering,
} => {
if c.disabled || !c.connected {
return;
}
c.tokens = (c.tokens + now.duration_since(c.refilled).as_secs_f64() * MIC_PER_SECOND)
.min(MIC_BURST);
c.refilled = now;
if c.tokens < 1.0 {
return;
}
c.tokens -= 1.0;
for (listener, addr, range) in st.listeners(player, whispering) {
let packet = match range {
Some(r) => ToClient::Positional {
sender: player,
data: &data,
sequence,
distance: r as f32,
whispering,
},
None => ToClient::Group {
sender: player,
data: &data,
sequence,
},
};
send(shared, &st.clients[&listener].cipher, addr, &packet);
}
}
FromClient::Authenticate { .. } => {}
}
}
fn constant_eq(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,734 @@
//! The voice chat wire format Simple Voice Chat 2.6.x clients speak
//! (compatibility version 20), from the server's side: control messages on
//! `voicechat:` channels over the game connection, and AES-128-GCM UDP
//! datagrams for the voice itself.
//!
//! Numbers are big-endian, a UUID is 16 bytes (most significant half first),
//! strings and byte arrays are a VarInt length then the bytes.
/// The only version this server speaks.
pub const COMPATIBILITY_VERSION: i32 = 20;
/// Every UDP datagram starts with it.
pub const MAGIC: u8 = 0xFF;
/// The largest Opus frame relayed.
pub const MAX_OPUS: usize = 1275;
/// The largest encrypted body read from a datagram.
pub const MAX_BODY: usize = 2048;
/// Group names and passwords, in characters.
pub const MAX_GROUP_TEXT: usize = 24;
pub const REQUEST_SECRET: &str = "voicechat:request_secret";
pub const SECRET: &str = "voicechat:secret";
pub const UPDATE_STATE: &str = "voicechat:update_state";
pub const STATE: &str = "voicechat:state";
pub const STATES: &str = "voicechat:states";
pub const REMOVE_STATE: &str = "voicechat:remove_state";
pub const CREATE_GROUP: &str = "voicechat:create_group";
pub const SET_GROUP: &str = "voicechat:set_group";
pub const LEAVE_GROUP: &str = "voicechat:leave_group";
pub const JOINED_GROUP: &str = "voicechat:joined_group";
pub const ADD_GROUP: &str = "voicechat:add_group";
pub const REMOVE_GROUP: &str = "voicechat:remove_group";
/// A message that can't be read.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Malformed;
type Read<T> = Result<T, Malformed>;
pub struct In<'a> {
rest: &'a [u8],
}
impl<'a> In<'a> {
pub fn new(data: &'a [u8]) -> Self {
Self { rest: data }
}
pub fn take(&mut self, n: usize) -> Read<&'a [u8]> {
if self.rest.len() < n {
return Err(Malformed);
}
let (head, tail) = self.rest.split_at(n);
self.rest = tail;
Ok(head)
}
fn fixed<const N: usize>(&mut self) -> Read<[u8; N]> {
let mut out = [0; N];
out.copy_from_slice(self.take(N)?);
Ok(out)
}
pub fn byte(&mut self) -> Read<u8> {
Ok(self.take(1)?[0])
}
pub fn flag(&mut self) -> Read<bool> {
Ok(self.byte()? != 0)
}
pub fn short(&mut self) -> Read<i16> {
Ok(i16::from_be_bytes(self.fixed()?))
}
pub fn int(&mut self) -> Read<i32> {
Ok(i32::from_be_bytes(self.fixed()?))
}
pub fn long(&mut self) -> Read<i64> {
Ok(i64::from_be_bytes(self.fixed()?))
}
pub fn uuid(&mut self) -> Read<u128> {
Ok(u128::from_be_bytes(self.fixed()?))
}
pub fn varint(&mut self) -> Read<i32> {
let mut value = 0u32;
for shift in 0..5 {
let b = self.byte()?;
value |= u32::from(b & 0x7F) << (shift * 7);
if b & 0x80 == 0 {
return Ok(value as i32);
}
}
Err(Malformed)
}
/// A VarInt length of at most `max`, then that many bytes.
pub fn bytes(&mut self, max: usize) -> Read<&'a [u8]> {
let n = usize::try_from(self.varint()?).map_err(|_| Malformed)?;
if n > max {
return Err(Malformed);
}
self.take(n)
}
/// A string of at most `max` characters.
pub fn text(&mut self, max: usize) -> Read<String> {
let raw = self.bytes(max.saturating_mul(4))?;
let s = std::str::from_utf8(raw).map_err(|_| Malformed)?;
if s.chars().count() > max {
return Err(Malformed);
}
Ok(s.to_owned())
}
}
#[derive(Default)]
pub struct Out(pub Vec<u8>);
impl Out {
pub fn byte(&mut self, v: u8) -> &mut Self {
self.0.push(v);
self
}
pub fn flag(&mut self, v: bool) -> &mut Self {
self.byte(u8::from(v))
}
pub fn short(&mut self, v: i16) -> &mut Self {
self.0.extend(v.to_be_bytes());
self
}
pub fn int(&mut self, v: i32) -> &mut Self {
self.0.extend(v.to_be_bytes());
self
}
pub fn long(&mut self, v: i64) -> &mut Self {
self.0.extend(v.to_be_bytes());
self
}
pub fn float(&mut self, v: f32) -> &mut Self {
self.0.extend(v.to_be_bytes());
self
}
pub fn double(&mut self, v: f64) -> &mut Self {
self.0.extend(v.to_be_bytes());
self
}
pub fn uuid(&mut self, v: u128) -> &mut Self {
self.0.extend(v.to_be_bytes());
self
}
pub fn varint(&mut self, v: i32) -> &mut Self {
let mut v = v as u32;
while v >= 0x80 {
self.0.push((v as u8) | 0x80);
v >>= 7;
}
self.0.push(v as u8);
self
}
pub fn bytes(&mut self, b: &[u8]) -> &mut Self {
self.varint(b.len() as i32);
self.0.extend_from_slice(b);
self
}
pub fn text(&mut self, s: &str) -> &mut Self {
self.bytes(s.as_bytes())
}
pub fn done(&mut self) -> Vec<u8> {
std::mem::take(&mut self.0)
}
}
// ------------------------------------------------------------ the game connection
/// How a group hears the world.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GroupKind {
/// Members hear each other anywhere and players nearby; nobody outside hears them.
Normal,
/// Members hear each other anywhere; players nearby hear them and are heard.
Open,
/// Members hear only each other.
Isolated,
}
impl GroupKind {
pub fn from_wire(v: i16) -> Self {
match v {
1 => Self::Open,
2 => Self::Isolated,
_ => Self::Normal,
}
}
pub fn wire(self) -> i16 {
match self {
Self::Normal => 0,
Self::Open => 1,
Self::Isolated => 2,
}
}
}
/// What a client asks on a `voicechat:` channel.
#[derive(Debug, Clone, PartialEq)]
pub enum Request {
Secret {
version: i32,
},
State {
disabled: bool,
},
CreateGroup {
name: String,
password: Option<String>,
kind: GroupKind,
},
JoinGroup {
group: u128,
password: Option<String>,
},
LeaveGroup,
}
impl Request {
/// None: not a channel a client sends on.
pub fn read(channel: &str, data: &[u8]) -> Option<Read<Self>> {
let mut r = In::new(data);
let parsed = match channel {
REQUEST_SECRET => r.int().map(|version| Self::Secret { version }),
UPDATE_STATE => r.flag().map(|disabled| Self::State { disabled }),
CREATE_GROUP => (|| {
let name = r.text(MAX_GROUP_TEXT)?;
let password = if r.flag()? {
Some(r.text(MAX_GROUP_TEXT)?)
} else {
None
};
let kind = GroupKind::from_wire(r.short()?);
Ok(Self::CreateGroup {
name,
password,
kind,
})
})(),
SET_GROUP => (|| {
let group = r.uuid()?;
let password = if r.flag()? {
Some(r.text(MAX_GROUP_TEXT)?)
} else {
None
};
Ok(Self::JoinGroup { group, password })
})(),
LEAVE_GROUP => Ok(Self::LeaveGroup),
_ => return None,
};
Some(parsed)
}
}
/// `voicechat:secret`: what the client needs to open its voice connection.
pub struct SecretInfo<'a> {
pub secret: &'a [u8; 16],
pub port: u16,
pub player: u128,
pub mtu: i32,
pub distance: f64,
pub keep_alive_ms: i32,
pub groups: bool,
/// Empty: the game server's own address.
pub voice_host: &'a str,
}
pub fn secret(s: &SecretInfo) -> Vec<u8> {
let mut o = Out::default();
o.0.extend_from_slice(s.secret);
o.int(i32::from(s.port))
.uuid(s.player)
.byte(0) // the Opus application: VoIP
.int(s.mtu)
.double(s.distance)
.int(s.keep_alive_ms)
.flag(s.groups)
.text(s.voice_host)
.flag(false) // recording not allowed
.done()
}
/// A player's voice chat state, as `voicechat:state` and each of `voicechat:states` carry it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlayerState {
pub uuid: u128,
pub name: String,
pub disabled: bool,
pub disconnected: bool,
pub group: Option<u128>,
}
fn put_state(o: &mut Out, s: &PlayerState) {
o.flag(s.disabled)
.flag(s.disconnected)
.uuid(s.uuid)
.text(&s.name)
.flag(s.group.is_some());
if let Some(g) = s.group {
o.uuid(g);
}
}
pub fn state(s: &PlayerState) -> Vec<u8> {
let mut o = Out::default();
put_state(&mut o, s);
o.done()
}
pub fn states(all: &[PlayerState]) -> Vec<u8> {
let mut o = Out::default();
o.int(all.len() as i32);
for s in all {
put_state(&mut o, s);
}
o.done()
}
pub fn remove_state(uuid: u128) -> Vec<u8> {
Out::default().uuid(uuid).done()
}
pub fn add_group(id: u128, name: &str, has_password: bool, kind: GroupKind) -> Vec<u8> {
Out::default()
.uuid(id)
.text(name)
.flag(has_password)
.flag(false) // persistent
.flag(false) // hidden
.short(kind.wire())
.done()
}
pub fn remove_group(id: u128) -> Vec<u8> {
Out::default().uuid(id).done()
}
pub fn joined_group(group: Option<u128>, wrong_password: bool) -> Vec<u8> {
let mut o = Out::default();
o.flag(group.is_some());
if let Some(g) = group {
o.uuid(g);
}
o.flag(wrong_password).done()
}
// ------------------------------------------------------------ UDP
/// A voice packet a client sends.
#[derive(Debug, Clone, PartialEq)]
pub enum FromClient {
/// The microphone: an Opus frame (empty: stopped talking).
Mic {
data: Vec<u8>,
sequence: i64,
whispering: bool,
},
Authenticate {
player: u128,
secret: [u8; 16],
},
Ping {
id: u128,
timestamp: i64,
},
KeepAlive,
ConnectionCheck,
}
impl FromClient {
pub fn read(plain: &[u8]) -> Read<Self> {
let mut r = In::new(plain);
Ok(match r.byte()? {
1 => Self::Mic {
data: r.bytes(MAX_OPUS)?.to_vec(),
sequence: r.long()?,
whispering: r.flag()?,
},
5 => Self::Authenticate {
player: r.uuid()?,
secret: r.fixed()?,
},
7 => Self::Ping {
id: r.uuid()?,
timestamp: r.long()?,
},
8 => Self::KeepAlive,
9 => Self::ConnectionCheck,
_ => return Err(Malformed),
})
}
}
/// A voice packet this server sends.
#[derive(Debug, Clone, PartialEq)]
pub enum ToClient<'a> {
/// A voice heard where `sender` stands.
Positional {
sender: u128,
data: &'a [u8],
sequence: i64,
distance: f32,
whispering: bool,
},
/// A group member's voice, heard everywhere.
Group {
sender: u128,
data: &'a [u8],
sequence: i64,
},
AuthenticateAck,
Ping {
id: u128,
timestamp: i64,
},
KeepAlive,
ConnectionCheckAck,
}
impl ToClient<'_> {
pub fn write(&self) -> Vec<u8> {
let mut o = Out::default();
match self {
// The channel is the sender: the client places a positional voice at that entity.
Self::Positional {
sender,
data,
sequence,
distance,
whispering,
} => o
.byte(2)
.uuid(*sender)
.uuid(*sender)
.bytes(data)
.long(*sequence)
.float(*distance)
.flag(*whispering),
Self::Group {
sender,
data,
sequence,
} => o
.byte(3)
.uuid(*sender)
.uuid(*sender)
.bytes(data)
.long(*sequence)
.byte(0),
Self::AuthenticateAck => o.byte(6),
Self::Ping { id, timestamp } => o.byte(7).uuid(*id).long(*timestamp),
Self::KeepAlive => o.byte(8),
Self::ConnectionCheckAck => o.byte(10),
};
o.done()
}
}
/// AES-128-GCM keyed by a player's secret: a random 12-byte nonce, then the
/// ciphertext and its 16-byte tag.
pub struct Cipher {
key: ring::aead::LessSafeKey,
}
impl Cipher {
pub fn new(secret: &[u8; 16]) -> Self {
let key =
ring::aead::UnboundKey::new(&ring::aead::AES_128_GCM, secret).expect("a 16-byte key");
Self {
key: ring::aead::LessSafeKey::new(key),
}
}
pub fn seal(&self, rng: &dyn ring::rand::SecureRandom, plain: &[u8]) -> Option<Vec<u8>> {
let mut nonce = [0u8; 12];
rng.fill(&mut nonce).ok()?;
let mut body = plain.to_vec();
self.key
.seal_in_place_append_tag(
ring::aead::Nonce::assume_unique_for_key(nonce),
ring::aead::Aad::empty(),
&mut body,
)
.ok()?;
let mut out = nonce.to_vec();
out.extend(body);
Some(out)
}
pub fn open(&self, sealed: &[u8]) -> Option<Vec<u8>> {
if sealed.len() < 12 + 16 {
return None;
}
let (nonce, body) = sealed.split_at(12);
let nonce = ring::aead::Nonce::try_assume_unique_for_key(nonce).ok()?;
let mut body = body.to_vec();
let plain = self
.key
.open_in_place(nonce, ring::aead::Aad::empty(), &mut body)
.ok()?;
Some(plain.to_vec())
}
}
/// A client's datagram: magic, the player's UUID (in clear, to find their key), the sealed packet.
pub fn client_datagram(datagram: &[u8]) -> Read<(u128, &[u8])> {
let mut r = In::new(datagram);
if r.byte()? != MAGIC {
return Err(Malformed);
}
let player = r.uuid()?;
Ok((player, r.bytes(MAX_BODY)?))
}
/// A server datagram: magic, then the sealed packet.
pub fn server_datagram(sealed: &[u8]) -> Vec<u8> {
Out::default().byte(MAGIC).bytes(sealed).done()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn varints_match_minecraft() {
for (v, bytes) in [
(0, vec![0]),
(127, vec![0x7F]),
(128, vec![0x80, 1]),
(300, vec![0xAC, 2]),
(-1, vec![0xFF, 0xFF, 0xFF, 0xFF, 0x0F]),
] {
assert_eq!(Out::default().varint(v).done(), bytes, "{v}");
assert_eq!(In::new(&bytes).varint().unwrap(), v);
}
assert_eq!(In::new(&[0x80; 6]).varint(), Err(Malformed));
}
#[test]
fn requests_read_their_layouts() {
assert_eq!(
Request::read(REQUEST_SECRET, &[0, 0, 0, 20]),
Some(Ok(Request::Secret { version: 20 }))
);
assert_eq!(
Request::read(UPDATE_STATE, &[1]),
Some(Ok(Request::State { disabled: true }))
);
// name "ab", a password "x", kind open.
assert_eq!(
Request::read(CREATE_GROUP, &[2, b'a', b'b', 1, 1, b'x', 0, 1]),
Some(Ok(Request::CreateGroup {
name: "ab".into(),
password: Some("x".into()),
kind: GroupKind::Open
}))
);
let mut join = 7u128.to_be_bytes().to_vec();
join.push(0);
assert_eq!(
Request::read(SET_GROUP, &join),
Some(Ok(Request::JoinGroup {
group: 7,
password: None
}))
);
assert_eq!(
Request::read(LEAVE_GROUP, &[]),
Some(Ok(Request::LeaveGroup))
);
assert_eq!(
Request::read("voicechat:secret", &[]),
None,
"a server's channel"
);
let long = [&[30u8][..], &[b'n'; 30]].concat();
assert_eq!(
Request::read(CREATE_GROUP, &[&long[..], &[0, 0, 0]].concat()),
Some(Err(Malformed))
);
assert_eq!(Request::read(REQUEST_SECRET, &[0, 0]), Some(Err(Malformed)));
}
#[test]
fn secret_layout() {
let key = [9u8; 16];
let bytes = secret(&SecretInfo {
secret: &key,
port: 24454,
player: 0xAB,
mtu: 1024,
distance: 48.0,
keep_alive_ms: 1000,
groups: true,
voice_host: "",
});
// key, port, uuid, codec, mtu, distance, keep-alive, groups, "" , recording
assert_eq!(bytes.len(), 16 + 4 + 16 + 1 + 4 + 8 + 4 + 1 + 1 + 1);
assert_eq!(&bytes[..16], &key);
assert_eq!(&bytes[16..20], &24454i32.to_be_bytes());
assert_eq!(&bytes[20..36], &0xABu128.to_be_bytes());
assert_eq!(&bytes[41..49], &48.0f64.to_be_bytes());
assert_eq!(bytes[53], 1);
}
#[test]
fn states_and_groups_layouts() {
let a = PlayerState {
uuid: 1,
name: "a".into(),
disabled: true,
disconnected: false,
group: Some(2),
};
assert_eq!(
state(&a),
[
&[1u8, 0][..],
&1u128.to_be_bytes(),
&[1, b'a', 1],
&2u128.to_be_bytes()
]
.concat()
);
let both = states(&[
a.clone(),
PlayerState {
group: None,
..a.clone()
},
]);
assert_eq!(&both[..4], &2i32.to_be_bytes());
assert_eq!(both.len(), 4 + 37 + 21);
let g = add_group(5, "Base", true, GroupKind::Isolated);
assert_eq!(
g,
[
&5u128.to_be_bytes()[..],
&[4, b'B', b'a', b's', b'e', 1, 0, 0, 0, 2]
]
.concat()
);
assert_eq!(joined_group(None, true), vec![0, 1]);
assert_eq!(
joined_group(Some(3), false),
[&[1u8][..], &3u128.to_be_bytes(), &[0]].concat()
);
}
#[test]
fn voice_packets() {
let mic = [&[1u8, 2, 0xAA, 0xBB][..], &5i64.to_be_bytes(), &[1]].concat();
assert_eq!(
FromClient::read(&mic),
Ok(FromClient::Mic {
data: vec![0xAA, 0xBB],
sequence: 5,
whispering: true
})
);
let auth = [&[5u8][..], &7u128.to_be_bytes(), &[3; 16]].concat();
assert_eq!(
FromClient::read(&auth),
Ok(FromClient::Authenticate {
player: 7,
secret: [3; 16]
})
);
assert_eq!(FromClient::read(&[8]), Ok(FromClient::KeepAlive));
assert_eq!(FromClient::read(&[9]), Ok(FromClient::ConnectionCheck));
assert_eq!(FromClient::read(&[2]), Err(Malformed), "a server's packet");
let big = [
&[1u8][..],
&Out::default().varint(1276).done(),
&[0; 1276],
&[0; 9],
]
.concat();
assert_eq!(FromClient::read(&big), Err(Malformed));
let sound = ToClient::Positional {
sender: 4,
data: &[1, 2],
sequence: 9,
distance: 48.0,
whispering: false,
}
.write();
assert_eq!(sound[0], 2);
assert_eq!(&sound[1..17], &4u128.to_be_bytes());
assert_eq!(&sound[17..33], &4u128.to_be_bytes());
assert_eq!(&sound[33..36], &[2, 1, 2]);
assert_eq!(&sound[36..44], &9i64.to_be_bytes());
assert_eq!(&sound[44..48], &48.0f32.to_be_bytes());
assert_eq!(sound[48], 0);
assert_eq!(sound.len(), 49);
let group = ToClient::Group {
sender: 4,
data: &[1],
sequence: 2,
}
.write();
assert_eq!(
(group[0], group.len(), group[group.len() - 1]),
(3, 1 + 32 + 2 + 8 + 1, 0)
);
assert_eq!(ToClient::AuthenticateAck.write(), vec![6]);
assert_eq!(ToClient::ConnectionCheckAck.write(), vec![10]);
}
#[test]
fn sealed_bodies_round_trip_and_reject_tampering() {
let rng = ring::rand::SystemRandom::new();
let c = Cipher::new(&[1; 16]);
let sealed = c.seal(&rng, b"hello").unwrap();
assert_eq!(sealed.len(), 12 + 5 + 16);
assert_eq!(c.open(&sealed).unwrap(), b"hello");
let mut bad = sealed.clone();
bad[14] ^= 1;
assert_eq!(c.open(&bad), None);
assert_eq!(Cipher::new(&[2; 16]).open(&sealed), None);
assert_ne!(
c.seal(&rng, b"hello").unwrap(),
sealed,
"fresh nonce each time"
);
let datagram = [
&[MAGIC][..],
&9u128.to_be_bytes(),
&Out::default().bytes(&sealed).done(),
]
.concat();
assert_eq!(client_datagram(&datagram), Ok((9, &sealed[..])));
assert_eq!(client_datagram(&datagram[1..]), Err(Malformed));
assert_eq!(server_datagram(&[1, 2])[..], [MAGIC, 2, 1, 2]);
}
}
@@ -0,0 +1,653 @@
//! The relay with real UDP sockets on localhost: clients that speak the
//! wire format the way a voice chat client does, written from the layouts.
use super::*;
use proto::{In, Out};
const A: u128 = 0xA;
const B: u128 = 0xB;
const FAR: u128 = 0xC;
const NETHER: u128 = 0xD;
const PLAIN: u128 = 0xE;
fn player(uuid: u128, name: &str, dimension: &str, x: f64) -> Player {
Player {
uuid,
name: name.into(),
dimension: dimension.into(),
position: [x, 64.0, 0.0],
}
}
fn world() -> Vec<Player> {
vec![
player(A, "alice", "minecraft:overworld", 0.0),
player(B, "bob", "minecraft:overworld", 10.0),
player(FAR, "carol", "minecraft:overworld", 500.0),
player(NETHER, "dave", "minecraft:the_nether", 0.0),
// Joined without a voice chat client: never asks, never told.
player(PLAIN, "erin", "minecraft:overworld", 5.0),
]
}
fn server() -> Server {
server_with(Config::default())
}
fn server_with(config: Config) -> Server {
let s = Server::start(Config {
bind: "127.0.0.1".into(),
port: 0,
..config
})
.unwrap();
s.tick(world());
s
}
/// The messages for `who` among `all`.
fn for_player(all: &[Message], who: u128) -> Vec<(String, Vec<u8>)> {
all.iter()
.filter(|m| m.0 == who)
.map(|m| (m.1.clone(), m.2.clone()))
.collect()
}
struct Voice {
uuid: u128,
secret: [u8; 16],
cipher: proto::Cipher,
socket: UdpSocket,
server: SocketAddr,
rng: SystemRandom,
/// The datagrams of its handshake: authenticate, connection check.
handshake: Vec<Vec<u8>>,
}
impl Voice {
/// Reads the secret the server gave, then authenticates and checks the connection.
fn connect(uuid: u128, secret_message: &[u8]) -> Self {
let mut r = In::new(secret_message);
let secret: [u8; 16] = r.take(16).unwrap().try_into().unwrap();
let port = r.int().unwrap() as u16;
assert_eq!(r.uuid().unwrap(), uuid);
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(300)))
.unwrap();
let mut v = Self {
uuid,
secret,
cipher: proto::Cipher::new(&secret),
socket,
server: SocketAddr::from(([127, 0, 0, 1], port)),
rng: SystemRandom::new(),
handshake: Vec::new(),
};
let auth = v.send(
&Out::default()
.byte(5)
.uuid(uuid)
.done()
.into_iter()
.chain(secret)
.collect::<Vec<_>>(),
);
assert_eq!(v.next(|p| p == [6]), Some(vec![6]), "authenticated");
let check = v.send(&[9]);
assert_eq!(v.next(|p| p == [10]), Some(vec![10]), "connection checked");
v.handshake = vec![auth, check];
v
}
/// Sends a sealed packet; returns the datagram.
fn send(&self, plain: &[u8]) -> Vec<u8> {
let sealed = self.cipher.seal(&self.rng, plain).unwrap();
let datagram = Out::default()
.byte(proto::MAGIC)
.uuid(self.uuid)
.bytes(&sealed)
.done();
self.socket.send_to(&datagram, self.server).unwrap();
datagram
}
/// Answers keep-alives for a while, as a live client does.
fn idle(&self, for_ms: u64) {
let until = Instant::now() + Duration::from_millis(for_ms);
while Instant::now() < until {
self.next(|_| false);
}
}
fn talk(&self, sequence: i64, whispering: bool) -> Vec<u8> {
self.send(
&Out::default()
.byte(1)
.bytes(&[0xAB; 40])
.long(sequence)
.flag(whispering)
.done(),
)
}
/// The next packet `wanted` accepts, skipping keep-alives (answered) and others; None after a quiet while.
fn next(&self, wanted: impl Fn(&[u8]) -> bool) -> Option<Vec<u8>> {
let mut buf = [0u8; 4096];
let until = Instant::now() + Duration::from_millis(900);
while Instant::now() < until {
let Ok((n, _)) = self.socket.recv_from(&mut buf) else {
continue;
};
let mut r = In::new(&buf[..n]);
assert_eq!(r.byte().unwrap(), proto::MAGIC);
let plain = self
.cipher
.open(r.bytes(4096).unwrap())
.expect("sealed with my key");
if plain == [8] {
self.send(&[8]);
}
if wanted(&plain) {
return Some(plain);
}
}
None
}
fn hears(&self) -> Option<Vec<u8>> {
self.next(|p| p[0] == 2 || p[0] == 3)
}
}
fn ask_secret(s: &Server, who: u128) -> Vec<u8> {
s.message(who, proto::REQUEST_SECRET, &20i32.to_be_bytes());
let out = s.outgoing(64);
for_player(&out, who)
.into_iter()
.find(|(c, _)| c == proto::SECRET)
.expect("a secret")
.1
}
#[test]
fn only_asking_clients_hear_of_voice_chat() {
let s = server();
assert!(s.outgoing(64).is_empty(), "nothing before anyone asks");
// Wrong version: no answer.
s.message(A, proto::REQUEST_SECRET, &21i32.to_be_bytes());
assert!(s.outgoing(64).is_empty());
s.message(A, proto::REQUEST_SECRET, &20i32.to_be_bytes());
s.tick(world());
let out = s.outgoing(64);
assert!(out.iter().all(|m| m.0 == A), "{out:?}");
let channels: Vec<String> = for_player(&out, A).into_iter().map(|m| m.0).collect();
assert_eq!(channels, [proto::SECRET, proto::STATES, proto::STATE]);
// A message from someone not on the server, or garbage, is ignored.
s.message(0xF, proto::REQUEST_SECRET, &20i32.to_be_bytes());
s.message(B, proto::CREATE_GROUP, &[200]);
s.tick(world());
assert!(s.outgoing(64).is_empty());
}
#[test]
fn voices_reach_who_is_near_in_the_same_dimension() {
let s = server();
let voices: Vec<Voice> = [A, B, FAR, NETHER]
.iter()
.map(|&u| Voice::connect(u, &ask_secret(&s, u)))
.collect();
let [a, b, far, nether] = &voices[..] else {
unreachable!()
};
s.tick(world());
let states = s.outgoing(64);
assert!(
for_player(&states, PLAIN).is_empty(),
"the player without voice chat gets nothing"
);
a.talk(0, false);
let heard = b.hears().expect("bob, 10 blocks away, hears alice");
assert_eq!(heard[0], 2, "positional");
assert_eq!(&heard[1..17], &A.to_be_bytes(), "at alice");
assert_eq!(&heard[33..34], &[40]);
assert_eq!(
&heard[heard.len() - 13..heard.len() - 5],
&0i64.to_be_bytes()
);
assert_eq!(far.hears(), None, "500 blocks away");
assert_eq!(nether.hears(), None, "another dimension");
assert_eq!(a.hears(), None, "not your own voice");
// A whisper carries 24 blocks: bob at 10 still hears it; at 30 he doesn't.
a.talk(1, true);
assert_eq!(
b.hears().map(|p| p[p.len() - 1]),
Some(1),
"whispering flag"
);
let mut moved = world();
moved[1].position[0] = 30.0;
s.tick(moved);
a.talk(2, true);
assert_eq!(b.hears(), None);
a.talk(3, false);
assert!(b.hears().is_some());
s.tick(world());
// Voice chat off: bob neither hears nor is heard.
s.message(B, proto::UPDATE_STATE, &[1]);
a.talk(4, false);
assert_eq!(b.hears(), None);
b.talk(0, false);
assert_eq!(a.hears(), None);
s.message(B, proto::UPDATE_STATE, &[0]);
a.talk(5, false);
assert!(b.hears().is_some());
}
#[test]
fn groups_follow_their_kind() {
let s = server();
let a = Voice::connect(A, &ask_secret(&s, A));
let b = Voice::connect(B, &ask_secret(&s, B));
let far = Voice::connect(FAR, &ask_secret(&s, FAR));
s.tick(world());
s.outgoing(64);
// Alice makes an isolated group with a password; carol joins it from 500 blocks.
s.message(
A,
proto::CREATE_GROUP,
&Out::default()
.text("Base")
.flag(true)
.text("pw")
.short(2)
.done(),
);
let out = s.outgoing(64);
let add = for_player(&out, FAR)
.into_iter()
.find(|m| m.0 == proto::ADD_GROUP)
.expect("everyone sees the group")
.1;
let id = In::new(&add).uuid().unwrap();
let joined = for_player(&out, A)
.into_iter()
.find(|m| m.0 == proto::JOINED_GROUP)
.unwrap()
.1;
assert_eq!(joined, proto::joined_group(Some(id), false));
s.message(
FAR,
proto::SET_GROUP,
&Out::default().uuid(id).flag(true).text("nope").done(),
);
let out = s.outgoing(64);
assert_eq!(
for_player(&out, FAR),
vec![(
proto::JOINED_GROUP.to_owned(),
proto::joined_group(None, true)
)]
);
s.message(
FAR,
proto::SET_GROUP,
&Out::default().uuid(id).flag(true).text("pw").done(),
);
s.tick(world());
s.outgoing(64);
a.talk(0, false);
assert_eq!(
far.hears().map(|p| p[0]),
Some(3),
"a group voice, from anywhere"
);
assert_eq!(b.hears(), None, "isolated: bob nearby doesn't hear alice");
b.talk(0, false);
assert_eq!(a.hears(), None, "isolated: alice doesn't hear bob");
// Both leave: the empty group goes away for everyone.
s.message(A, proto::LEAVE_GROUP, &[]);
s.message(FAR, proto::LEAVE_GROUP, &[]);
let out = s.outgoing(64);
assert!(
for_player(&out, B).contains(&(proto::REMOVE_GROUP.to_owned(), proto::remove_group(id)))
);
a.talk(1, false);
assert_eq!(b.hears().map(|p| p[0]), Some(2), "back to proximity");
// An open group: members are heard nearby, and hear who is nearby.
s.message(
A,
proto::CREATE_GROUP,
&Out::default().text("Open").flag(false).short(1).done(),
);
s.tick(world());
s.outgoing(64);
a.talk(2, false);
assert_eq!(b.hears().map(|p| p[0]), Some(2));
b.talk(1, false);
assert_eq!(a.hears().map(|p| p[0]), Some(2));
// A normal one: members aren't heard outside, but hear who is nearby.
s.message(
A,
proto::CREATE_GROUP,
&Out::default().text("Normal").flag(false).short(0).done(),
);
s.tick(world());
s.outgoing(64);
a.talk(3, false);
assert_eq!(b.hears(), None);
b.talk(2, false);
assert_eq!(a.hears().map(|p| p[0]), Some(2));
}
#[test]
fn leaving_and_strangers() {
let s = server();
let a = Voice::connect(A, &ask_secret(&s, A));
let b = Voice::connect(B, &ask_secret(&s, B));
s.tick(world());
s.outgoing(64);
// Someone without the key can't speak as alice, nor take her address.
let stranger = UdpSocket::bind("127.0.0.1:0").unwrap();
let fake = proto::Cipher::new(&[0; 16])
.seal(&SystemRandom::new(), &[1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 9, 0])
.unwrap();
stranger
.send_to(
&Out::default()
.byte(proto::MAGIC)
.uuid(A)
.bytes(&fake)
.done(),
b.server,
)
.unwrap();
assert_eq!(b.hears(), None);
// A copy of alice's own handshake from elsewhere, while she's active, is refused.
let replay = a
.cipher
.seal(
&a.rng,
&Out::default()
.byte(5)
.uuid(A)
.done()
.into_iter()
.chain(a.secret)
.collect::<Vec<_>>(),
)
.unwrap();
stranger
.set_read_timeout(Some(Duration::from_millis(300)))
.unwrap();
stranger
.send_to(
&Out::default()
.byte(proto::MAGIC)
.uuid(A)
.bytes(&replay)
.done(),
b.server,
)
.unwrap();
let mut buf = [0u8; 64];
assert!(
stranger.recv_from(&mut buf).is_err(),
"no answer to a stranger"
);
a.talk(0, false);
assert!(
b.hears().is_some(),
"alice still talks from her own address"
);
// Bob leaves the game: alice is told, and his voice is gone.
let mut without_bob = world();
without_bob.retain(|p| p.uuid != B);
s.tick(without_bob);
let out = s.outgoing(64);
assert_eq!(
for_player(&out, A),
vec![(proto::REMOVE_STATE.to_owned(), proto::remove_state(B))]
);
b.talk(5, false);
assert_eq!(a.hears(), None);
s.left(A);
assert!(s.outgoing(64).is_empty());
}
#[test]
fn outgoing_is_paced_per_player() {
let s = server();
ask_secret(&s, A);
for i in 0..10 {
s.message(
A,
proto::CREATE_GROUP,
&Out::default()
.text(&format!("g{i}"))
.flag(false)
.short(0)
.done(),
);
}
let first = s.outgoing(4);
assert_eq!(first.len(), 4);
let mut rest = 0;
loop {
let more = s.outgoing(4);
if more.is_empty() {
break;
}
assert!(more.len() <= 4);
rest += more.len();
}
// Each group: its add and alice's joined (the old one, emptied, is removed).
assert_eq!(4 + rest, 10 * 2 + 9);
}
#[test]
fn config_defaults_fill_in() {
let c: Config = serde_json::from_str(r#"{"port": 25565}"#).unwrap();
assert_eq!((c.port, c.distance, c.enabled), (25565, 48.0, true));
assert!(
Server::start(Config {
enabled: false,
..Config::default()
})
.is_err()
);
}
/// A socket elsewhere that copies datagrams it saw and sends them as its own.
fn stranger() -> UdpSocket {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(300)))
.unwrap();
socket
}
fn answered(socket: &UdpSocket) -> bool {
let mut buf = [0u8; 4096];
socket.recv_from(&mut buf).is_ok()
}
#[test]
fn a_replayed_handshake_after_a_silence_moves_nothing() {
let s = server();
let a = Voice::connect(A, &ask_secret(&s, A));
let b = Voice::connect(B, &ask_secret(&s, B));
s.tick(world());
s.outgoing(64);
// Alice says nothing for longer than the old 5 s move rule (but less than
// the 10 s after which she counts as lost).
std::thread::sleep(Duration::from_millis(5300));
let eve = stranger();
for datagram in &a.handshake {
eve.send_to(datagram, a.server).unwrap();
assert!(!answered(&eve), "no answer to a copied handshake");
}
b.talk(0, false);
assert!(a.hears().is_some(), "bob's voice still goes to alice");
assert!(!answered(&eve), "and not to the copier");
// Exact copies from alice's own address are dropped too.
a.socket.send_to(&a.handshake[1], a.server).unwrap();
assert_eq!(a.next(|p| p == [10]), None, "a copied check isn't answered");
let said = a.talk(0, false);
assert!(b.hears().is_some());
a.socket.send_to(&said, a.server).unwrap();
assert_eq!(b.hears(), None, "a copied voice packet isn't relayed twice");
a.talk(1, false);
assert!(b.hears().is_some(), "the next one is");
// Once her handshake has left the nonces remembered, the secret still
// belongs to her address alone.
for _ in 0..SEEN_NONCES + 4 {
a.send(&[8]);
}
a.idle(200);
for datagram in &a.handshake {
eve.send_to(datagram, a.server).unwrap();
assert!(!answered(&eve), "no answer to an old copy either");
}
b.talk(1, false);
assert!(a.hears().is_some());
assert!(!answered(&eve));
}
#[test]
fn a_client_the_server_stops_hearing_gets_a_fresh_secret() {
let s = server_with(Config {
// Lost after 3 s: bob, who answers keep-alives only while a test
// reads his socket, must not count as lost too.
keep_alive_ms: 300,
..Config::default()
});
let a = Voice::connect(A, &ask_secret(&s, A));
let b = Voice::connect(B, &ask_secret(&s, B));
s.message(
A,
proto::CREATE_GROUP,
&Out::default().text("Open").flag(false).short(1).done(),
);
s.tick(world());
let out = s.outgoing(64);
let group = for_player(&out, A)
.into_iter()
.find(|m| m.0 == proto::ADD_GROUP)
.map(|m| In::new(&m.1).uuid().unwrap())
.unwrap();
// Alice's NAT rebinds: the server hears nothing from her for 10 keep-alives.
b.idle(3500);
let out = s.outgoing(64);
let to_alice = for_player(&out, A);
assert_eq!(
to_alice.iter().map(|m| m.0.as_str()).collect::<Vec<_>>(),
[proto::SECRET],
"only a new secret, over the game connection"
);
let fresh = &to_alice[0].1;
assert_ne!(&fresh[..16], &a.secret, "a new key");
// The old key opens nothing now, from anywhere.
let eve = stranger();
eve.send_to(&a.handshake[0], a.server).unwrap();
assert!(!answered(&eve));
a.send(&[9]);
assert_eq!(a.next(|p| p == [10]), None);
// From her new address with the new secret, she is back, in her group.
let moved = Voice::connect(A, fresh);
s.tick(world());
let out = s.outgoing(64);
let alice = proto::state(&PlayerState {
uuid: A,
name: "alice".into(),
disabled: false,
disconnected: false,
group: Some(group),
});
assert!(
for_player(&out, B).contains(&(proto::STATE.to_owned(), alice)),
"{out:?}"
);
b.talk(0, false);
assert!(
moved.hears().is_some(),
"bob's voice reaches her new address"
);
assert_eq!(a.hears(), None, "and not the old one");
moved.talk(0, false);
assert!(b.hears().is_some());
}
#[test]
fn repeated_secret_requests_and_group_changes_stay_bounded() {
let s = server();
// 40 players, each in a group of their own.
let many: Vec<Player> = (0..40u128)
.map(|i| player(0x100 + i, &format!("p{i}"), "minecraft:overworld", 0.0))
.collect();
s.tick(many.clone());
for p in &many {
ask_secret(&s, p.uuid);
s.message(
p.uuid,
proto::CREATE_GROUP,
&Out::default().text(&p.name).flag(false).short(0).done(),
);
}
s.tick(many.clone());
s.outgoing(usize::MAX);
// A thousand requests in a row: answered once now, once more a second later.
let first = many[0].uuid;
for _ in 0..1000 {
s.message(first, proto::REQUEST_SECRET, &20i32.to_be_bytes());
}
let count = |out: &[Message], channel: &str| {
out.iter()
.filter(|m| m.0 == first && m.1 == channel)
.count()
};
let out = s.outgoing(usize::MAX);
assert_eq!(count(&out, proto::SECRET), 0, "too soon after the last one");
std::thread::sleep(SECRET_EVERY + Duration::from_millis(50));
s.tick(many.clone());
let out = s.outgoing(usize::MAX);
assert_eq!(count(&out, proto::SECRET), 1);
// Starting over, it left its own group, which went away.
assert_eq!(count(&out, proto::ADD_GROUP), 39, "the groups once");
let mine: Vec<String> = for_player(&out, first).into_iter().map(|m| m.0).collect();
assert!(mine.len() <= 1 + 1 + 39 + 1, "{mine:?}");
s.tick(many.clone());
assert_eq!(count(&s.outgoing(usize::MAX), proto::SECRET), 0);
// Group changes: a burst of 10, then 2 a second.
let second = many[1].uuid;
for i in 0..100 {
s.message(
second,
proto::CREATE_GROUP,
&Out::default()
.text(&format!("g{i}"))
.flag(false)
.short(0)
.done(),
);
}
let out = s.outgoing(usize::MAX);
let joined = out
.iter()
.filter(|m| m.0 == second && m.1 == proto::JOINED_GROUP)
.count();
// Its bucket refilled during the wait: 10 of the 100.
assert_eq!(joined, OPS_BURST as usize);
}
@@ -0,0 +1,13 @@
// The end of each server tick: the relay learns where everyone is, and its
// messages leave for the players' clients.
fn voicechat_server_tick(
game: &mut crate::game::Game<'_>,
phase: crate::game::native_lifecycle::ServerTickPhase,
) {
if matches!(
phase,
crate::game::native_lifecycle::ServerTickPhase::ServerTickEnd { .. }
) {
crate::game::voicechat_server::tick(game);
}
}
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+6
View File
@@ -6,3 +6,9 @@
# sha256 = "..."
# reason = "What it did, in one sentence players will read."
# date = "2026-10-24"
[[revoked]]
id = "crossversion"
sha256 = "deb9cf8a84838e6c48999f70fbd3c81db90992c456d6e85a0e0746b088c5b8fa"
reason = "Paused while its Microsoft sign-in for online-mode servers is reviewed with its author; a reviewed version will replace it."
date = "2026-11-03"