Compare commits

..
Author SHA1 Message Date
Ludovic MarieandMusti3214 83ace618de Add server_browser 2.2.0
Submitted by @Musti3214 through the form.

Submission: bvGS1lN2yYLgBA1QcXQgGlNfTWdkBEwglD5IG7qfDO8
Co-authored-by: Musti3214 <[email protected]>
2026-11-05 01:13:50 +01:00
e53ad8220b SMP 0.33.0: no daily sale or /pay caps, no resale delay; a 20% price floor instead (#228)
* Remove SMP daily coin cap on sales and payments

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

* Remove SMP daily per-item sale limit

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

* Allow immediate resale of SMP market purchases

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

* SMP 0.33.0: a 20% price floor carries what the daily caps did

With no daily sale caps, the market's moving price is what bounds a farm:
- min_factor 0.6 -> 0.2 (a config still on the old default gets 0.2 at load)
- an item's depth is at most 50 items a 1% (was up to 400), so the cheap
  items farms make reach the floor within 10,000 sold; a family takes its
  step from its cheapest member, so every member does
- tests: 10,000 of any item sold a minute at a time end at the floor and
  bring under half their base value (diamond 21%, wheat 44%), and the price
  comes back within a day; buying and selling straight back (no resale
  delay) never earns, for every item traded both ways, 5 amounts, from a
  fresh price, the floor and the cap (495 round trips)
- proof: 1,000 wheat sold over four minutes, 250 a minute, the price lower
  after each sale

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

* SMP proof: the cobblestone stack's comment for the new depth

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

---------

Co-authored-by: Codex gpt-6.1-sol <[email protected]>
Co-authored-by: Ludovic Marie <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 00:14:07 +01:00
Ludovic MarieandClaude Opus 5.5 43fb2513a2 smp: /rtp (/wild): random safe spot 500-5000 blocks out, 30 min cooldown (#241)
* smp: /rtp (/wild) to a random safe spot 500-5000 blocks from the spawn, 30 min cooldown

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

* smp: /rtp draws until enough points fall inside a close border

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

* smp: /rtp undo as a struct; rustfmt

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

* smp: /rtp proof (2 clients, flat world) and SMP_PROOF=rtp tick cost on a normal world; per-/rtp read cost in the log

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

* smp: proof checks the /rtp landing with execute ... run say (block tags aside)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 21:02:45 +01:00
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
35 changed files with 3067 additions and 609 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+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."
+31 -26
View File
@@ -1,44 +1,49 @@
id = "basic_hud"
kind = "native-mod"
name = "The Basic HUD"
summary = "A visual HUD editor with movable widgets, FPS, coordinates, effect timers, cooldowns, mini armour and a customizable Tab list."
summary = "A visual HUD editor with 14 customizable widgets, recent pickups, effect timers, durability, coordinates, FPS and a customizable Tab list."
description = """
![The Basic HUD](https://raw.githubusercontent.com/repositorykreml1n/the-basic-hud/v0.4.1/assets/cover-0.4.1.png)
A client-side visual HUD for Horizon. Move, resize and style fourteen independent widgets directly over the game.
## Your HUD, your layout
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.
Arrange 13 independent widgets directly over the game. Drag, resize and style each block with colours, opacity, padding and shadows. Use full labels or compact icons with useful values such as ×16 and 1:45.
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.
Includes effects, consumables, durability, observed totem uses, available player-health data, item cooldowns, coordinates, FPS, ping, optional speed/CPS/clock, and mini armour next to the hotbar. Customize the Tab list with coloured numeric ping, bars, counts, sorting, scale and opacity.
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.
Ten interface languages plus Auto. A short first-visit guide can be replayed through ?. Reset requires confirmation; leaving unsaved edits offers Save / Discard changes / Stay. Failed writes retain the draft.
Controls: Right Shift, or Options > Mods > The Basic HUD > Settings > Edit HUD.
## Install and compatibility
Install the .hnmod through Horizon and choose Performance. Minecraft 26.3; pinned Horizon 68ac6f647e7022b2c696e81863d7cface28c9416. Compatibility with other Nightlies is not claimed.
Native .hnmod for Minecraft 26.3, pinned to Horizon **68ac6f647e7022b2c696e81863d7cface28c9416**. Install through the launcher and select **Performance**. Dev can overflow the startup stack on this Windows baseline. Other Nightlies require separate verification.
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.
Client-side only. Unknown player health remains unknown, unseen totem uses cannot be counted and server-plugin-only timers are unavailable. No server mod is required. No FPS improvement is promised. Native mods compile into the game and run with the player’s rights, without a sandbox. Tested on Windows; other platforms were not tested.
## Other languages
**Русский:** редактор поверх игры, размер и оформление каждого блока, значки с числами, FPS, координаты, кулдауны, мини-броня и настройка Tab. Обучение один раз; подтверждение сброса и выхода с правками.
**Українська:** редактор поверх гри, розмір і стиль кожного віджета, значки зі значеннями, FPS, координати, час відновлення, мініброня та налаштування Tab. Навчання під час першого входу й захист незбережених змін.
**Deutsch:** Editor direkt im Spiel, frei verschiebbare und skalierbare Widgets, Symbole mit Werten, FPS, Koordinaten, Abklingzeiten, Rüstungsanzeige und anpassbare Tab-Liste. Einführung beim ersten Besuch und Schutz ungespeicherter Änderungen.
**Français :** éditeur superposé au jeu, widgets déplaçables et redimensionnables, icônes avec valeurs, FPS, coordonnées, délais de récupération, petite vue de l’armure et liste Tab personnalisable. Tutoriel initial et protection des modifications non enregistrées.
**Español:** editor superpuesto al juego, widgets movibles y ajustables, iconos con valores, FPS, coordenadas, tiempos de recarga, vista pequeña de la armadura y lista Tab personalizable. Tutorial inicial y protección de cambios sin guardar.
[Full update notes in six languages](https://github.com/repositorykreml1n/the-basic-hud/blob/main/CHANGELOG.md)
MIT. Not an official Horizon, Mojang or Microsoft product. Cover is a generated promotional illustration, not a gameplay screenshot or measured FPS claim."""
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/main/icon.png"
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"
+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."""
+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."
+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."
+22 -8
View File
@@ -1,21 +1,35 @@
id = "resourcify"
kind = "native-mod"
name = "Resourcify"
summary = "Download resource packs in Horizon"
name = "ResourcifyHorizon"
summary = "Resourcify for Horizon"
description = """
Resourcify lets you browse and download resource packs without ever leaving Horizon.
Find and install resource packs from Modrinth without leaving the game. By tariksabanuz.
Features:
- Browse resource packs right inside the game
- One-click download and install
- Search to find the pack you want quickly
- Native Horizon mod, fast and lightweight"""
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"
+32
View File
@@ -0,0 +1,32 @@
id = "server_browser"
kind = "native-mod"
name = "Server Atlas"
summary = "Discover and organize Minecraft servers with filters, favorites and local notes."
description = """
Server Atlas is an independent, MIT-licensed native Rust multiplayer browser for Horizon and Minecraft Java 26.3, with familiar Minecraft buttons and translucent server rows.
Features: My Servers, Discover, Favorites, Recent and LAN tabs; search names, addresses, MOTDs, tags and notes; combined status, version, ping and player-count filters; local notes and groups; custom ordering; saved filter profiles; JSON/NBT import/export preserving unknown vanilla fields. English and German are included.
Public discovery combines Modrinth and minecraftserve.rs with a bundled catalog of 2,835 public addresses. It is enabled by default and can be switched off. Personal server lists, notes and search text are not submitted to directory providers. Normal status pings contact the servers being checked. Feedback and server suggestions save local drafts; automatic email delivery is not configured. Joining and authentication use Horizon's existing implementation.
Install the .hnmod in the desired Horizon instance, enable it, build through the official launcher and open Multiplayer in that built instance. Native code runs with the player's permissions. No separate host patch or companion executable is required.
Version 2.2.0 was built and tested on Nightly 2026-11-04: Horizon 6475f62f3c834b7489e1816bf47f47ea5eb2b879 and horizon-mixins a2af5c0dd4117d51ecd70066ba7a00b4a2c10dab. The official package checker, native build, 44 Rust tests and isolated game checks pass. Runtime coverage includes 10,000 saved entries, CRUD/filter/undo, Mods on/off, controller keyboard, GUI scales/4K, both live directories and real local-server Join/Direct Connect. Future Nightlies and combined native mod sets need separate validation. This bundle is unsigned.
Real game captures and installation: https://musti3214.github.io/Horizon-Server-Browser/
Validation: https://github.com/Musti3214/Horizon-Server-Browser/blob/v2.2.0/mod/VALIDATION.md
Developed with Codex assistance at the owner's request. Separate Fabric and Forge public betas are different files from this native Horizon bundle."""
authors = ["shuab"]
owners = ["Musti3214"]
license = "MIT"
source = "https://github.com/Musti3214/Horizon-Server-Browser/tree/v2.2.0/mod"
icon = "https://raw.githubusercontent.com/Musti3214/Horizon-Server-Browser/v2.2.0/docs/horizon-registry/icons/server_browser.png"
[[versions]]
version = "2.2.0"
published = "2026-11-05"
game = "26.3"
file = "server_browser-2.2.0.hnmod"
sha256 = "e65411ec3ed57c2ef288b627d0337cb1592762c24a869024a301b4c57bdfa94e"
changelog = "Initial registry submission of Server Atlas 2.2.0. Updated for Horizon Nightly 2026-11-04 (6475f62): client library, rendering, scrolling, keyboard actions, LAN connection flags and DNS resolution. Official package checker, native build, 44 Rust tests and isolated game checks pass, including Join/Direct Connect against a real local dedicated server."
+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=="""
+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."
+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."
+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,7 +4,7 @@ version = 4
[[package]]
name = "horizon-mod-smp"
version = "0.31.0"
version = "0.33.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "horizon-mod-smp"
version = "0.31.0"
version = "0.33.0"
edition = "2024"
license = "MIT"
publish = false
+63 -22
View File
@@ -27,7 +27,7 @@ header and footer).
| `/ah cancel <listing>`, `/ah claim`, `/ah search <words>`, `/ah mine`, `/ah mail` | takes back a listing nobody bid on; gives you your mail; the menu on a search, your listings, your mail |
| `/shop` | the market as a chest menu: categories, a click buys one, a shift-click 64, a click on a stack of yours sells it, "Sell all" |
| `/market [item]` | the moving market as a chest menu: categories, each item's price, its move over 1 h and 24 h and its volume; an item's page buys or sells 1, 16 or 64, sells all you carry, and gives its price chart as a map |
| `/worth [item]` | an item's price, the same to buy and to sell (the one in your hand without a name), and what you may still sell today |
| `/worth [item]` | an item's price, the same to buy and to sell (the one in your hand without a name) |
| `/sell hand [count]`, `/sell <item> [count]`, `/sell all` | sells to the market: the stack in your hand, an item, or everything it buys |
| `/buy <item> [count]` | buys from the market, only what fits in your inventory |
| `/trade <player>` | asks a player to trade (60 s to answer) |
@@ -43,6 +43,7 @@ header and footer).
| `/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 |
| `/rtp`, `/wild` | to a random safe spot 500 to 5,000 blocks from the spawn, in the Overworld only; once every 30 minutes |
| `/rules` | the server's rules |
| `/back` | back to where you died, or where your last teleport took you from (warmup and cooldown as any teleport) |
| `/afk` | marks you away from the keyboard until you move, turn or run a command |
@@ -54,14 +55,16 @@ 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
money and the room before anything moves.
**No way to get rich from the market.** The market makes money only from what players gather, and only so much a day:
**No way to get rich from the market.** The market makes money only from what players gather:
- 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
@@ -74,12 +77,10 @@ money and the room before anything moves.
copper, diamond, emerald, coal, redstone, lapis, wheat, wood and food chains at each start.
- Items a recipe links (an ingot and its block, wheat and hay...) share one moving price, the block moving it as
its 9 ingots do, so crafting never beats the market however prices move; the vanilla-recipe test checks every item
at the worst drift (what makes it at 0.6 times its price, it at 1.6) too.
- Each player sells at most `daily_sell_coins` (2,000 coins) and `daily_sell_items` (640 of each item) a UTC day,
`/worth` and the shop show what is left, and `market.json` keeps the day's count across restarts.
- Money received with `/pay` counts toward the receiver's same `daily_sell_coins`: selling up to the limit on alts
and paying it all to a main is refused once the main's limit is reached ("can only receive N more today").
Trades (`/trade`) and auction house sales are not counted.
at the worst drift (what makes it at 0.2 times its price, it at 1.6) too.
- There are no daily coin or per-item limits on market sales, or daily limits on `/pay` receipts.
The former `daily_sell_coins` and `daily_sell_items` settings and `market.json` counts are ignored,
including in existing server files.
- Amounts are checked: no price above 1,000,000,000,000, no sum that overflows.
- The coinflip and the lottery give the winner all the stakes: they move money between players and never make any.
@@ -110,6 +111,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
@@ -130,15 +139,19 @@ names), `reserved` (the words above).
**Moving prices** (`market.dynamic`, on by default since 0.26). Each item has one price: its base price (`items`)
times a factor that supply and demand move as each trade happens. Every item bought raises the log of the factor by
`1% / depth`, every item sold lowers it as much, where the depth follows the item's rarity (400 / its price, between
2 and 400: a diamond moves 50 times faster per item than cobblestone; `depth` overrides it per item). A trade of N is
2 and 50: a diamond moves 25 times faster per item than cobblestone, and any item reaches the floor within 10,000
sold; `depth` overrides it per item). A trade of N is
worth the area under that curve (`price / step × (e^(N × step) - 1)`), rounded up to whole coins when buying and down
when selling: one diamond at 200 costs 201 and sells for 199, 64 cobblestone at 1 cost 65, and a sale walks back the
exact path of the purchase. All trades together move an item at most `max_minute_move_percent` (5%) a minute from
where it started that minute: past that, a purchase is cut short and a sale keeps the rest ("sell them in a
minute"). The factor goes back to 1 with a half-life of `half_life_hours` (6) and stays within `min_factor` (0.6)
and `max_factor` (1.6), where the rest of a trade goes at that price. There is no tax on trades (an older config's
`tax_percent` is ignored): the receipt is the price ("Sold 3 diamond at 198 coins each: 595 coins."). Items a player bought from the market in the last `round_trip_hours` (24) can't be
sold back to it. (`max_trade_impact_percent` and `max_player_impact_percent` are no longer used: the curve and the
minute"). The factor goes back to 1 with a half-life of `half_life_hours` (6) and stays within `min_factor` (0.2)
and `max_factor` (1.6), where the rest of a trade goes at that price. With no daily sale caps (since 0.33), the floor
is what bounds a farm: selling one item without end takes its price down to a fifth of its base, where it stays while
the selling goes on; a config with the old floor (0.6) gets 0.2 when it loads. 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."). Market purchases can be sold back immediately;
the former `round_trip_hours` setting and saved purchase receipts are ignored. (`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
@@ -148,7 +161,7 @@ fixed prices of 0.23. For other mods: `Market::spot`, `volume(id, now, hours)`,
**`/market`.** A 6-row chest menu: the shop's categories and the day's movers; a category's items, each with its
price, its change over 1 h and 24 h (green ▲, red ▼) and what was traded in 24 h; an item's page with its
base price and what you may still sell today, buttons to buy or sell 1, 16 and 64 (what they come to
base price , 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.
@@ -157,8 +170,8 @@ chart map is drawn again each minute for whoever carries it, and once a minute t
**The shop.** `/shop` opens a 6-row chest menu on the market's prices: the categories of `config.json`'s `shop` (an
item in none goes under "Other"), each item named with its prices. A click buys one, a shift-click 64; a click on a
stack in your own inventory, below the menu, sells it; "Sell all" sells everything the market buys. The bottom row
shows your balance and today's selling limit. It goes through the market's own rules (survival or adventure only,
items then money, logged, the daily limits); the items move by the console's `clear` and `give`, measured on the
shows your balance. It goes through the market's own rules (survival or adventure only,
items then money, logged); the items move by the console's `clear` and `give`, measured on the
inventory before and after, so the money follows what really moved.
**Trades.** Once accepted, both players see a 6-row chest menu: their own offer on the left (16 stacks), the other's
@@ -216,6 +229,20 @@ was on (`homes`: `[hours, homes]`, by default 1 home, 2 from 5 hours, 3 from 20,
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.
`/rtp` (or `/wild`) draws `rtp_attempts` (6) chunks at random between `rtp_min_distance` (500) and `rtp_max_distance`
(5000) blocks from the spawn (`/setspawn`'s point, 0 0 without one), uniform over the ring's area and at least 16 blocks
inside the world border (as last saved in `world_border.dat`), then holds them loaded one after another with vanilla's
`forceload` while the warmup runs: the game generates them on its worker threads, never on the server tick. Once a
chunk is loaded, the Overworld's tick reads its 256 columns (`native_read_columns`, the top block below the
`MOTION_BLOCKING_NO_LEAVES` height and the two above it) and the player lands, still standing still, on a random one
whose top block is solid ground (no fluid, fire, cactus, magma, powder snow, berry bush or dripstone) with air or grass
above it, outside another player's claim and still in the ring and the border; with none the next chunk is tried. With
no spot after the six, or no land loaded `rtp_search_seconds` (30) after the warmup, it says so and nothing is spent.
A teleport made starts `rtp_cooldown_seconds` (1800) for that player, kept in `places.json` over restarts ("Wait 12 min
before your next /rtp."); a teleport called off (a move, a hit, the game failing it) doesn't. Elsewhere than the
Overworld it refuses. The chunks it forced are let go 10 s after the landing, or at the next start if the server
stopped first; a chunk an operator forced already is used and left forced. `/back` returns from it as from any teleport.
**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
@@ -297,6 +324,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
@@ -304,16 +344,17 @@ 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) |
| `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), `shop`: `enabled`, `title`, `categories` (`name`, `icon`, `items`); `web`: `url` (Kira's `/smp/ingest`) and `token` (empty: nothing is sent), where the pages of `web/` go; written with the defaults the first time, and again when a newer version adds keys (your values kept) |
| `economy.json` | balances by player UUID, with the name last seen; `held`: coinflip stakes and lottery tickets being decided; `pools`: the casino's pot and the auction house's bids; `ext`: other parts' state written in the same step as the money (`auction`: the listings, bids, mail and notes) |
| `places.json` | the spawn, the warps, each player's homes, and the seconds each player has played (written once a minute) |
| `nicks.json` | each player's nickname and when they last changed it |
| `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 |
| `market_prices.json` | each item's price factor and history (minutes for 24 h, hours for 30 days: `[time, mid, traded, sold]`), the day's summary |
| `web/market.json` | for the website (`/hub/market`, through Kira), written by a thread each minute prices move: `schema` 1, `generated`, `currency`, `tax_percent` (always 0, for older readers), `daily_summary` (`day`, `lines`), `items` (`id`, `name`, `category`, `price` (the one price; `buy` and `sell` repeat it on the sides the market trades, for older readers), `mid`, `base`, `change_1h`, `change_24h`, `volume_24h`, `day`: 5-minute points, `month`: hours, each `{step, t, price, volume}`). When `web.token` is set, the same thread POSTs each page of `web/` that changed (gzipped, `Authorization: Bearer <token>`) to `<web.url>/<name>`: Kira keeps the last one and serves it to the site; the server needs no inbound access and Kira no key to it |
| `presence.json` | the players who have joined (no welcome again), and the day each last got the daily reward |
| `board.json` | the players who hid the sidebar (`/sidebar off`) |
| `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`, `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 |
@@ -329,13 +370,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-seven modules and the HEAD hooks |
| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs`, `smp_recipes.rs`, `smp_shop.rs`, `smp_nick.rs`, `smp_trade.rs`, `smp_games.rs`, `smp_lottery.rs`, `smp_places.rs`, `smp_claims.rs`, `smp_board.rs`, `smp_presence.rs`, `smp_auction.rs`, `smp_report.rs`, `smp_market_prices.rs`, `smp_market_menu.rs`, `smp_market_chart.rs`, `smp_invsee.rs` | The rules, no game types (a trade's stacks are a type parameter): added to the game as `game::smp_store`, `game::smp_economy`, `game::smp_market`, `game::smp_recipes`, `game::smp_shop`, `game::smp_nick`, `game::smp_trade`, `game::smp_games`, `game::smp_lottery`, `game::smp_places`, `game::smp_claims`, `game::smp_board`, `game::smp_presence`, `game::smp_auction`, `game::smp_report`, `game::smp_market_prices`, `game::smp_market_menu`, `game::smp_market_chart`, `game::smp_invsee`; also this crate's modules (`src/lib.rs`), for their tests |
| `mixins/smp/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*`) |
+2 -2
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": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+404 -59
View File
@@ -2,7 +2,7 @@
// between the game's types and the rules (smp_economy, smp_market, smp_shop,
// smp_trade, smp_games, smp_lottery, smp_places, smp_claims). Added to
// the game as `game::smp_glue`; the helpers (register.rs, command.rs,
// tick.rs, lifecycle.rs, menu_clicked.rs, menu_closed.rs, destroy_block.rs,
// tick.rs, level_tick.rs, lifecycle.rs, menu_clicked.rs, menu_closed.rs, destroy_block.rs,
// right_click_block.rs, block_spread.rs, piston_move.rs,
// explosion_detonate.rs, fill_bucket.rs, attack_entity.rs) call it from the
// hook points.
@@ -27,7 +27,9 @@ use crate::game::{
smp_nick::{self, Names as _, Nicks},
smp_shop::{self, Shop},
smp_store,
smp_switch::{self, Part, Switches},
smp_trade::{self, Act, Cell, Trades},
smp_vanish::{self, Vanish},
};
use horizon_client::{
items::ItemStack,
@@ -39,7 +41,7 @@ use std::{path::PathBuf, sync::Mutex};
/// The mod's id, name and version.
pub const ID: &str = "smp";
pub const NAME: &str = "SMP";
pub const VERSION: &str = "0.31.0";
pub const VERSION: &str = "0.33.0";
/// `config.json` in `world/horizon-smp/`, written with its defaults the
/// first time, and again with the keys a newer version adds (values kept).
@@ -123,6 +125,14 @@ pub struct Smp {
/// `/claim show`: whose chunk border is drawn (level, chunk x, chunk z)
/// until when.
shows: std::collections::HashMap<u128, (String, i32, i32, i64)>,
/// `/vanish`: an error when `vanish.json` can't be read (nobody is
/// hidden then, and the command says so).
vanish: Result<Vanish, String>,
/// The vanished players on who were sent the subtitle: cleared and
/// counted for sleeping again once they show themselves or leave.
vanish_titled: std::collections::HashSet<u128>,
/// The parts operators turned off (`/smp off`).
switches: Switches,
}
static STATE: Mutex<Option<Smp>> = Mutex::new(None);
@@ -133,6 +143,11 @@ static BUSY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(
/// Sleep-count changes decided inside a player's command, for the next
/// second's tick.
static PENDING_SLEEP: Mutex<Vec<smp_presence::Act>> = Mutex::new(Vec::new());
/// Whether an `/rtp` waits for its land: the Overworld's tick reads it then.
static RTP: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// What each `/rtp`'s reads cost the Overworld's tick, by player: reads,
/// total and longest (µs), for the line logged when they land.
static RTP_COST: Mutex<Vec<(u128, u32, u64, u64)>> = Mutex::new(Vec::new());
fn open(dir: PathBuf) -> Smp {
let config_path = dir.join("config.json");
@@ -194,6 +209,10 @@ fn open(dir: PathBuf) -> Smp {
Err(e) => log::error!("[smp] claims are off: {e}"),
}
let board = Board::open(&dir, config.board);
let vanish = Vanish::open(&dir);
if let Err(e) = &vanish {
log::error!("[smp] /vanish is off: {e}");
}
if let Err(e) = &board {
log::error!("[smp] the sidebar is off: {e}");
}
@@ -218,6 +237,13 @@ fn open(dir: PathBuf) -> Smp {
if let Err(e) = &presence {
log::error!("[smp] welcomes, daily rewards and AFK are off: {e}");
}
let (switches, problem) = Switches::open(&dir);
if let Some(e) = problem {
log::error!("[smp] {e}: every part is on");
}
for p in Part::ALL.into_iter().filter(|&p| !switches.on(p)) {
log::warn!("[smp] {} is turned off (/smp on {} turns it on)", p.name(), p.name());
}
let (casino, problem) = Casino::open(&dir, config.casino, config.gambling.enabled);
if let Some(e) = problem {
log::error!("[smp] casino.json can't be read, the day's ledger starts again (kept apart): {e}");
@@ -244,6 +270,9 @@ fn open(dir: PathBuf) -> Smp {
nicks,
nicks_shown: Default::default(),
shows: Default::default(),
vanish,
vanish_titled: Default::default(),
switches,
}
}
@@ -281,9 +310,6 @@ fn save(smp: &mut Smp) {
{
log::error!("[smp] {err}");
}
if let Err(err) = smp.market.save() {
log::error!("[smp] {err}");
}
if let Ok(p) = &mut smp.places
&& let Err(err) = p.save(chrono::Utc::now().timestamp_millis())
{
@@ -312,6 +338,14 @@ fn save(smp: &mut Smp) {
if let Err(err) = smp.casino.save() {
log::error!("[smp] {err}");
}
if let Ok(v) = &mut smp.vanish
&& let Err(err) = v.save()
{
log::error!("[smp] {err}");
}
if let Err(err) = smp.switches.save() {
log::error!("[smp] {err}");
}
}
/// The commands, for every player's command tree.
@@ -360,6 +394,8 @@ pub fn commands(commands: &mut Vec<NativeCommand>) {
"tpdeny [player:word]",
"rules",
"back",
"rtp",
"wild",
"playtime [player:word]",
"claim",
"claim info",
@@ -394,9 +430,14 @@ pub fn commands(commands: &mut Vec<NativeCommand>) {
"setwarp <name:word>",
"delwarp <name:word>",
"nick <name:word> <nick:word>",
"vanish",
"vanish list",
] {
commands.push(NativeCommand::op(usage, 3));
}
for usage in ["smp", "smp status", "smp on [part:word]", "smp off [part:word] [reason:text]"] {
commands.push(NativeCommand::op(usage, smp_switch::LEVEL));
}
// Operators make money (an event prize, a bug refund: with a reason
// everyone reads) and take a cheat's gains away.
for name in ["eco", "money"] {
@@ -555,18 +596,91 @@ const COMMANDS: [&str; 16] = [
"lottery", "ah", "casino",
];
/// `/pay <player> <amount>`'s receiver (account, name) and amount, as
/// `Economy::pay` reads them; None for another command.
fn pay_target(e: &Economy, line: &str) -> Option<(u128, String, i64)> {
let [cmd, who, amount] = line.split_whitespace().collect::<Vec<_>>()[..] else {
return None;
/// The world border, from the Overworld's `world_border.dat` (the root's in
/// older worlds), as the game last saved it; None without one (the default
/// border, 60 million blocks wide).
fn world_border(world: &std::path::Path) -> Option<smp_places::Bounds> {
let files = [
world.join("dimensions/minecraft/overworld/data/minecraft/world_border.dat"),
world.join("data/minecraft/world_border.dat"),
];
let root = files
.iter()
.find_map(|f| horizon_world::datafix::files::read_saved_dat(f).ok().flatten())?;
let border = horizon_client::worldborder::WorldBorder::unpack(root.get("data")?);
Some(border.bounds())
}
/// After a level's tick: in the Overworld, each `/rtp` that waits reads the
/// chunk it loads (the game reads only the level it ticks), five times a
/// second. Columns in another player's claim are never a spot.
pub(in crate::game) fn level_ticked(game: &mut Game<'_>, dimension: &str) {
use std::sync::atomic::Ordering::Relaxed;
if dimension != smp_places::OVERWORLD
|| !RTP.load(Relaxed)
|| TICKS.load(Relaxed) % 4 != 0
|| !game.native_on(ID)
{
return;
}
let Some(dir) = game.native_world_dir() else {
return;
};
if cmd != "pay" {
let mut guard = state(dir.join(smp_store::DIR));
let Some(smp) = guard.as_mut() else {
return;
};
let (Ok(places), claims) = (&mut smp.places, &smp.claims) else {
return;
};
let game = &*game;
let mut cost = RTP_COST.lock().unwrap_or_else(|e| e.into_inner());
places.rtp_look(&mut |uuid, (cx, cz)| {
let started = std::time::Instant::now();
let ground = read_chunk(game, claims, uuid, (cx, cz));
let us = started.elapsed().as_micros() as u64;
match cost.iter_mut().find(|c| c.0 == uuid) {
Some(c) => *c = (uuid, c.1 + 1, c.2 + us, c.3.max(us)),
None => cost.push((uuid, 1, us, us)),
}
ground
});
RTP.store(places.rtp_searching(), Relaxed);
}
/// Chunk `(cx, cz)` of the Overworld as `/rtp` reads it, None while it isn't
/// loaded; no columns when another player claimed it.
fn read_chunk(
game: &Game<'_>,
claims: &Result<Claims, String>,
uuid: u128,
(cx, cz): (i32, i32),
) -> Option<smp_places::Ground> {
let columns: Vec<(i32, i32)> = (0..256).map(|i| (cx * 16 + i / 16, cz * 16 + i % 16)).collect();
let read = game
.native_read_columns(
smp_places::OVERWORLD,
&columns,
crate::game::native_world_edit::NativeHeightmap::MotionBlockingNoLeaves,
(1, 2),
)
.ok()?;
if read.bases.iter().any(Option::is_none) {
return None;
}
let amount = smp_economy::parse_amount(amount)?;
let (to, account) = e.find(who)?;
Some((to, account.name.clone(), amount))
let mut ground = smp_places::Ground { palette: read.palette, columns: Vec::new() };
let claimed = claims
.as_ref()
.ok()
.and_then(|c| c.owner(smp_places::OVERWORLD, cx * 16, cz * 16))
.is_some_and(|owner| owner != uuid);
if !claimed {
for (i, (&(x, z), base)) in columns.iter().zip(&read.bases).enumerate() {
let b = &read.blocks[i * 3..i * 3 + 3];
ground.columns.push((x, z, base.unwrap_or_default(), [b[0], b[1], b[2]]));
}
}
Some(ground)
}
/// The players on, as the places see them.
@@ -618,6 +732,8 @@ pub(in crate::game) fn perform(
&smp_board::COMMANDS,
&smp_presence::COMMANDS,
&smp_nick::COMMANDS,
&smp_vanish::COMMANDS,
&smp_switch::COMMANDS,
];
if !game.native_on(ID) || !ours.iter().any(|c| c.contains(&word)) {
return;
@@ -634,20 +750,44 @@ pub(in crate::game) fn perform(
// Survival and adventure: a creative player could sell what they made.
let trades = matches!(player.game_mode, 0 | 2);
let players = game.native_server_players();
let online = |name: &str| {
players
.iter()
.find(|p| p.name.eq_ignore_ascii_case(name))
.map(|p| (p.uuid, p.name.clone(), matches!(p.game_mode, 0 | 2)))
};
let now = chrono::Utc::now().timestamp_millis();
let mut guard = state(dir.join(smp_store::DIR));
let smp = guard.as_mut().expect("opened");
// A vanished player is out of reach (trades, teleports, bets) of those
// who don't see them.
let hidden: Vec<u128> = smp.vanish.as_ref().map_or_else(
|_| Vec::new(),
|v| players.iter().filter(|p| !v.sees(me.level, p.uuid)).map(|p| p.uuid).collect(),
);
let online = |name: &str| {
players
.iter()
.filter(|p| !hidden.contains(&p.uuid))
.find(|p| p.name.eq_ignore_ascii_case(name))
.map(|p| (p.uuid, p.name.clone(), matches!(p.game_mode, 0 | 2)))
};
// The switches: `/smp` itself, then a part turned off answers alone.
if let Some((out, changed)) = smp.switches.command(&me, now, line) {
let out = switched(game, smp, &me, &players, changed, now, out);
drop(guard);
*ran = true;
show(game, out);
return;
}
if let Some(part) = smp_switch::Part::of_command(word).filter(|&p| !smp.switches.on(p)) {
let refusal = smp_switch::refusal(part);
drop(guard);
*ran = true;
show(game, vec![refusal]);
return;
}
let mut acts = Vec::new();
// Any SMP command is activity (no longer AFK), and past the limit waits.
let mut sleeps = Vec::new();
let mut before = Vec::new();
if let Ok(p) = &mut smp.presence {
if let Ok(p) = &mut smp.presence
&& smp.switches.on(Part::Presence)
{
match p.allow(&me, now) {
Ok((o, a)) => (before, sleeps) = (o, a),
Err(o) => {
@@ -669,7 +809,24 @@ pub(in crate::game) fn perform(
n.resolve(line, &is_name, &name_of)
});
let line = resolved.as_deref().unwrap_or(line);
let out = if smp_nick::COMMANDS.contains(&word) {
let out = if smp_vanish::COMMANDS.contains(&word) {
let ops: Vec<u128> = players.iter().filter(|p| p.permission >= smp_vanish::LEVEL).map(|p| p.uuid).collect();
match &mut smp.vanish {
Ok(v) => {
let out = v.command(&me, &ops, line).unwrap_or_default();
// Hidden or shown at once; the subtitle comes with the next
// second (commands can't run inside a player's).
for (uuid, hidden) in v.changes() {
game.native_hide_player(uuid, hidden.then_some(smp_vanish::LEVEL));
}
if let Ok(p) = &mut smp.presence {
p.quiet = v.quiet();
}
out
}
Err(_) => vec![Out::Error("/vanish is off: vanish.json can't be read (see the server log).".into())],
}
} else if smp_nick::COMMANDS.contains(&word) {
let names = ServerNames::read(&dir, smp.economy.as_ref().ok());
match &mut smp.nicks {
Ok(n) => {
@@ -739,7 +896,15 @@ pub(in crate::game) fn perform(
}
} else if smp_places::COMMANDS.contains(&word) {
match &mut smp.places {
Ok(p) => p.command(&me, &bodies(&players), now, line).unwrap_or_default(),
Ok(p) => {
if matches!(word, "rtp" | "wild") {
p.border = world_border(&dir);
RTP_COST.lock().unwrap_or_else(|e| e.into_inner()).retain(|c| c.0 != me.uuid);
BUSY.store(true, std::sync::atomic::Ordering::Relaxed);
}
let seen: Vec<NativeServerPlayer> = players.iter().filter(|p| !hidden.contains(&p.uuid)).cloned().collect();
p.command(&me, &bodies(&seen), now, line).unwrap_or_default()
}
Err(_) => vec![Out::Error(
"Homes and warps are off: their file can't be read (see the server log).".into(),
)],
@@ -767,27 +932,7 @@ pub(in crate::game) fn perform(
let report = smp.report.as_ref();
report
.and_then(|r| r.command(e, line))
.or_else(|| {
// A payment counts toward the receiver's daily selling
// limit (alts can't sell for a main past it).
let paid = pay_target(e, line);
if let Some((to, name, amount)) = &paid
&& let Err(left) = smp.market.may_receive(*to, *amount, now)
{
return Some(vec![Out::Error(format!(
"{name} can only receive {} more today: payments count toward the same daily limit as selling to the market.",
e.money(left)
))]);
}
let before = paid.as_ref().map(|(to, ..)| e.balance(*to));
let out = e.command(&me, &on, line);
if let (Some((to, _, amount)), Some(b)) = (paid, before)
&& e.balance(to) == b + amount
{
smp.market.count_received(to, now, amount);
}
out
})
.or_else(|| e.command(&me, &on, line))
.or_else(|| smp.market.command(e, &me, trades, &mut bag, line))
.or_else(|| {
let (out, a) = smp.trades.command(e, &me, trades, &online, now, line)?;
@@ -830,6 +975,9 @@ pub(in crate::game) fn guard(game: &mut Game<'_>, pos: [i32; 3], canceled: &mut
};
let mut guard = state(dir.join(smp_store::DIR));
let smp = guard.as_mut().expect("opened");
if !smp.switches.on(Part::Claims) {
return;
}
let Ok(claims) = &mut smp.claims else {
return;
};
@@ -864,7 +1012,7 @@ pub(in crate::game) fn attack(game: &mut Game<'_>, kind: &str, pos: [f64; 3], ca
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
.is_some_and(|s| s.claims.as_ref().is_ok_and(|c| c.protects(kind)));
.is_some_and(|s| s.switches.on(Part::Claims) && s.claims.as_ref().is_ok_and(|c| c.protects(kind)));
if protected {
guard(game, pos.map(|v| v.floor() as i32), canceled);
}
@@ -887,7 +1035,7 @@ pub(in crate::game) fn world_allows(dimension: &str, from: [i32; 3], to: &[[i32;
let Ok(state) = STATE.try_lock() else {
return true;
};
match state.as_ref().map(|s| &s.claims) {
match state.as_ref().filter(|s| s.switches.on(Part::Claims)).map(|s| &s.claims) {
Some(Ok(c)) => to.iter().all(|&t| c.spread_allowed(dimension, from, t)),
_ => true,
}
@@ -902,7 +1050,7 @@ pub(in crate::game) fn explosion(game: &Game<'_>, dimension: &str, blocks: &mut
let Ok(state) = STATE.try_lock() else {
return;
};
if let Some(Ok(c)) = state.as_ref().map(|s| &s.claims) {
if let Some(Ok(c)) = state.as_ref().filter(|s| s.switches.on(Part::Claims)).map(|s| &s.claims) {
c.spare(dimension, blocks);
}
}
@@ -1080,6 +1228,14 @@ pub(in crate::game) fn menu_clicked(game: &mut Game<'_>, uuid: u128, window: i32
};
let mut guard = state(dir.join(smp_store::DIR));
let smp = guard.as_mut().expect("opened");
// A menu of a part turned off since it opened: closed, nothing done.
if let Some(part) = menu_part(smp, uuid, window).filter(|&p| !smp.switches.on(p)) {
let refusal = smp_switch::refusal(part);
drop(guard);
game.native_close_menu(uuid);
tell(game, uuid, vec![refusal]);
return;
}
if smp.shop.views.get(&uuid).is_some_and(|v| v.0 == Some(window)) {
shop_clicked(game, smp, uuid, slot, mode == 1);
return;
@@ -1128,6 +1284,133 @@ pub(in crate::game) fn menu_clicked(game: &mut Game<'_>, uuid: u128, window: i32
show(game, out);
}
/// The part whose menu `window` is, open for that player.
fn menu_part(smp: &Smp, uuid: u128, window: i32) -> Option<Part> {
let open = |v: Option<i32>| v == Some(window);
if smp.shop.views.get(&uuid).is_some_and(|v| open(v.0)) || smp.market_menus.views.get(&uuid).is_some_and(|v| open(v.0)) {
return Some(Part::Market);
}
if smp.auction.as_ref().is_some_and(|a| a.views.get(&uuid).is_some_and(|v| open(v.0))) {
return Some(Part::Auction);
}
if smp.casino.owns(uuid, window) {
return Some(Part::Gambling);
}
let trade = smp.trades.trade_of(uuid);
trade.and_then(|t| t.side_of(uuid).map(|me| t.sides[me].window)).filter(|&w| open(w)).map(|_| Part::Trades)
}
/// Parts turned off or on by `/smp`: what was under way in those turned off
/// stops (trades end with nothing moved, menus close, teleports waiting are
/// called off, sidebars and nicknames are taken away), the other operators
/// on are told, and the change is in the server's log and `audit.log`.
fn switched(
game: &mut Game<'_>,
smp: &mut Smp,
me: &Player,
players: &[NativeServerPlayer],
changed: smp_switch::Changed,
now: i64,
mut out: Vec<Out>,
) -> Vec<Out> {
let names = |ps: &[Part]| ps.iter().map(|p| p.name()).collect::<Vec<_>>().join(", ");
let reason = line_reason(&smp.switches, &changed);
for (verb, parts) in [("off", &changed.off), ("on", &changed.on)] {
if parts.is_empty() {
continue;
}
log::warn!("[smp] {} turned {verb}: {}{reason}", me.name, names(parts));
if let Ok(e) = &mut smp.economy
&& let Err(err) = e.record(serde_json::json!({
"time": now, "kind": format!("switch_{verb}"), "by": smp_store::uuid_string(me.uuid),
"parts": parts.iter().map(|p| p.name()).collect::<Vec<_>>(), "reason": reason.trim_start_matches(": "),
}))
{
log::error!("[smp] {err}");
}
for p in players.iter().filter(|p| p.permission >= smp_switch::LEVEL && p.uuid != me.uuid) {
out.push(Out::Tell(p.uuid, format!("[SMP] {} turned {verb}: {}{reason}", me.name, names(parts))));
}
}
for part in changed.off {
match part {
Part::Trades => {
let sides: Vec<u128> = smp.trades.open.iter().map(|t| t.sides[0].uuid).collect();
for uuid in sides {
let (o, acts) = smp.trades.end(uuid, "trading was turned off");
out.extend(o);
act(game, smp, acts);
}
}
Part::Market => {
let open: Vec<u128> = smp.shop.views.keys().chain(smp.market_menus.views.keys()).copied().collect();
open.into_iter().for_each(|u| game.native_close_menu(u));
}
Part::Auction => {
let open: Vec<u128> = smp.auction.as_ref().map(|a| a.views.keys().copied().collect()).unwrap_or_default();
open.into_iter().for_each(|u| game.native_close_menu(u));
}
// A round under way still ends and is paid when its menu closes
// (`menu_clicked` closes it at the next click).
Part::Gambling => {}
Part::Places => {
if let Ok(p) = &mut smp.places {
for t in std::mem::take(&mut p.pending) {
out.push(Out::Tell(t.uuid, "Teleport called off: teleporting was turned off.".into()));
}
}
}
Part::Claims => smp.shows.clear(),
Part::Board => {
if let Ok(b) = &mut smp.board {
b.forget();
}
let blank = Text::literal("");
for p in players {
game.native_sidebar(p.uuid, None);
game.native_tab_list(p.uuid, &blank, &blank);
}
}
Part::Nicks => {
for uuid in std::mem::take(&mut smp.nicks_shown) {
game.native_set_nickname(uuid, None);
}
}
// `/invsee` refuses and its open menus take no click.
Part::Invsee | Part::Economy | Part::Presence => {}
}
}
BUSY.store(true, std::sync::atomic::Ordering::Relaxed);
out
}
/// `: why` of the parts just turned off (as `switches.json` has it), or "".
fn line_reason(switches: &Switches, changed: &smp_switch::Changed) -> String {
changed
.off
.first()
.and_then(|&p| switches.why(p))
.filter(|r| !r.is_empty())
.map(|r| format!(": {r}"))
.unwrap_or_default()
}
/// What a command of a part turned off answers.
pub(in crate::game) fn refusal(part: Part) -> String {
match smp_switch::refusal(part) {
Out::Error(s) | Out::Reply(s) | Out::Tell(_, s) | Out::All(s) => s,
}
}
/// Whether a part of the SMP is on (`/smp off` turns them off), for the
/// SMP's other glue (`/invsee`).
pub(in crate::game) fn part_on(game: &Game<'_>, part: Part) -> bool {
let Some(dir) = game.native_world_dir() else {
return true;
};
state(dir.join(smp_store::DIR)).as_ref().is_none_or(|s| s.switches.on(part))
}
/// A native menu closed: a trade's ends, nothing moved.
pub(in crate::game) fn menu_closed(game: &mut Game<'_>, uuid: u128, window: i32) {
if !game.native_on(ID) {
@@ -1288,10 +1571,6 @@ fn shop_items(smp: &Smp, uuid: u128) -> Vec<Option<ItemStack>> {
button("minecraft:gold_ingot", &format!("Balance: {}", money(b)), "gold")
}
C::SellAll => button("minecraft:hopper", "Sell everything the market buys", "green"),
C::Limits => {
let line = economy.and_then(|e| smp.market.limits_line(e, uuid));
button("minecraft:clock", &line.unwrap_or_else(|| "No selling limit".into()), "aqua")
}
C::Help => button("minecraft:book", smp_shop::HELP_TEXT, "white"),
C::Close => button("minecraft:barrier", "Close", "red"),
})
@@ -1472,9 +1751,6 @@ fn market_items(smp: &Smp, uuid: u128, now: i64) -> Vec<Option<ItemStack>> {
let mut lines = price_lines(smp, &id, now);
let base = smp.market.base(&id);
lines.push((format!("Base price: {}", money(base.buy.max(base.sell))), "gray"));
if let Some(left) = smp.market.items_left(uuid, now, &id) {
lines.push((format!("You may still sell {left} today"), "gray"));
}
with_lore(button(&id, &name(&id), "white"), &lines)
}
C::Buy(id, n) => {
@@ -2019,7 +2295,31 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
};
let now = chrono::Utc::now().timestamp_millis();
let mut out = Vec::new();
// `/vanish`, once a second: the game told who is hidden (after a
// restart), and the subtitle sent to each vanished player on.
let vanished = smp.vanish.as_ref().map(|v| v.quiet()).unwrap_or_default();
let (mut subtitles, mut unvanished) = (Vec::new(), Vec::new());
if n % 20 == 0
&& let Ok(v) = &mut smp.vanish
{
for (uuid, hidden) in v.changes() {
game.native_hide_player(uuid, hidden.then_some(smp_vanish::LEVEL));
}
for p in &players {
if vanished.contains(&p.uuid) {
subtitles.push((p.uuid, p.name.clone()));
} else if smp.vanish_titled.contains(&p.uuid) {
unvanished.push((p.uuid, p.name.clone()));
}
}
smp.vanish_titled = subtitles.iter().map(|s| s.0).collect();
if let Ok(p) = &mut smp.presence {
p.quiet = vanished.clone();
}
}
// Teleports: each tick while one warms up; requests expire once a second.
// With places off only the playtime counts.
let places_on = smp.switches.on(Part::Places);
if let Ok(p) = &mut smp.places
&& (n % 20 == 0 || warming)
{
@@ -2027,7 +2327,7 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
let on: Vec<u128> = players.iter().map(|p| p.uuid).collect();
p.played(&on, now);
}
let (o, go) = p.tick(&bodies(&players), now);
let (o, go) = if places_on { p.tick(&bodies(&players), now) } else { Default::default() };
out.extend(o);
for t in go {
// `.3`: a whole number would be a block's centre to `tp`.
@@ -2048,8 +2348,20 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
{
log::warn!("[smp] no safe spot near the spawn: {spread}");
}
if t.what == "a random spot" {
let mut cost = RTP_COST.lock().unwrap_or_else(|e| e.into_inner());
let (_, n, total, max) = cost.iter().find(|c| c.0 == t.uuid).copied().unwrap_or_default();
cost.retain(|c| c.0 != t.uuid);
log::info!(
"[smp] /rtp: {} to {:.1} {:.1} {:.1} ({n} chunk reads on the tick: {total} µs, longest {max} µs)",
t.name, t.to.x, t.to.y, t.to.z
);
}
out.push(Out::Tell(t.uuid, t.done.clone()));
}
// `/rtp`'s chunks: held loaded while it looks, let go after.
p.force(now, &mut |line| game.native_run_command(line));
RTP.store(p.rtp_searching(), Relaxed);
}
if let Ok(e) = &mut smp.economy
&& n % 20 == 0
@@ -2069,9 +2381,16 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
for uuid in gone {
smp.games.left(e, uuid);
}
out.extend(smp.lottery.tick(&smp.games.config, e, now, &mut below));
// A part turned off waits: its draws, endings, markings and payments
// happen once it is on again (coinflips not taken still go back).
let on = |p| smp.switches.on(p);
if on(Part::Gambling) {
out.extend(smp.lottery.tick(&smp.games.config, e, now, &mut below));
}
// The auction house: notes for who came on, listings that ended.
if let Some(a) = &mut smp.auction {
if let Some(a) = &mut smp.auction
&& on(Part::Auction)
{
let on: Vec<u128> = players.iter().map(|p| p.uuid).collect();
out.extend(a.tick(e, &on, now));
}
@@ -2146,7 +2465,9 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
}
// Welcomes, daily rewards and AFK, once a second.
let mut sleeps = std::mem::take(&mut *PENDING_SLEEP.lock().unwrap_or_else(|e| e.into_inner()));
let economy_on = smp.switches.on(Part::Economy);
if n % 20 == 0
&& smp.switches.on(Part::Presence)
&& let Ok(pr) = &mut smp.presence
{
let seen: Vec<smp_presence::Seen> = players
@@ -2165,7 +2486,9 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
for a in acts {
match a {
smp_presence::Act::Reward(uuid) => {
if let Ok(e) = &mut smp.economy {
if let Ok(e) = &mut smp.economy
&& economy_on
{
match e.adjust("daily_reward", uuid, reward, "") {
Ok(_) => out.push(Out::Tell(uuid, format!("Daily reward: {}.", e.money(reward)))),
Err(err) => log::warn!("[smp] daily reward: {err}"),
@@ -2204,6 +2527,7 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
let mut tab = None;
let (nicks, places) = (&smp.nicks, &smp.places);
if n % 20 == 0
&& smp.switches.on(Part::Board)
&& let Ok(b) = &mut smp.board
{
let on: Vec<u128> = players.iter().map(|p| p.uuid).collect();
@@ -2223,16 +2547,20 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
.unwrap_or_default(),
played_seconds: places.as_ref().map_or(0, |p| p.played_seconds(uuid)),
};
b.quiet = vanished.clone();
sidebars = b.updates(now, &on, me);
let to = b.tab_due(now, &on);
if !to.is_empty() {
let (rate, mspt) = game.native_tick_stats();
let tps = f64::from(rate).min(1000.0 / mspt.max(0.001));
tab = Some((to, b.tab_lines(tps, mspt, on.len())));
// Vanished players aren't counted: the footer is everyone's.
let shown = on.iter().filter(|u| !b.quiet.contains(u)).count();
tab = Some((to, b.tab_lines(tps, mspt, shown)));
}
}
// Nicknames: the changes asked for, then those of players who came on.
if n % 20 == 0
&& smp.switches.on(Part::Nicks)
&& let Ok(nicks) = &mut smp.nicks
{
let pending = std::mem::take(&mut nicks.pending);
@@ -2288,6 +2616,23 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
game.native_sleep_ignored(uuid, ignored);
}
}
// A vanished player doesn't hold the night up; the subtitle lasts a
// little past the next second's (1.5 s), so it never blinks.
for (uuid, name) in subtitles {
game.native_sleep_ignored(uuid, true);
let subtitle = serde_json::json!({"text": smp_vanish::SUBTITLE, "color": "aqua"});
for line in [
format!("title {name} times 0 30 5"),
format!("title {name} subtitle {subtitle}"),
format!("title {name} title \"\""),
] {
game.native_run_command(&line);
}
}
for (uuid, name) in unvanished {
game.native_sleep_ignored(uuid, false);
game.native_run_command(&format!("title {name} clear"));
}
for line in particles.into_iter().chain(casino) {
game.native_run_command(&line);
}
+26 -4
View File
@@ -1,12 +1,12 @@
[mod]
id = "smp"
version = "0.31.0"
version = "0.33.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"
@@ -74,6 +74,12 @@ 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"
@@ -164,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"
@@ -201,6 +213,16 @@ bindings = [
{ name = "phase", ty = "crate::game::native_lifecycle::ServerTickPhase", value = "phase" },
]
[[injection]]
id = "level_tick"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_level_tick_post"
helper = "level_tick.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "dimension", ty = "&str", value = "dimension" },
]
[[injection]]
id = "lifecycle"
kind = "HEAD"
+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.
+5
View File
@@ -0,0 +1,5 @@
// After a level's tick: in the Overworld, the `/rtp`s waiting for their land
// read it (the game reads only the level it ticks).
fn smp_level_tick_post(game: &mut crate::game::Game<'_>, dimension: &str) {
crate::game::smp_glue::level_ticked(game, dimension);
}
+22 -1
View File
@@ -122,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.
@@ -165,6 +167,7 @@ impl Board {
personal_at: None,
tab_at: None,
tab_sent: HashSet::new(),
quiet: HashSet::new(),
})
}
@@ -176,6 +179,14 @@ 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))
}
@@ -250,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));
@@ -476,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);
}
+255 -372
View File
@@ -7,16 +7,16 @@
// log refuses gives the items back. Nobody gets rich from it: buying always
// costs more than selling (an item priced otherwise isn't bought), no chain
// of recipes turns bought items into dearer ones (smp_recipes; such an item
// isn't bought either), and what a player sells is capped each UTC day, in
// coins and per item (`market.json` counts it). No game types: the glue
// isn't bought either). No daily sale caps. No game types: the glue
// 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, parse_amount};
use super::smp_market_prices::{
Book, Dynamic, MINUTE_MS, Point, Quote, Span, arrow, base_mid, one_price, to_get, to_pay,
Book, Dynamic, MIN_FACTOR, MINUTE_MS, OLD_MIN_FACTOR, Point, Quote, Span, arrow, base_mid,
one_price, to_get, to_pay,
};
use super::smp_store::{read_json, uuid_string, write_json};
use super::smp_store::read_json;
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, path::PathBuf};
@@ -39,12 +39,6 @@ pub struct Config {
pub enabled: bool,
/// By item id, `minecraft:diamond`. Items that stack to 64.
pub items: BTreeMap<String, Price>,
/// The most a player earns selling to the market in a UTC day (0: no
/// limit).
pub daily_sell_coins: i64,
/// The most of one item a player sells to the market in a UTC day (0:
/// no limit).
pub daily_sell_items: u32,
/// Prices that move with supply and demand (smp_market_prices); the
/// item prices above are then the base ones.
pub dynamic: Dynamic,
@@ -119,8 +113,7 @@ pub const PRICES: &[(&str, i64, i64)] = &[
];
impl Default for Config {
/// `PRICES` (one price where the market trades both ways), and the
/// daily limits.
/// `PRICES` (one price where the market trades both ways).
fn default() -> Self {
Self {
enabled: true,
@@ -137,8 +130,6 @@ impl Default for Config {
)
})
.collect(),
daily_sell_coins: 2000,
daily_sell_items: 640,
dynamic: Dynamic::default(),
}
}
@@ -167,9 +158,20 @@ const OLD_PRICES: [(&str, i64, i64); 18] = [
("torch", 1, 0),
];
/// A config whose prices are still the old defaults, untouched: today's
/// defaults instead. Whether it changed (the file is written again).
/// A config whose prices, or price floor, are still the old defaults,
/// untouched: today's defaults instead. Whether it changed (the file is
/// written again).
pub fn migrate(config: &mut Config) -> bool {
// 0.33 dropped the daily sale caps: the floor (60% of the base before)
// goes down to 20%, so selling without end pays less and less.
let floor = (config.dynamic.min_factor - OLD_MIN_FACTOR).abs() < 1e-9;
if floor {
config.dynamic.min_factor = MIN_FACTOR;
}
migrate_prices(config) || floor
}
fn migrate_prices(config: &mut Config) -> bool {
let old: BTreeMap<String, Price> = OLD_PRICES
.iter()
.map(|(id, buy, sell)| {
@@ -189,29 +191,11 @@ pub fn migrate(config: &mut Config) -> bool {
true
}
/// What one player sold today.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Sold {
/// The UTC day (days since 1970) the counts are for.
pub day: i64,
pub coins: i64,
pub items: BTreeMap<String, u32>,
}
/// `market.json`: what each player sold today, by UUID.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Ledger {
pub sold: BTreeMap<String, Sold>,
}
/// A config's prices made safe: an amount out of range closes that side; an
/// item traded both ways gets one price; an item a chain of recipes makes
/// for less than it sells for is no longer bought. What changed, to log.
pub fn validate(config: &mut Config) -> Vec<String> {
let mut closed = Vec::new();
config.daily_sell_coins = config.daily_sell_coins.clamp(0, MAX_AMOUNT);
config.dynamic.validate();
for (id, p) in config.items.iter_mut() {
if !valid_id(id) {
@@ -315,10 +299,8 @@ pub fn item_name(id: &str) -> String {
pub struct Market {
pub config: Config,
ledger: Ledger,
/// `market.json`; None in tests that keep nothing.
path: Option<PathBuf>,
dirty: bool,
/// The SMP data directory; None in tests that keep nothing.
dir: Option<PathBuf>,
/// The moving prices and their history (`market_prices.json`, written
/// by the glue off the tick thread: `book_to_write`).
pub book: Book,
@@ -368,9 +350,7 @@ const BOOK_EVERY_MS: i64 = 5 * MINUTE_MS;
impl Market {
/// The market of `dir` (`world/horizon-smp/`), its config made safe
/// (`validate`), with what to log. A `market.json` that can't be read
/// starts the day's counts from nothing (they only limit, never hold
/// money).
/// (`validate`), with what to log.
pub fn open(dir: &std::path::Path, mut config: Config) -> (Self, Vec<String>) {
let mids = |c: &Config| -> BTreeMap<String, f64> {
c.items
@@ -383,11 +363,6 @@ impl Market {
.into_iter()
.map(|l| format!("market: {l}"))
.collect();
let path = dir.join("market.json");
let ledger = read_json(&path).unwrap_or_else(|e| {
problems.push(format!("{e}: today's sale limits start again"));
Ledger::default()
});
let mut book: Book = read_json(&dir.join("market_prices.json")).unwrap_or_else(|e| {
problems.push(format!("{e}: prices start again from their base"));
Book::default()
@@ -401,9 +376,7 @@ impl Market {
}
let mut market = Self {
config,
ledger,
path: Some(path),
dirty: false,
dir: Some(dir.to_owned()),
book,
book_written: 0,
book_dirty: !rebased.is_empty(),
@@ -419,9 +392,7 @@ impl Market {
pub fn new(config: Config) -> Self {
let mut m = Self {
config,
ledger: Ledger::default(),
path: None,
dirty: false,
dir: None,
book: Book::default(),
book_written: 0,
book_dirty: false,
@@ -446,18 +417,21 @@ impl Market {
}
/// How far one `id` moves the log of its factor: in a family, by value
/// (a block of 9 moves it as 9 ingots do), so crafting can't beat it.
/// (a block of 9 moves it as 9 ingots do), so crafting can't beat it,
/// from its cheapest member's depth (so that every member, not only
/// the cheapest, reaches the floor within `MAX_DEPTH` items a 1%).
fn unit_move(&self, id: &str) -> f64 {
let d = &self.config.dynamic;
let mid = |id: &str| {
let b = self.base(id);
base_mid(b.buy, b.sell)
};
match self
.family
.get(id)
.and_then(|f| f.iter().chain([&id.to_owned()]).min().cloned())
{
match self.family.get(id).and_then(|f| {
f.iter()
.chain([&id.to_owned()])
.min_by(|a, b| mid(a).total_cmp(&mid(b)).then_with(|| a.cmp(b)))
.cloned()
}) {
Some(first) if mid(&first) > 0.0 => {
Book::unit_move(d, &first, mid(&first)) / mid(&first) * mid(id)
}
@@ -485,82 +459,6 @@ impl Market {
self.spot(id).map_or(0.0, |q| q.mid)
}
pub fn save(&mut self) -> Result<(), String> {
if !self.dirty {
return Ok(());
}
if let Some(path) = &self.path {
write_json(path, &self.ledger)?;
}
self.dirty = false;
Ok(())
}
/// What `uuid` sold today (`now`: milliseconds since 1970).
fn sold(&self, uuid: u128, now: i64) -> Sold {
match self.ledger.sold.get(&uuid_string(uuid)) {
Some(s) if s.day == now.div_euclid(DAY_MS) => s.clone(),
_ => Sold::default(),
}
}
/// The coins `uuid` may still earn selling today (None: no limit).
pub fn coins_left(&self, uuid: u128, now: i64) -> Option<i64> {
let cap = self.config.daily_sell_coins;
(cap > 0).then(|| (cap - self.sold(uuid, now).coins).max(0))
}
/// How many of `id` `uuid` may still sell today (None: no limit).
pub fn items_left(&self, uuid: u128, now: i64, id: &str) -> Option<u32> {
let cap = self.config.daily_sell_items;
(cap > 0)
.then(|| cap.saturating_sub(self.sold(uuid, now).items.get(id).copied().unwrap_or(0)))
}
/// Whether `uuid` may be paid `amount` by another player now (`/pay`):
/// money received counts toward the same daily limit as selling to the
/// market, so selling on alts and paying it all to a main doesn't get
/// past it. Err: what they may still receive today.
pub fn may_receive(&self, uuid: u128, amount: i64, now: i64) -> Result<(), i64> {
match self.coins_left(uuid, now) {
Some(left) if amount > left => Err(left),
_ => Ok(()),
}
}
/// Counts `coins` paid to `uuid` (`/pay`) against today's limit.
pub fn count_received(&mut self, uuid: u128, now: i64, coins: i64) {
let day = now.div_euclid(DAY_MS);
let s = self.ledger.sold.entry(uuid_string(uuid)).or_default();
if s.day != day {
*s = Sold {
day,
..Sold::default()
};
}
s.coins = s.coins.saturating_add(coins.max(0));
self.ledger.sold.retain(|_, s| s.day == day);
self.dirty = true;
}
/// Counts a sale against today's limits.
fn count_sale(&mut self, uuid: u128, now: i64, id: &str, n: u32, coins: i64) {
let day = now.div_euclid(DAY_MS);
let s = self.ledger.sold.entry(uuid_string(uuid)).or_default();
if s.day != day {
*s = Sold {
day,
..Sold::default()
};
}
s.coins = s.coins.saturating_add(coins);
let c = s.items.entry(id.to_owned()).or_default();
*c = c.saturating_add(n);
// Yesterday's counts go.
self.ledger.sold.retain(|_, s| s.day == day);
self.dirty = true;
}
/// The price of `id` now (nothing on either side when it isn't
/// traded): the base one moved by supply and demand.
pub fn price(&self, id: &str) -> Price {
@@ -642,7 +540,7 @@ impl Market {
if !self.book_dirty || (!force && now - self.book_written < BOOK_EVERY_MS) {
return None;
}
let path = self.path.as_ref()?.with_file_name("market_prices.json");
let path = self.dir.as_ref()?.join("market_prices.json");
self.book_dirty = false;
self.book_written = now;
Some((path, self.book.clone()))
@@ -696,26 +594,6 @@ impl Market {
}
}
/// Today's limits for `uuid`, as a line (None when there are none).
pub fn limits_line(&self, economy: &Economy, uuid: u128) -> Option<String> {
let now = economy.now;
let coins = self.coins_left(uuid, now).map(|left| {
format!(
"{} left of your {} a day",
economy.money(left),
economy.money(self.config.daily_sell_coins)
)
});
let items = (self.config.daily_sell_items > 0).then(|| {
format!(
"at most {} of each item a day",
self.config.daily_sell_items
)
});
let parts: Vec<String> = coins.into_iter().chain(items).collect();
(!parts.is_empty()).then(|| format!("Selling today: {}.", parts.join(", ")))
}
/// Runs `/worth`, `/sell` or `/buy`; None for another command. `trades`
/// is false for a player in creative or spectator mode.
pub fn command(
@@ -735,7 +613,7 @@ impl Market {
return Some(vec![Out::Error("The market is closed.".into())]);
}
if name == "worth" {
return Some(self.worth(economy, me, bag, args));
return Some(self.worth(economy, bag, args));
}
if !trades {
return Some(vec![Out::Error(
@@ -748,7 +626,7 @@ impl Market {
})
}
fn worth(&self, economy: &Economy, me: &Player, bag: &mut dyn Bag, args: &[&str]) -> Vec<Out> {
fn worth(&self, economy: &Economy, bag: &mut dyn Bag, args: &[&str]) -> Vec<Out> {
let id = match args {
[] => match bag.held() {
Some((id, ..)) => id,
@@ -781,19 +659,10 @@ impl Market {
id.trim_start_matches("minecraft:")
)));
}
if p.sell > 0 {
if let Some(left) = self.items_left(me.uuid, economy.now, &id) {
out.push(Out::Reply(format!(
"You may sell {left} more {name} today."
)));
}
out.extend(self.limits_line(economy, me.uuid).map(Out::Reply));
}
out
}
/// Sells what `args` names (`hand [n]`, `all`, `<item> [n]`), within
/// today's limits.
/// Sells what `args` names (`hand [n]`, `all`, `<item> [n]`).
pub fn sell(
&mut self,
economy: &mut Economy,
@@ -846,39 +715,15 @@ impl Market {
continue;
}
let price = self.each(&id).max(0.01);
// Never more than the account can be paid for at once, nor
// than today's limits leave.
let now = economy.now;
let mut cap = (MAX_AMOUNT as f64 / price).min(f64::from(u32::MAX)) as u32;
if let Some(left) = self.items_left(me.uuid, now, &id) {
cap = cap.min(left);
}
// What they bought here lately can't come back yet.
let back = self.recently_bought(me.uuid, &id, now);
if let Some(left) = self.coins_left(me.uuid, now) {
cap = cap.min((left as f64 / price).min(f64::from(u32::MAX)) as u32);
}
// Never more than the account can be paid for at once.
let cap = (MAX_AMOUNT as f64 / price).min(f64::from(u32::MAX)) as u32;
let carried = bag.count(&id).min(at_most);
if carried == 0 {
out.push(Out::Error(format!("You have no plain {name}.")));
continue;
}
let limited = carried.min(cap);
if limited == 0 {
out.push(Out::Error(format!(
"You reached today's selling limit for {name}; it starts again at midnight UTC."
)));
continue;
}
let have = limited.min(carried.saturating_sub(back));
if have == 0 {
let hours = self.config.dynamic.round_trip_hours;
out.push(Out::Error(format!(
"You bought {back} {name} from the market in the last {hours} h: those can't be sold back yet."
)));
continue;
}
let taken = bag.take(&id, have);
let taken = bag.take(&id, limited);
if taken == 0 {
continue;
}
@@ -905,7 +750,6 @@ impl Market {
let kind = format!("sell {taken} {id}");
match economy.adjust(&kind, me.uuid, pay, "") {
Ok(_) => {
self.count_sale(me.uuid, now, &id, taken, pay);
self.moved(&id, taken, false);
total = total.saturating_add(pay);
let each = each_money(economy, pay as f64 / f64::from(taken));
@@ -913,19 +757,6 @@ impl Market {
"Sold {taken} {name} at {each} each: {}.",
economy.money(pay)
)));
if taken < limited {
let hours = self.config.dynamic.round_trip_hours;
out.push(Out::Reply(format!(
"{} {name} kept: bought from the market in the last {hours} h.",
limited - taken
)));
}
if limited < carried {
out.push(Out::Reply(format!(
"{} {name} kept: today's selling limit.",
carried - limited
)));
}
}
Err(e) => {
let back = bag.give(&id, taken);
@@ -1008,9 +839,6 @@ impl Market {
};
if given > 0 {
self.moved(&id, given, true);
let now = economy.now;
self.book.bought(&uuid_string(me.uuid), &id, given, now);
self.book_dirty = true;
}
let each = each_money(economy, paid as f64 / f64::from(given.max(1)));
let mut out = vec![Out::Reply(format!(
@@ -1069,15 +897,6 @@ impl Market {
);
self.book_dirty = true;
}
/// How many of `id` `uuid` bought within the round-trip window.
pub fn recently_bought(&self, uuid: u128, id: &str, now: i64) -> u32 {
if !self.config.dynamic.enabled {
return 0;
}
self.book
.recently_bought(&self.config.dynamic, &uuid_string(uuid), id, now)
}
}
/// `minecraft:iron_ingot` as `Iron Ingot`.
@@ -1207,6 +1026,7 @@ impl WebView {
#[cfg(test)]
mod tests {
use super::super::smp_economy::Config as EconomyConfig;
use super::super::smp_store::{uuid_string, write_json};
use super::*;
use std::fs;
@@ -1279,31 +1099,58 @@ mod tests {
}
#[test]
fn money_received_by_pay_counts_toward_the_daily_selling_limit() {
let config = Config {
daily_sell_coins: 250,
..Config::default()
fn legacy_daily_caps_do_not_limit_sales_or_payments() {
// Existing configs and today's saved ledger may still contain the
// removed daily caps and receipts from before the upgrade.
let config: Config = serde_json::from_value(serde_json::json!({
"daily_sell_coins": 2000,
"daily_sell_items": 640,
"dynamic": {"enabled": false}
}))
.unwrap();
let (mut e, mut m, me, dir) = setup_with("no-coin-cap", config);
e.now = 5 * 86_400_000;
fs::write(
dir.join("market.json"),
serde_json::json!({
"sold": { uuid_string(ALEX): {
"day": 5, "coins": 2000, "items": {"minecraft:diamond": 640}
}}
})
.to_string(),
)
.unwrap();
let (loaded, problems) = Market::open(&dir, m.config.clone());
assert!(problems.is_empty(), "{problems:?}");
m = loaded;
let mut b = bag(&[("minecraft:diamond", 700)], 0);
m.command(&mut e, &me, true, &mut b, "sell diamond")
.unwrap();
assert_eq!(b.count("minecraft:diamond"), 0);
assert_eq!(e.balance(ALEX), 140_000);
let earned = e.balance(ALEX);
e.seen(2, "Steve");
e.adjust("test", 2, 10_000, "").unwrap();
let sender = Player {
uuid: 2,
name: "Steve".into(),
level: 0,
};
let (_e, mut m, _me, _dir) = setup_with("received", config);
let now = 1_793_577_600_000;
// An alt pays the main 200: fine, and it counts.
assert_eq!(m.may_receive(ALEX, 200, now), Ok(()));
m.count_received(ALEX, now, 200);
assert_eq!(m.coins_left(ALEX, now), Some(50));
// A second alt's 100 is refused: 50 left today, for sales or payments.
assert_eq!(m.may_receive(ALEX, 100, now), Err(50));
assert_eq!(m.may_receive(ALEX, 50, now), Ok(()));
// The next day the limit is whole again.
assert_eq!(m.may_receive(ALEX, 250, now + DAY_MS), Ok(()));
// No limit configured: anything goes.
let (_e, m, _me, _dir) = setup_with(
"unlimited",
Config {
daily_sell_coins: 0,
..Config::default()
},
);
assert_eq!(m.may_receive(ALEX, 1_000_000, now), Ok(()));
// Repeated receipts over the former cap, on the same day as sales.
for _ in 0..2 {
let out = e.command(&sender, &[], "pay Alex 3000").unwrap();
assert!(matches!(&out[0], Out::Reply(_)), "{out:?}");
}
assert_eq!(e.balance(ALEX), earned + 6000);
assert_eq!(e.balance(2), 4000);
// Reopening on the same day ignores the old full daily ledger too.
let (mut m, problems) = Market::open(&dir, m.config.clone());
assert!(problems.is_empty(), "{problems:?}");
b.items.insert("minecraft:diamond".into(), 700);
m.command(&mut e, &me, true, &mut b, "sell all").unwrap();
assert_eq!(b.count("minecraft:diamond"), 0);
assert_eq!(e.balance(ALEX), 286_000);
fs::remove_dir_all(dir).unwrap();
}
#[test]
@@ -1433,95 +1280,6 @@ mod tests {
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn sales_stop_at_the_daily_limits_and_start_again_the_next_day() {
let config = Config {
daily_sell_coins: 250,
daily_sell_items: 30,
..fixed()
};
let (mut e, mut m, me, dir) = setup_with("limits", config);
e.now = 5 * DAY_MS + 1000;
let mut b = bag(&[("minecraft:diamond", 5), ("minecraft:wheat", 64)], 0);
// 250 coins a day: one diamond (200), then 25 wheat (50 coins), the
// coins' limit before the 30 items of one kind.
let out = m
.command(&mut e, &me, true, &mut b, "sell diamond")
.unwrap();
assert_eq!(
out[0],
Out::Reply("Sold 1 diamond at 200 coins each: 200 coins.".into()),
"{out:?}"
);
assert_eq!(
out[1],
Out::Reply("4 diamond kept: today's selling limit.".into())
);
let out = m
.command(&mut e, &me, true, &mut b, "sell wheat 5")
.unwrap();
assert_eq!(
out[0],
Out::Reply("Sold 5 wheat at 2 coins each: 10 coins.".into()),
"{out:?}"
);
// 40 coins left, but 25 more wheat would pass the 30 items.
e.adjust("test", ALEX, 0, "").ok();
m.config.daily_sell_coins = 1000;
let out = m.command(&mut e, &me, true, &mut b, "sell wheat").unwrap();
assert_eq!(
out[0],
Out::Reply("Sold 25 wheat at 2 coins each: 50 coins.".into()),
"{out:?}"
);
m.config.daily_sell_coins = 250;
let out = m.command(&mut e, &me, true, &mut b, "sell wheat").unwrap();
assert_eq!(
out,
[Out::Error(
"You reached today's selling limit for wheat; it starts again at midnight UTC."
.into()
)]
);
let worth = m
.command(&mut e, &me, true, &mut b, "worth diamond")
.unwrap();
assert!(
worth.contains(&Out::Reply("You may sell 29 more diamond today.".into())),
"{worth:?}"
);
assert!(
worth.contains(&Out::Reply(
"Selling today: 0 coins left of your 250 coins a day, at most 30 of each item a day.".into()
)),
"{worth:?}"
);
assert_eq!((e.balance(ALEX), b.items["minecraft:diamond"]), (260, 4));
// Kept across a restart, in market.json.
m.path = Some(dir.join("market.json"));
m.dirty = true;
m.save().unwrap();
let (mut m, _) = Market::open(&dir, m.config.clone());
let out = m
.command(&mut e, &me, true, &mut b, "sell diamond")
.unwrap();
assert!(
matches!(&out[0], Out::Error(s) if s.contains("limit")),
"{out:?}"
);
// A new UTC day.
e.now = 6 * DAY_MS;
let out = m
.command(&mut e, &me, true, &mut b, "sell diamond")
.unwrap();
assert_eq!(
out[0],
Out::Reply("Sold 1 diamond at 200 coins each: 200 coins.".into()),
"{out:?}"
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn prices_that_would_make_money_are_closed() {
let mut config = fixed();
@@ -1556,13 +1314,7 @@ mod tests {
);
assert!(config.items.keys().all(|k| valid_id(k)));
// At the largest price: one at a time, without overflow.
let (mut e, mut m, me, dir) = setup_with(
"validate",
Config {
daily_sell_coins: 0,
..config
},
);
let (mut e, mut m, me, dir) = setup_with("validate", Config { ..config });
let mut b = bag(&[("minecraft:rotten_flesh", 64)], 0);
let out = m
.command(&mut e, &me, true, &mut b, "sell rotten_flesh")
@@ -1630,15 +1382,9 @@ mod tests {
assert_eq!(edited, before);
}
/// Moving prices, validated, with no daily caps and no round-trip
/// window: only the prices themselves stand between a trader and money.
/// Moving prices, validated: only the prices stand between a trader and money.
fn moving() -> Config {
let mut c = Config {
daily_sell_coins: 0,
daily_sell_items: 0,
..Config::default()
};
c.dynamic.round_trip_hours = 0;
let mut c = Config::default();
let _ = validate(&mut c);
c
}
@@ -1797,11 +1543,157 @@ mod tests {
}
#[test]
fn moving_prices_pay_the_whole_receipt_and_keep_bought_items_from_coming_back() {
fn the_old_price_floor_becomes_the_new_one_and_an_edited_one_stays() {
let mut c = Config::default();
let _ = validate(&mut c);
c.dynamic.min_factor = 0.6;
assert!(migrate(&mut c));
assert_eq!(c.dynamic.min_factor, 0.2);
assert!(!migrate(&mut c), "once");
c.dynamic.min_factor = 0.5;
assert!(!migrate(&mut c));
assert_eq!(c.dynamic.min_factor, 0.5);
}
#[test]
fn selling_ten_thousand_of_any_item_drives_its_price_to_the_floor() {
// No daily cap: an AFK farm sells 10,000 of one item, a minute at a
// time as the band allows. The price falls to a fifth of its base
// and the farm gets far less than 10,000 at the base price.
let c = moving();
let ids: Vec<String> = c
.items
.iter()
.filter(|(_, p)| p.sell > 0)
.map(|(id, _)| id.clone())
.collect();
assert!(ids.len() > 30, "{ids:?}");
let t0 = 1_800_000_000_000i64;
for id in ids {
let (mut e, _, me, dir) = setup(&format!("farm-{id}"));
let mut m = Market::new(c.clone());
let base = m.base(&id).sell;
// Room for what a minute's band keeps.
let mut b = bag(&[(id.as_str(), 10_000)], u32::MAX);
let mut now = t0;
e.now = now;
m.tick(now);
let mut minutes = 0;
while b.count(&id) > 0 && minutes < 600 {
m.sell(&mut e, &me, &mut b, &[&id]);
minutes += 1;
now += MINUTE_MS;
e.now = now;
m.tick(now);
}
// A few left worth less than a coin at the floor: kept.
assert!(b.count(&id) < 5, "{id}: {} unsold", b.count(&id));
let got = e.balance(ALEX);
let at_base = 10_000 * base;
assert!(
got * 2 < at_base,
"{id}: 10,000 sold for {got}, {at_base} at the base price"
);
let f = m.book.factor(&id);
assert!(f < c.dynamic.min_factor * 1.01, "{id}: factor {f}");
if std::env::var_os("SMP_MARKET_REPORT").is_some() {
eprintln!("{id}: 10,000 sold for {got} ({at_base} at the base) in {minutes} min");
}
// The price comes back once the selling stops: in a day, to
// within 10% of the base.
m.tick(now + 24 * 60 * MINUTE_MS);
assert!(m.book.factor(&id) > 0.9, "{id}: {}", m.book.factor(&id));
fs::remove_dir_all(dir).ok();
}
}
#[test]
fn buying_and_selling_straight_back_never_earns_without_a_resale_delay() {
// Every item the market trades both ways, bought and sold back the
// same moment, from a fresh price, from the floor (after another
// player's dump) and from the top (after another's pumping): the
// trader never ends with more coins than they started with.
let c = moving();
let ids: Vec<String> = c
.items
.iter()
.filter(|(_, p)| p.buy > 0 && p.sell > 0)
.map(|(id, _)| id.clone())
.collect();
assert!(ids.len() > 10, "{ids:?}");
let t0 = 1_800_000_000_000i64;
let mut cases = 0;
let mut worst = (0i64, String::new());
for id in &ids {
for start in ["fresh", "floor", "top"] {
for n in [1u32, 7, 64, 640, 64 * 36] {
let (mut e, _, me, dir) = setup("loop");
let mut m = Market::new(c.clone());
let mut now = t0;
m.tick(now);
// Others push the price to a bound, a minute at a time.
let to_bound = |m: &mut Market, now: &mut i64, buy: bool| {
for _ in 0..200 {
m.moved(id, 100_000, buy);
*now += MINUTE_MS;
m.tick(*now);
}
};
match start {
"floor" => to_bound(&mut m, &mut now, false),
"top" => to_bound(&mut m, &mut now, true),
_ => {}
}
match start {
"floor" => assert!(m.book.factor(id) < c.dynamic.min_factor * 1.01),
"top" => assert!(m.book.factor(id) > c.dynamic.max_factor * 0.99),
_ => {}
}
e.now = now;
e.adjust("test_funds", me.uuid, 100_000_000, "").unwrap();
let start_coins = e.balance(me.uuid);
let mut b = bag(&[], u32::MAX);
m.buy(&mut e, &me, &mut b, &[id, &n.to_string()]);
let bought = b.count(id);
assert!(bought > 0, "{id} {start} {n}: nothing bought");
// At once; what the minute's band keeps, in the next
// minutes (the price only drifts back down meanwhile).
m.sell(&mut e, &me, &mut b, &[id]);
let mut minutes = 0;
while b.count(id) >= 5 && minutes < 2_000 {
now += MINUTE_MS;
e.now = now;
m.tick(now);
m.sell(&mut e, &me, &mut b, &[id]);
minutes += 1;
}
assert!(b.count(id) < 5, "{id} {start} {n}: {} kept", b.count(id));
let end = e.balance(me.uuid);
assert!(
end <= start_coins,
"{id} from {start}: bought {bought} and sold back, {} coins earned",
end - start_coins
);
worst = worst.max((end - start_coins, format!("{id} {start} {n}")));
cases += 1;
fs::remove_dir_all(dir).ok();
}
}
}
assert_eq!(cases, ids.len() * 15);
if std::env::var_os("SMP_MARKET_REPORT").is_some() {
eprintln!("round trips: {cases}, best for the trader {worst:?}");
}
}
#[test]
fn moving_prices_allow_immediate_resale_with_legacy_purchase_history() {
let c: Config = serde_json::from_value(serde_json::json!({
"dynamic": {"round_trip_hours": 24}
}))
.unwrap();
let (mut e, _, me, dir) = setup("dyn");
let mut m = Market::new(c);
let (mut m, problems) = Market::open(&dir, c);
assert!(problems.is_empty(), "{problems:?}");
let t0 = 1_800_000_000_000i64;
e.now = t0;
e.adjust("test_funds", me.uuid, 10_000, "").unwrap();
@@ -1819,31 +1711,22 @@ mod tests {
Out::Reply("Bought 10 iron ingot at 15 coins each: 151 coins.".into())
);
assert_eq!(e.balance(me.uuid), 10_000 + 595 - 151);
// Old files can still contain a purchase within the former 24 h
// window. Preserve the moving price history and ignore that receipt.
let mut old_book = serde_json::to_value(&m.book).unwrap();
old_book["bought"] = serde_json::json!({
uuid_string(me.uuid): [["minecraft:iron_ingot", t0, 10]]
});
write_json(&dir.join("market_prices.json"), &old_book).unwrap();
let (mut m, problems) = Market::open(&dir, m.config.clone());
assert!(problems.is_empty(), "{problems:?}");
let out = m.sell(&mut e, &me, &mut b, &["iron_ingot"]);
assert_eq!(
out[0],
Out::Error("You bought 10 iron ingot from the market in the last 24 h: those can't be sold back yet.".into())
);
// Five more found in a mine: those sell, the bought ones stay.
b.items.insert("minecraft:iron_ingot".into(), 15);
let out = m.sell(&mut e, &me, &mut b, &["iron_ingot"]);
assert!(
matches!(&out[0], Out::Reply(r) if r.starts_with("Sold 5 iron ingot")),
"{out:?}"
);
assert_eq!(
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);
// A day later they may.
e.now = t0 + 25 * 3_600_000;
m.tick(e.now);
let out = m.sell(&mut e, &me, &mut b, &["iron_ingot"]);
assert!(
matches!(&out[0], Out::Reply(r) if r.starts_with("Sold 10 iron ingot")),
"{out:?}"
Out::Reply("Sold 10 iron ingot at 15 coins each: 150 coins.".into())
);
assert_eq!(b.count("minecraft:iron_ingot"), 0);
assert_eq!(e.balance(me.uuid), 10_000 + 595 - 151 + 150);
fs::remove_dir_all(dir).ok();
}
+16 -51
View File
@@ -28,6 +28,17 @@ pub const HOUR_MS: i64 = 3_600_000;
pub const MINUTES: usize = 1440;
pub const HOURS: usize = 720;
/// The least share of its base price the market pays (since 0.33, with no
/// daily sale caps: an item sold without end falls to it and stays there
/// while the selling goes on).
pub const MIN_FACTOR: f64 = 0.2;
/// The default floor before 0.33: a config that still has it gets
/// `MIN_FACTOR` (`smp_market::migrate`).
pub const OLD_MIN_FACTOR: f64 = 0.6;
/// The most items that move a price 1% (unless `depth` says otherwise): the
/// cheap items farms make reach the floor within 10,000 sold.
pub const MAX_DEPTH: f64 = 50.0;
/// `config.json`'s `market.dynamic`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -43,9 +54,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,
/// What a player bought from the market can't be sold back to it for
/// this long (that many of that item).
pub round_trip_hours: i64,
/// Items that move the price 1%, by item (else from its price: dear
/// items move faster).
pub depth: BTreeMap<String, f64>,
@@ -58,13 +66,12 @@ impl Default for Dynamic {
fn default() -> Self {
Self {
enabled: true,
min_factor: 0.6,
min_factor: MIN_FACTOR,
max_factor: 1.6,
half_life_hours: 6.0,
max_trade_impact_percent: 2.0,
max_player_impact_percent: 2.0,
max_minute_move_percent: 5.0,
round_trip_hours: 24,
depth: BTreeMap::new(),
news_percent: 30.0,
news_min_volume: 64,
@@ -98,7 +105,6 @@ impl Dynamic {
50.0,
d.max_minute_move_percent,
);
self.round_trip_hours = self.round_trip_hours.clamp(0, 24 * 30);
self.depth.retain(|_, v| v.is_finite() && *v > 0.0);
}
}
@@ -175,9 +181,6 @@ pub struct Book {
pub minute: i64,
/// The UTC day the last summary was for.
pub summary_day: i64,
/// What each player bought (item, when, how many), for the round-trip
/// rule.
pub bought: BTreeMap<String, Vec<(String, i64, u32)>>,
/// The 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).
@@ -262,7 +265,7 @@ impl Book {
.depth
.get(id)
.copied()
.unwrap_or_else(|| (400.0 / mid.max(0.01)).clamp(2.0, 400.0))
.unwrap_or_else(|| (400.0 / mid.max(0.01)).clamp(2.0, MAX_DEPTH))
}
/// How far one of `id` (based at `mid`) moves the log of its factor.
@@ -448,34 +451,9 @@ impl Book {
// Items no longer traded go.
self.items.retain(|id, _| mids.contains_key(id));
self.minute = minute;
// Purchases past the round-trip window go.
let since = now - config.round_trip_hours * HOUR_MS;
for v in self.bought.values_mut() {
v.retain(|(_, t, _)| *t > since);
}
self.bought.retain(|_, v| !v.is_empty());
true
}
/// `uuid` bought `n` of `id` at `now`.
pub fn bought(&mut self, uuid: &str, id: &str, n: u32, now: i64) {
self.bought
.entry(uuid.to_owned())
.or_default()
.push((id.to_owned(), now, n));
}
/// How many of `id` `uuid` bought within the round-trip window.
pub fn recently_bought(&self, config: &Dynamic, uuid: &str, id: &str, now: i64) -> u32 {
let since = now - config.round_trip_hours * HOUR_MS;
self.bought.get(uuid).map_or(0, |v| {
v.iter()
.filter(|(i, t, _)| i == id && *t > since)
.map(|e| e.2)
.sum()
})
}
pub fn history(&self, id: &str, span: Span) -> &[Point] {
match self.items.get(id) {
Some(t) => match span {
@@ -667,10 +645,10 @@ mod tests {
assert!((b.factor(D) - (-0.005f64).exp()).abs() < 1e-12, "at once");
assert_eq!(deal(&mut b, 1, true), (-250, 1));
assert!((b.factor(D) - 1.0).abs() < 1e-12, "and back");
// Wheat is deep: 64 move it far less than one diamond.
// Wheat is deep: a whole stack moves it under 1.5%.
let w = Book::unit_move(&c, "minecraft:wheat", 2.0);
b.trade(&c, "minecraft:wheat", &[], 2.0, w, 64, false);
assert!(1.0 - b.factor("minecraft:wheat") < 0.004);
assert!(1.0 - b.factor("minecraft:wheat") < 0.015);
// Its family moves with it.
let fam = vec!["minecraft:hay_block".to_string()];
b.trade(&c, "minecraft:wheat", &fam, 2.0, w, 64, true);
@@ -843,20 +821,7 @@ mod tests {
}
#[test]
fn the_round_trip_window_and_one_price() {
let c = Dynamic::default();
let mut b = book();
b.bought("u", "minecraft:diamond", 5, T0);
assert_eq!(
b.recently_bought(&c, "u", "minecraft:diamond", T0 + HOUR_MS),
5
);
b.tick(&c, &mids(), T0 + 25 * HOUR_MS);
assert_eq!(
b.recently_bought(&c, "u", "minecraft:diamond", T0 + 25 * HOUR_MS),
0
);
assert!(b.bought.is_empty());
fn one_price_and_whole_coin_rounding() {
// One price from the old two: their geometric mean, whole.
assert_eq!(one_price(400, 100), 200);
assert_eq!(one_price(30, 8), 15);
+806 -15
View File
@@ -1,8 +1,9 @@
// Places: the server's spawn (`/spawn`, `/setspawn`), each player's homes
// (`/home`, `/sethome`, `/delhome`, `/homes`), warps (`/warp`, `/warps`,
// `/setwarp`, `/delwarp`), teleport requests between players (`/tpa`,
// `/tpaccept`, `/tpdeny`), `/back` (where the player died, or was before
// their last teleport), `/playtime` and `/rules`. A teleport waits `warmup_seconds`,
// `/tpaccept`, `/tpdeny`), `/rtp` (`/wild`: a random safe spot far from the
// spawn), `/back` (where the player died, or was before their last
// teleport), `/playtime` and `/rules`. A teleport waits `warmup_seconds`,
// and a player who moves or is hurt meanwhile stays where they are; the glue
// moves them with a vanilla `tp` run as the server. The number of homes grows
// with the time a player has played here, counted by the mod. Everything is
@@ -12,7 +13,7 @@
use super::smp_economy::{Out, Player};
use super::smp_store;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};
/// `config.json`'s `places` section.
@@ -34,6 +35,17 @@ pub struct Config {
/// spawn point, like vanilla's `respawn_radius` for a first join or a
/// respawn (0: exactly the spawn point).
pub spawn_radius: i64,
/// `/rtp` lands at least this many blocks from the spawn,
pub rtp_min_distance: i64,
/// and at most this many (uniform over the ring's area, inside the
/// world border; the Overworld only).
pub rtp_max_distance: i64,
/// Between two `/rtp`s of one player, kept over a restart.
pub rtp_cooldown_seconds: i64,
/// Chunks tried, one after another, before `/rtp` gives up.
pub rtp_attempts: i64,
/// How long `/rtp` waits, after the warmup, for the land to load.
pub rtp_search_seconds: i64,
}
impl Default for Config {
@@ -49,6 +61,11 @@ impl Default for Config {
"The currency is earned in game only: never bought or sold for real money.".into(),
],
spawn_radius: 250,
rtp_min_distance: 500,
rtp_max_distance: 5000,
rtp_cooldown_seconds: 1800,
rtp_attempts: 6,
rtp_search_seconds: 30,
}
}
}
@@ -82,6 +99,40 @@ struct Data {
homes: BTreeMap<String, BTreeMap<String, Place>>,
/// Seconds played here, by player UUID.
played: BTreeMap<String, i64>,
/// The last `/rtp` (ms), by player UUID.
rtp: BTreeMap<String, i64>,
/// The Overworld chunks `/rtp` holds loaded (`forceload add`): let go at
/// the next start if the server stopped first.
forced: BTreeSet<(i32, i32)>,
}
/// The world border, `[min_x, min_z, max_x, max_z]`.
pub type Bounds = [f64; 4];
/// The Overworld, the only level `/rtp` goes to.
pub const OVERWORLD: &str = "minecraft:overworld";
/// An `/rtp` looking for its spot.
#[derive(Clone, Debug, PartialEq)]
pub struct Rtp {
/// Where the distances count from.
centre: (f64, f64),
border: Option<Bounds>,
/// The chunks left to try, the one loading first.
chunks: Vec<(i32, i32)>,
/// The spot found, waiting for the warmup's end.
spot: Option<Place>,
/// "Looking for a safe spot" was said.
told: bool,
}
/// A loaded chunk's columns, as the game read them: each column's `x`,
/// `z`, height (the first block above its ground) and its ground, feet and
/// head blocks, as indexes into `palette`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Ground {
pub palette: Vec<String>,
pub columns: Vec<(i32, i32, i32, [u16; 3])>,
}
/// Where a teleport goes.
@@ -93,6 +144,8 @@ enum To {
Spawn(Place),
/// A player, wherever they are when the warmup ends.
Player(u128, String),
/// `/rtp`: a safe spot found in the chunks it loads.
Random(Rtp),
}
/// A teleport waiting for its warmup.
@@ -126,8 +179,17 @@ pub struct Teleport {
/// 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)>),
/// What it changed: `call_off` puts it back.
undo: Undo,
}
/// The cooldown and `/back` before a teleport, and the `/rtp` cooldown
/// when it is one.
#[derive(Clone, Debug, PartialEq)]
struct Undo {
last: Option<i64>,
back: Option<(Place, bool)>,
rtp: Option<Option<i64>>,
}
impl Teleport {
@@ -146,9 +208,9 @@ impl Teleport {
}
/// The places' commands.
pub const COMMANDS: [&str; 16] = [
pub const COMMANDS: [&str; 18] = [
"spawn", "setspawn", "home", "sethome", "delhome", "homes", "warp", "warps", "setwarp",
"delwarp", "tpa", "tpaccept", "tpdeny", "rules", "back", "playtime",
"delwarp", "tpa", "tpaccept", "tpdeny", "rules", "back", "playtime", "rtp", "wild",
];
pub struct Places {
@@ -166,7 +228,93 @@ pub struct Places {
/// over a restart.
back: HashMap<u128, (Place, bool)>,
/// The players seen dead (health 0) and not respawned yet.
dead: std::collections::HashSet<u128>,
dead: HashSet<u128>,
/// Chunks an `/rtp` landed in, held loaded until then (ms): the
/// player's own view takes over.
release: Vec<((i32, i32), i64)>,
/// Chunks someone else forced already: wanted, never let go by `/rtp`.
others: HashSet<(i32, i32)>,
/// `/rtp`'s random (never the world's).
pub rng: u64,
/// The world border, as the game last told (None: the default one).
pub border: Option<Bounds>,
}
/// `2 h 05 min`, `12 min`, `40 s` for the time left on a cooldown.
fn wait_text(ms: i64) -> String {
let s = (ms + 999) / 1000;
if s < 60 {
format!("{s} s")
} else {
duration(s + 59)
}
}
/// SplitMix64: the next of `state`'s numbers.
fn next(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// A number in `0..1`.
fn unit(state: &mut u64) -> f64 {
(next(state) >> 11) as f64 / (1u64 << 53) as f64
}
/// A point `min..=max` blocks from `centre`, uniform over the ring's area,
/// from two numbers in `0..1`.
pub fn ring_point(centre: (f64, f64), min: f64, max: f64, u: f64, v: f64) -> (f64, f64) {
let r = (min * min + u * (max * max - min * min)).sqrt();
let a = v * std::f64::consts::TAU;
(centre.0 + r * a.cos(), centre.1 + r * a.sin())
}
/// Whether `(x, z)` is at least 16 blocks inside the border.
fn inside(border: Option<Bounds>, x: f64, z: f64) -> bool {
border.is_none_or(|[x0, z0, x1, z1]| {
x >= x0 + 16.0 && x <= x1 - 16.0 && z >= z0 + 16.0 && z <= z1 - 16.0
})
}
/// A block a player may stand on: solid, no fluid, nothing that hurts.
fn ground_ok(name: &str) -> bool {
let n = name.strip_prefix("minecraft:").unwrap_or(name);
!(matches!(
n,
"air"
| "cave_air"
| "void_air"
| "water"
| "lava"
| "fire"
| "soul_fire"
| "cactus"
| "magma_block"
| "powder_snow"
| "campfire"
| "soul_campfire"
| "sweet_berry_bush"
| "pointed_dripstone"
| "wither_rose"
| "bubble_column"
| "kelp"
| "kelp_plant"
| "seagrass"
| "tall_seagrass"
| "bedrock"
) || n.ends_with("_leaves"))
}
/// A block a player's feet or head may be in: air, or a plant they walk through.
fn room_ok(name: &str) -> bool {
let n = name.strip_prefix("minecraft:").unwrap_or(name);
matches!(
n,
"air" | "cave_air" | "short_grass" | "tall_grass" | "fern" | "large_fern" | "snow"
)
}
/// `2 h 05 min`, `12 min`.
@@ -209,6 +357,14 @@ impl Places {
last: HashMap::new(),
back: HashMap::new(),
dead: Default::default(),
release: Vec::new(),
others: HashSet::new(),
rng: {
use std::hash::BuildHasher;
std::collections::hash_map::RandomState::new()
.hash_one(std::time::SystemTime::now())
},
border: None,
})
}
@@ -269,11 +425,19 @@ impl Places {
/// 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 {
if let Some(rtp) = t.undo.rtp {
let key = smp_store::uuid_string(t.uuid);
match rtp {
Some(at) => self.data.rtp.insert(key, at),
None => self.data.rtp.remove(&key),
};
self.changed = true;
}
match t.undo.last {
Some(at) => self.last.insert(t.uuid, at),
None => self.last.remove(&t.uuid),
};
match &t.undo.1 {
match &t.undo.back {
Some(b) => self.back.insert(t.uuid, b.clone()),
None => self.back.remove(&t.uuid),
};
@@ -308,6 +472,182 @@ impl Places {
}
}
/// `/rtp`: refused outside the Overworld and during its cooldown;
/// otherwise the chunks to try are drawn and the warmup starts. The
/// distances count from the spawn (`/setspawn`), 0 0 without one.
fn rtp(&mut self, me: &Body, now: i64) -> Vec<Out> {
if me.at.dimension != OVERWORLD {
return vec![Out::Error("/rtp only works in the Overworld.".into())];
}
let key = smp_store::uuid_string(me.uuid);
if let Some(&t) = self.data.rtp.get(&key) {
let wait = t + self.config.rtp_cooldown_seconds * 1000 - now;
if wait > 0 {
return vec![Out::Error(format!(
"Wait {} before your next /rtp.",
wait_text(wait)
))];
}
}
let centre = match &self.data.spawn {
Some(s) if s.dimension == OVERWORLD => (s.x, s.z),
_ => (0.0, 0.0),
};
let chunks = self.rtp_chunks(centre, self.border);
if chunks.is_empty() {
return vec![Out::Error(
"No land for /rtp: the world border is too close to the spawn.".into(),
)];
}
let border = self.border;
self.start(
me,
To::Random(Rtp {
centre,
border,
chunks,
spot: None,
told: false,
}),
now,
)
}
/// Up to `rtp_attempts` distinct chunks around points drawn on the ring,
/// inside the border (none when the border leaves no room).
fn rtp_chunks(&mut self, centre: (f64, f64), border: Option<Bounds>) -> Vec<(i32, i32)> {
let min = self.config.rtp_min_distance.max(0) as f64;
let max = (self.config.rtp_max_distance as f64).max(min);
let mut chunks = Vec::new();
// Drawn until enough fall inside the border: a border that cuts the
// ring takes more draws (a few microseconds each).
for _ in 0..2_000 {
if chunks.len() >= self.config.rtp_attempts.max(1) as usize {
break;
}
let (u, v) = (unit(&mut self.rng), unit(&mut self.rng));
let (x, z) = ring_point(centre, min, max, u, v);
let c = ((x.floor() as i32) >> 4, (z.floor() as i32) >> 4);
if inside(border, x, z) && !chunks.contains(&c) {
chunks.push(c);
}
}
chunks
}
/// The safe columns of a loaded chunk that are within the ring and the
/// border, and one of them at random.
fn pick(&mut self, rtp: &Rtp, ground: &Ground) -> Option<Place> {
let ok: Vec<bool> = ground.palette.iter().map(|n| ground_ok(n)).collect();
let room: Vec<bool> = ground.palette.iter().map(|n| room_ok(n)).collect();
let (min, max) = (
self.config.rtp_min_distance.max(0) as f64,
self.config.rtp_max_distance as f64,
);
let safe: Vec<&(i32, i32, i32, [u16; 3])> = ground
.columns
.iter()
.filter(|(x, z, _, [g, f, h])| {
let (px, pz) = (f64::from(*x) + 0.5, f64::from(*z) + 0.5);
let d = (px - rtp.centre.0).hypot(pz - rtp.centre.1);
let at = |i: &u16, v: &[bool]| v.get(usize::from(*i)).copied().unwrap_or(false);
(min..=max).contains(&d)
&& inside(rtp.border, px, pz)
&& at(g, &ok)
&& at(f, &room)
&& at(h, &room)
})
.collect();
if safe.is_empty() {
return None;
}
let &(x, z, y, _) = safe[(next(&mut self.rng) % safe.len() as u64) as usize];
Some(Place {
dimension: OVERWORLD.into(),
x: f64::from(x) + 0.5,
y: f64::from(y),
z: f64::from(z) + 0.5,
})
}
/// Whether an `/rtp` waits for land: the game reads it then
/// (`rtp_look`).
pub fn rtp_searching(&self) -> bool {
self.pending
.iter()
.any(|p| matches!(&p.to, To::Random(r) if r.spot.is_none() && !r.chunks.is_empty()))
}
/// Each `/rtp` still looking reads the chunk it loads: None while it
/// isn't loaded yet; with no safe column, the next chunk is tried.
pub fn rtp_look(&mut self, look: &mut dyn FnMut(u128, (i32, i32)) -> Option<Ground>) {
let mut pending = std::mem::take(&mut self.pending);
for p in &mut pending {
let To::Random(r) = &mut p.to else { continue };
if r.spot.is_some() {
continue;
}
let Some(&c) = r.chunks.first() else { continue };
let Some(ground) = look(p.uuid, c) else {
continue;
};
match self.pick(r, &ground) {
Some(spot) => r.spot = Some(spot),
None => {
r.chunks.remove(0);
}
}
}
self.pending = pending;
}
/// The chunks `/rtp` wants loaded now: each search's current one, and
/// those just landed in.
pub fn rtp_wanted(&mut self, now: i64) -> BTreeSet<(i32, i32)> {
self.release.retain(|&(_, until)| until > now);
let mut wanted: BTreeSet<(i32, i32)> = self.release.iter().map(|&(c, _)| c).collect();
for p in &self.pending {
if let To::Random(r) = &p.to
&& let Some(&c) = r.chunks.first()
{
wanted.insert(c);
}
}
wanted
}
/// Holds the chunks `/rtp` wants with vanilla's `forceload` (run by
/// `run`, true when the game did it) and lets go of the others it holds.
/// A chunk forced already by someone else is used, never let go.
pub fn force(&mut self, now: i64, run: &mut dyn FnMut(&str) -> bool) {
let wanted = self.rtp_wanted(now);
let line = |verb: &str, (x, z): (i32, i32)| {
format!(
"execute in {OVERWORLD} run forceload {verb} {} {}",
x * 16,
z * 16
)
};
for &c in &wanted {
if self.data.forced.contains(&c) || self.others.contains(&c) {
continue;
}
if run(&line("add", c)) {
self.data.forced.insert(c);
self.changed = true;
} else {
self.others.insert(c);
}
}
let gone: Vec<(i32, i32)> = self.data.forced.difference(&wanted).copied().collect();
for c in gone {
run(&line("remove", c));
self.data.forced.remove(&c);
self.changed = true;
}
self.others.retain(|c| wanted.contains(c));
}
/// The places' commands; None for another command. `bodies` are the
/// players on, `me` among them.
pub fn command(
@@ -353,6 +693,7 @@ impl Places {
))],
None => vec![Out::Error(format!("{name} isn't on."))],
},
("rtp" | "wild", []) => self.rtp(body, now),
("rules", []) => {
let mut out = vec![Out::Reply("Rules of this server:".into())];
for (i, r) in self.config.rules.iter().enumerate() {
@@ -537,6 +878,7 @@ impl Places {
"warp" | "setwarp" | "delwarp" => "Usage: /warp <name>; /warps",
"tpa" => "Usage: /tpa <player>",
"tpaccept" | "tpdeny" => "Usage: /tpaccept [player], /tpdeny [player]",
"rtp" | "wild" => "Usage: /rtp",
_ => "You may not use this command.",
}
.into(),
@@ -581,9 +923,46 @@ impl Places {
));
} else if now < p.due {
self.pending.push(p);
} else if let To::Random(r) = &p.to
&& r.spot.is_none()
{
// `/rtp` after the warmup, its land not found yet.
let search = self.config.rtp_search_seconds.max(0) * 1000;
if r.chunks.is_empty() {
out.push(Out::Tell(
p.uuid,
"No safe spot found for /rtp: try again.".into(),
));
} else if now - p.due > search {
out.push(Out::Tell(
p.uuid,
"/rtp couldn't load the land in time: try again.".into(),
));
} else {
let mut p = p;
if let To::Random(r) = &mut p.to
&& !r.told
{
r.told = true;
out.push(Out::Tell(
p.uuid,
"Looking for a safe spot: don't move.".into(),
));
}
self.pending.push(p);
}
} else {
let mut spread = 0;
let mut rtp = None;
let (to, what) = match &p.to {
To::Random(r) => {
let spot = r.spot.clone();
if let (Some(s), Some(&c)) = (&spot, r.chunks.first()) {
let d = (s.x - r.centre.0).hypot(s.z - r.centre.1).round();
rtp = Some((c, d as i64));
}
(spot, "a random spot".to_owned())
}
To::Place(place, what) => (Some(place.clone()), what.clone()),
To::Spawn(place) => {
spread = self.config.spawn_radius.max(0);
@@ -596,15 +975,31 @@ impl Places {
};
match to {
Some(to) => {
let undo = (
self.last.insert(p.uuid, now),
self.back.insert(p.uuid, (p.from.clone(), false)),
);
let mut done = format!("Teleported to {what}.");
let mut undo_rtp = None;
if let Some((chunk, d)) = rtp {
undo_rtp =
Some(self.data.rtp.insert(smp_store::uuid_string(p.uuid), now));
self.changed = true;
// Held a little longer: the player's own view takes it over.
self.release.push((chunk, now + 10_000));
done = format!(
"Teleported to a random spot {d} blocks from the spawn ({:.0} {:.0} {:.0}).",
to.x.floor(),
to.y,
to.z.floor()
);
}
let undo = Undo {
last: self.last.insert(p.uuid, now),
back: self.back.insert(p.uuid, (p.from.clone(), false)),
rtp: undo_rtp,
};
go.push(Teleport {
uuid: p.uuid,
name: me.name.clone(),
to,
done: format!("Teleported to {what}."),
done,
what,
spread,
undo,
@@ -899,4 +1294,400 @@ mod tests {
assert_eq!(go[0].spread_line(), None);
let _ = std::fs::remove_dir_all(d);
}
/// A chunk of the same column everywhere: `ground`, then `feet` and
/// `head`, the ground's top at y 64.
fn chunk(c: (i32, i32), ground: &str, feet: &str, head: &str) -> Ground {
let palette = vec![ground.to_owned(), feet.to_owned(), head.to_owned()];
let mut columns = Vec::new();
for dx in 0..16 {
for dz in 0..16 {
columns.push((c.0 * 16 + dx, c.1 * 16 + dz, 65, [0, 1, 2]));
}
}
Ground { palette, columns }
}
fn grass(c: (i32, i32)) -> Option<Ground> {
Some(chunk(
c,
"minecraft:grass_block",
"minecraft:air",
"minecraft:short_grass",
))
}
fn distance(t: &Teleport) -> f64 {
t.to.x.hypot(t.to.z)
}
/// An `/rtp` from `here` through its warmup to the teleport, every
/// chunk read as `land`; the time it ended.
fn rtp_to(
p: &mut Places,
here: &[Body],
now: i64,
land: &mut dyn FnMut(u128, (i32, i32)) -> Option<Ground>,
) -> (Vec<Out>, Vec<Teleport>) {
let out = p.command(&player(A, 0), here, now, "rtp").unwrap();
assert_eq!(
out,
vec![Out::Reply("Teleporting in 3 s: don't move.".into())]
);
for _ in 0..8 {
p.rtp_look(land);
}
p.tick(here, now + 3_000)
}
#[test]
fn rtp_points_cover_the_ring_evenly() {
let mut rng = 7;
let n = 20_000;
let mut inner = 0;
for _ in 0..n {
let (x, z) = ring_point(
(100.0, -50.0),
500.0,
5000.0,
unit(&mut rng),
unit(&mut rng),
);
let d = (x - 100.0).hypot(z + 50.0);
assert!((500.0 - 1e-6..=5000.0 + 1e-6).contains(&d), "{d}");
// Half the ring's area is within sqrt((500² + 5000²) / 2) blocks.
inner += usize::from(d < ((500f64.powi(2) + 5000f64.powi(2)) / 2.0).sqrt());
}
let share = inner as f64 / n as f64;
assert!(
(0.48..0.52).contains(&share),
"uniform over the area, not the radius: {share}"
);
// The ring's edges themselves.
assert_eq!(
ring_point((0.0, 0.0), 500.0, 5000.0, 0.0, 0.0),
(500.0, 0.0)
);
assert_eq!(
ring_point((0.0, 0.0), 500.0, 5000.0, 1.0, 0.0),
(5000.0, 0.0)
);
}
#[test]
fn rtp_lands_in_the_ring_inside_the_border() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
p.rng = 42;
p.rng = 1;
// A border 2,000 blocks wide round the spawn: every chunk tried and
// every spot is inside it, and 500 blocks out at least.
p.border = Some([-1000.0, -1000.0, 1000.0, 1000.0]);
for i in 0..50 {
let now = i * 10_000_000;
let here = [body(A, 0.0)];
let mut asked = Vec::new();
let (_, go) = rtp_to(&mut p, &here, now, &mut |_, c| {
asked.push(c);
grass(c)
});
assert_eq!(go.len(), 1);
let t = &go[0];
assert!((500.0..=5000.0).contains(&distance(t)), "{:?}", t.to);
assert!(t.to.x.abs() <= 984.0 && t.to.z.abs() <= 984.0, "{:?}", t.to);
assert_eq!((t.to.dimension.as_str(), t.to.y), (OVERWORLD, 65.0));
assert_eq!(t.spread_line(), None, "the spot is exact");
assert!(
t.done.starts_with("Teleported to a random spot "),
"{}",
t.done
);
for (x, z) in asked {
assert!((x * 16).abs() <= 1000 && (z * 16).abs() <= 1000, "{x} {z}");
}
}
// A border closer than the ring: nothing to try.
p.border = Some([-400.0, -400.0, 400.0, 400.0]);
let out = p
.command(&player(A, 0), &[body(A, 0.0)], 1_000_000_000, "rtp")
.unwrap();
assert!(
matches!(&out[0], Out::Error(s) if s.contains("world border")),
"{out:?}"
);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn rtp_has_its_own_cooldown_kept_over_a_restart() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
p.rng = 42;
let here = [body(A, 0.0)];
let (_, go) = rtp_to(&mut p, &here, 0, &mut |_, c| grass(c));
assert_eq!(go.len(), 1);
// Other teleports wait only their own short cooldown.
p.command(&player(A, 3), &here, 20_000, "sethome").unwrap();
let out = p.command(&player(A, 0), &here, 20_000, "home").unwrap();
assert!(
matches!(&out[0], Out::Reply(s) if s.starts_with("Teleporting")),
"{out:?}"
);
p.tick(&[body(A, 1.0)], 20_500);
let out = p
.command(&player(A, 0), &here, 18 * 60_000 + 3_000, "rtp")
.unwrap();
assert_eq!(
out,
vec![Out::Error("Wait 12 min before your next /rtp.".into())]
);
let out = p
.command(&player(A, 0), &here, 1_800_000 + 2_500, "wild")
.unwrap();
assert_eq!(
out,
vec![Out::Error("Wait 1 s before your next /rtp.".into())]
);
// Written, and read back after a restart.
p.save(0).unwrap();
let mut again = Places::open(&d, Config::default()).unwrap();
again.rng = 43;
let out = again.command(&player(A, 0), &here, 60_000, "rtp").unwrap();
assert_eq!(
out,
vec![Out::Error("Wait 30 min before your next /rtp.".into())]
);
let out = again
.command(&player(A, 0), &here, 1_803_000, "rtp")
.unwrap();
assert!(
matches!(&out[0], Out::Reply(_)),
"allowed after it: {out:?}"
);
// Someone else isn't held up by it.
let out = again
.command(&player(B, 0), &[body(B, 0.0)], 60_000, "rtp")
.unwrap();
assert!(matches!(&out[0], Out::Reply(_)), "{out:?}");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn rtp_without_a_spot_or_called_off_costs_no_cooldown() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
p.rng = 42;
let here = [body(A, 0.0)];
// Ocean everywhere: every chunk tried, one after another, then it says so.
let mut asked = Vec::new();
let (out, go) = rtp_to(&mut p, &here, 0, &mut |_, c| {
asked.push(c);
Some(chunk(
c,
"minecraft:water",
"minecraft:air",
"minecraft:air",
))
});
assert!(go.is_empty());
assert_eq!(
out,
vec![Out::Tell(
A,
"No safe spot found for /rtp: try again.".into()
)]
);
assert_eq!(asked.len(), 6, "rtp_attempts chunks: {asked:?}");
// Land that never loads: given up after rtp_search_seconds.
let (out, go) = rtp_to(&mut p, &here, 10_000, &mut |_, _| None);
assert!(go.is_empty());
assert_eq!(
out,
vec![Out::Tell(A, "Looking for a safe spot: don't move.".into())]
);
let (out, _) = p.tick(&here, 10_000 + 3_000 + 30_000);
assert!(out.is_empty(), "{out:?}");
let (out, _) = p.tick(&here, 10_000 + 3_000 + 30_001);
assert_eq!(
out,
vec![Out::Tell(
A,
"/rtp couldn't load the land in time: try again.".into()
)]
);
// The game couldn't make the teleport: no cooldown either.
let (_, go) = rtp_to(&mut p, &here, 50_000, &mut |_, c| grass(c));
p.call_off(&go[0]);
let (_, go) = rtp_to(&mut p, &here, 60_000, &mut |_, c| grass(c));
assert_eq!(go.len(), 1, "allowed at once");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn rtp_works_only_in_the_overworld() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
p.rng = 42;
for level in ["minecraft:the_nether", "minecraft:the_end"] {
let mut b = body(A, 0.0);
b.at.dimension = level.into();
let out = p.command(&player(A, 0), &[b], 0, "rtp").unwrap();
assert_eq!(
out,
vec![Out::Error("/rtp only works in the Overworld.".into())]
);
}
assert!(p.pending.is_empty() && p.data.rtp.is_empty());
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn rtp_lands_only_on_safe_ground() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
p.rng = 42;
let here = [body(A, 0.0)];
// Each chunk tried has one kind of ground, the last a safe one.
let bad = [
("minecraft:lava", "minecraft:air", "minecraft:air"),
("minecraft:cactus", "minecraft:air", "minecraft:air"),
("minecraft:magma_block", "minecraft:air", "minecraft:air"),
("minecraft:powder_snow", "minecraft:air", "minecraft:air"),
("minecraft:stone", "minecraft:fire", "minecraft:air"),
("minecraft:stone", "minecraft:air", "minecraft:oak_log"),
];
let mut n = 0;
let (out, go) = rtp_to(&mut p, &here, 0, &mut |_, c| {
n += 1;
Some(match bad.get(n - 1) {
Some((g, f, h)) => chunk(c, g, f, h),
None => chunk(c, "minecraft:sand", "minecraft:air", "minecraft:air"),
})
});
assert_eq!(n, 6);
assert!(
go.is_empty(),
"the six chunks were all unsafe: {go:?} {out:?}"
);
// One safe column among lava: that one.
let (_, go) = rtp_to(&mut p, &here, 100_000, &mut |_, c| {
let mut g = chunk(c, "minecraft:lava", "minecraft:air", "minecraft:air");
g.palette.push("minecraft:dirt".into());
g.columns[37].3[0] = 3;
Some(g)
});
let (x, z) = (go[0].to.x - 0.5, go[0].to.z - 0.5);
assert_eq!(x.rem_euclid(16.0), 2.0);
assert_eq!(z.rem_euclid(16.0), 5.0);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn rtp_holds_its_chunks_loaded_and_lets_them_go() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
p.rng = 42;
let here = [body(A, 0.0)];
p.command(&player(A, 0), &here, 0, "rtp").unwrap();
let mut ran = Vec::new();
p.force(0, &mut |l| {
ran.push(l.to_owned());
true
});
assert_eq!(ran.len(), 1, "one chunk at a time: {ran:?}");
let first = ran[0].clone();
assert!(
first.starts_with("execute in minecraft:overworld run forceload add "),
"{first}"
);
// The chunk isn't loaded yet: nothing more.
p.rtp_look(&mut |_, _| None);
ran.clear();
p.force(100, &mut |l| {
ran.push(l.to_owned());
true
});
assert!(ran.is_empty(), "{ran:?}");
// Ocean: the next one is held, the first let go.
p.rtp_look(&mut |_, c| {
Some(chunk(
c,
"minecraft:water",
"minecraft:air",
"minecraft:air",
))
});
p.force(200, &mut |l| {
ran.push(l.to_owned());
true
});
assert_eq!(ran.len(), 2, "{ran:?}");
assert!(ran[0].contains(" add "), "{ran:?}");
assert_eq!(ran[1], first.replace(" add ", " remove "));
let second = ran[0].replace(" add ", " remove ");
// Held over a restart: let go at the next start.
p.save(0).unwrap();
let mut again = Places::open(&d, Config::default()).unwrap();
again.rng = 43;
ran.clear();
again.force(0, &mut |l| {
ran.push(l.to_owned());
true
});
assert_eq!(ran, vec![second], "the one held when it stopped");
// Landed: held 10 s more, then let go.
p.rtp_look(&mut |_, c| grass(c));
let (_, go) = p.tick(&here, 3_000);
assert_eq!(go.len(), 1);
ran.clear();
p.force(12_000, &mut |l| {
ran.push(l.to_owned());
true
});
assert!(ran.is_empty(), "{ran:?}");
p.force(13_000, &mut |l| {
ran.push(l.to_owned());
true
});
assert_eq!(ran.len(), 1);
assert!(ran[0].contains(" remove "), "{ran:?}");
// A chunk someone forced already: used, never let go.
p.command(&player(A, 0), &here, 4_000_000, "rtp").unwrap();
ran.clear();
p.force(4_000_000, &mut |l| {
ran.push(l.to_owned());
false
});
assert_eq!(ran.len(), 1);
p.rtp_look(&mut |_, c| grass(c));
p.tick(&here, 4_003_000);
ran.clear();
p.force(5_000_000, &mut |l| {
ran.push(l.to_owned());
true
});
assert!(ran.is_empty(), "{ran:?}");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn a_move_calls_an_rtp_off_while_it_looks() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
p.rng = 42;
let here = [body(A, 0.0)];
p.command(&player(A, 0), &here, 0, "rtp").unwrap();
let (out, _) = p.tick(&here, 3_000);
assert_eq!(
out,
vec![Out::Tell(A, "Looking for a safe spot: don't move.".into())]
);
let (out, go) = p.tick(&[body(A, 2.0)], 4_000);
assert!(go.is_empty());
assert_eq!(
out,
vec![Out::Tell(A, "Teleport called off: you moved.".into())]
);
assert!(p.rtp_wanted(4_000).is_empty(), "its chunk is let go");
let out = p.command(&player(A, 0), &here, 5_000, "rtp").unwrap();
assert!(matches!(&out[0], Out::Reply(_)), "no cooldown: {out:?}");
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]);
+1 -1
View File
@@ -447,7 +447,7 @@ mod tests {
assert!(recipes.len() > 1000, "{} recipes", recipes.len());
let config = Config::default();
assert_eq!(loops(&config.items, &recipes), []);
// Nor once prices have moved as far as they may (0.6 to 1.6 times
// Nor once prices have moved as far as they may (0.2 to 1.6 times
// their base): an item sold at the top while what makes it is
// bought at the bottom, unless both share one price (a family).
let families = super::super::smp_market::families(&config.items);
+2 -5
View File
@@ -2,8 +2,8 @@
// categories; a category's page its items, with what the market asks and
// pays (in its name). A click buys one, a shift-click a stack of 64; a click on a stack in
// your own inventory sells it; "Sell all" sells everything the market buys.
// Buying and selling go through the market's own rules (its prices, today's
// limits, items first then money, logged). This file only lays the pages out
// Buying and selling go through the market's own rules (its prices,
// items first then money, logged). This file only lays the pages out
// and says what a click means; the glue opens the menu and moves the items.
// No game types. Added to the game as `game::smp_shop`; also a module of this
// crate, for its tests.
@@ -26,7 +26,6 @@ const HELP: usize = 46;
const PREV: usize = 47;
const BALANCE: usize = 48;
const SELL_ALL: usize = 49;
const LIMITS: usize = 50;
const NEXT: usize = 51;
const CLOSE: usize = 53;
/// Where the first page puts the categories: the middle of rows 2 to 4.
@@ -183,7 +182,6 @@ pub enum Cell {
Next,
Balance,
SellAll,
Limits,
Help,
Close,
}
@@ -254,7 +252,6 @@ impl Shop {
cells[HELP] = Cell::Help;
cells[BALANCE] = Cell::Balance;
cells[SELL_ALL] = Cell::SellAll;
cells[LIMITS] = Cell::Limits;
cells[CLOSE] = Cell::Close;
match page {
Page::Main => {
+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" | "rtp" | "wild" => 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);
}
}
+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);
}
}
+288 -37
View File
@@ -19,18 +19,26 @@ Steve, `horizon-ctl --offscreen`) join it. Checked from the clients' own chat an
the pot;
- after the server stops cleanly and starts again, the balances are the same, and audit.log holds every change;
- 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 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
a shift-click, a stack of the player's own sold by clicking it, "Sell all", the close button;
- after the restart, prices moving: a purchase and a sale with no tax (receipts), bought items can be sold
back immediately, 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;
- a farm's dump with no daily cap: 1,000 wheat (more than the old 640 a day) sold over four minutes, each minute's
sale stopped by the 5% band, the price lower after each, and /worth showing no daily limit;
- 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.
it off;
- /rtp: refused in the Nether; in the Overworld the player lands 500 to 5,000 blocks from the spawn on solid ground
(and stays there), a second /rtp is refused with the time left, and the chunk it held loaded is let go.
`SMP_PROOF=rtp` runs one /rtp alone on a normal (not flat) world's fresh land instead, and what it cost the server's
ticks: a performance report (`horizon-report`) over the search and landing against one over the same time before it.
The casino has its own proof (proof_casino.py): `SMP_PROOF=casino` runs it instead of this one.
@@ -98,10 +106,10 @@ def scratch_env(root):
class Server:
"""`horizon-client --dedicated --nogui` in a scratch server folder, its console on stdin."""
def __init__(self, game, resources, folder, port):
def __init__(self, game, resources, folder, port, properties=None):
self.folder, self.lines, self.game, self.resources, self.port = folder, [], game, resources, port
folder.mkdir(parents=True, exist_ok=True)
props = dict(PROPERTIES, **{"server-port": str(port)})
props = dict(PROPERTIES, **(properties or {}), **{"server-port": str(port)})
(folder / "server.properties").write_text("".join(f"{k}={v}\n" for k, v in props.items()))
(folder / "eula.txt").write_text("eula=true\n")
self.env = scratch_env(folder.parent / "server-env")
@@ -340,6 +348,135 @@ def places(server, alex, steve):
return r
def landed(c, text, timeout=120):
"""Waits for a chat line with `text` (an /rtp's end), up to `timeout` s; the line."""
end = time.time() + timeout
while time.time() < end:
hit = [l for l in c.chat()[-6:] if text in l or "/rtp couldn't" in l or "No safe spot" in l]
if hit:
return hit[-1]
time.sleep(1)
raise AssertionError(f"{c.name}: no {text!r} in {timeout} s: {c.chat()[-8:]}")
FLAT_GROUND = "if block ~ ~-1 ~ minecraft:grass_block if block ~ ~ ~ minecraft:air if block ~ ~1 ~ minecraft:air"
SOLID_GROUND = ("unless block ~ ~-1 ~ minecraft:air unless block ~ ~-1 ~ minecraft:water "
"unless block ~ ~-1 ~ minecraft:lava")
def standing(server, c, at, tests=FLAT_GROUND):
"""Whether the blocks round `at` pass `tests` (execute's conditions), as the server sees them: it says so."""
x, y, z = (math.floor(v) for v in at)
before = len(server.lines)
server.console(f"execute in minecraft:overworld positioned {x} {y} {z} {tests} run say rtp-ground-ok")
time.sleep(3)
seen = server.lines[before:] + c.chat()[-4:]
return any("rtp-ground-ok" in l for l in seen), seen
def rtp(server, steve, data):
"""/rtp: refused in the Nether; to a random spot 500..5000 blocks from the spawn, on the ground; then its cooldown."""
r = {}
spawn = json.loads((data / "places.json").read_text())["spawn"]
_, home, _ = where(steve)
server.console("execute in minecraft:the_nether run tp Steve 0.5 128 0.5")
time.sleep(5)
r["nether"] = steve.say("/rtp")
assert any("/rtp only works in the Overworld." in l for l in r["nether"]), r
server.console(f"execute in minecraft:overworld run tp Steve {home[0]:.2f} {home[1]:.2f} {home[2]:.2f}")
time.sleep(5)
start = len(server.lines)
r["asked"] = steve.say("/wild")
assert any("Teleporting in 2 s: don't move." in l for l in r["asked"]), r
r["landed"] = landed(steve, "Teleported to a random spot")
assert "Teleported to a random spot" in r["landed"], r
time.sleep(3)
dim, at, health = where(steve)
r["at"], r["distance"] = at, math.hypot(at[0] - spawn["x"], at[2] - spawn["z"])
assert dim == "minecraft:overworld" and 499 <= r["distance"] <= 5001, r
# On the ground, not falling: the same place a few seconds later, unhurt.
time.sleep(4)
_, still, health2 = where(steve)
assert near(at, still) and health2 >= health, (r, still, health2)
r["ground"], lines = standing(server, steve, at)
assert r["ground"], (r, lines)
log = [l for l in server.lines[start:] if "[smp] /rtp: Steve" in l]
assert log, server.lines[start:][-20:]
r["log"] = log[-1]
r["slow_ticks"] = [l for l in server.lines[start:] if "slow tick" in l or "Can't keep up" in l]
r["again"] = steve.say("/rtp")
assert any("Wait 30 min before your next /rtp." in l for l in r["again"]), r
# The chunk it held loaded is let go 10 s after the landing.
time.sleep(12)
saved = json.loads((data / "places.json").read_text())
assert not saved.get("forced") and len(saved.get("rtp", {})) == 1, saved
server.console(f"execute in minecraft:overworld run tp Steve {home[0]:.2f} {home[1]:.2f} {home[2]:.2f}")
time.sleep(3)
return r
def report(server, seconds):
"""A performance report over the next `seconds`: its ticks (`mspt`, `over_50_ms`...)."""
folder = server.folder / "reports"
before = set(folder.glob("perf-*.json")) if folder.exists() else set()
server.console(f"horizon-report {seconds}")
end = time.time() + seconds + 60
while time.time() < end:
made = set(folder.glob("perf-*.json")) - before if folder.exists() else set()
if made:
time.sleep(1)
return json.loads(made.pop().read_text())["ticks"]
time.sleep(1)
raise AssertionError(f"no report after {seconds} s: {server.lines[-20:]}")
def rtp_alone(ctl, resources, out):
"""SMP_PROOF=rtp: one /rtp on a normal world's fresh land (view distance 8, as the official server), with the
ticks' cost: a 30 s report from the /rtp on, against a 30 s report just before it."""
out = Path(out)
out.mkdir(parents=True, exist_ok=True)
game = Path(ctl).with_name("horizon-client")
root = Path(tempfile.mkdtemp(prefix="smp-rtp-"))
port = free_port()
server = Server(game, resources, root / "server", port, {"level-type": "minecraft\\:normal", "view-distance": "8",
"generate-structures": "true"})
server.env["HORIZON_SPIKES"] = "50"
steve = Client(ctl, resources, "Steve", root)
r = {}
try:
server.start()
steve.join(port)
# The spawn's land settles first.
time.sleep(30)
r["before"] = report(server, 30)
start = len(server.lines)
r["asked"] = steve.say("/rtp")
assert any("Teleporting in 3 s: don't move." in l for l in r["asked"]), r
server.console("horizon-report 30")
r["landed"] = landed(steve, "Teleported to a random spot", 90)
assert "Teleported to a random spot" in r["landed"], r
folder = server.folder / "reports"
end = time.time() + 120
while len(list(folder.glob("perf-*.json"))) < 2 and time.time() < end:
time.sleep(1)
r["during"] = json.loads(max(folder.glob("perf-*.json"), key=lambda f: f.stat().st_mtime).read_text())["ticks"]
time.sleep(5)
dim, at, _ = where(steve)
r["at"], r["distance"] = at, math.hypot(at[0], at[2])
assert dim == "minecraft:overworld" and 499 <= r["distance"] <= 5001, r
r["ground"], _ = standing(server, steve, at, SOLID_GROUND)
r["log"] = [l for l in server.lines[start:] if "[smp] /rtp: Steve" in l]
r["slow_ticks"] = [l for l in server.lines[start:] if "slow tick" in l or "Can't keep up" in l]
print(json.dumps({"proof": "rtp", "ok": True, **r}, indent=2))
finally:
(out / "server.log").write_text("\n".join(server.lines))
steve.quit()
server.stop()
if server.__dict__.get("process") and server.process.poll() is None:
server.process.kill()
shutil.rmtree(root, ignore_errors=True)
def claims(server, alex, steve):
"""Alex claims a chunk: Steve can't break, use or place there until trusted."""
r = {}
@@ -512,6 +649,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."""
@@ -602,7 +770,7 @@ def nicks(server, alex, steve, out):
def shop(server, alex):
"""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."""
selling a stack of hers, "Sell all" without a 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")
@@ -627,42 +795,40 @@ def shop(server, alex):
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.
# Along the price as the stack moves it (50 a 1%): 64 at 1 coin come to 64.41, 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 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.
# Clear the cobblestone so the later Sell all step only tests diamonds.
server.console("clear Alex minecraft:cobblestone 64")
time.sleep(1)
# The iron she just bought can be sold back immediately.
alex.click(alex.slot_of("minecraft:iron_ingot"))
results["round_trip"] = alex.chat()[-1:]
assert any("You bought 1 iron ingot from the market in the last 24 h" in l for l in results["round_trip"]), results
results["round_trip"] = alex.chat()[-3:]
assert any("Sold 1 iron ingot at 15 coins each: 15 coins." in l for l in results["round_trip"]), results
assert alex.count("minecraft:iron_ingot") == 0
server.console("give Alex minecraft:iron_ingot 1")
time.sleep(2)
alex.click(alex.slot_of("minecraft:iron_ingot"))
results["sold"] = alex.chat()[-4:]
# Sold at the price she bought at: one price.
assert any("Sold 1 iron ingot at 15 coins each: 15 coins." in l for l in results["sold"]), results
assert any("1 iron ingot kept: bought from the market in the last 24 h." in l for l in results["sold"]), results
assert alex.count("minecraft:iron_ingot") == 1
# Sell all, up to the 2,000 coins a day: 1,984 left (1 from Steve's /pay in the nickname step: money received
# counts too; 15 for the iron), 9 diamonds at 200. Each one sold lowers the price: 40,000 * (1 - e^-0.045)
# = 1,760.1 for the 9.
server.console("give Alex minecraft:diamond 30")
assert alex.count("minecraft:iron_ingot") == 0
# Sell all nine diamonds, with no daily cap. Along the curve they earn
# 40,000 * (1 - e^-0.045) = 1,760.1, rounded down to 1,760 coins.
server.console("give Alex minecraft:diamond 9")
time.sleep(2)
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 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]
results["limits"] = limits
# 1,984 - 1,760.
assert "224 coins left of your 2,000 coins a day" in json.dumps(limits), limits
assert alex.count("minecraft:diamond") == 0, alex.run("inventory")
assert alex.menu()[50]["item"] == "minecraft:gray_stained_glass_pane", alex.menu()
alex.click(300 + 53) # close
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
return 2000 + 10 - 15 - 65 + 15 + 1760, results
assert any("diamond:" in l for l in results["worth"]), results
return 2000 + 10 - 15 - 65 + 15 + 15 + 1760, results
def invsee(alex, data):
@@ -748,6 +914,47 @@ def market(server, alex, out, data):
return paid, results
def farm(server, alex):
"""No daily sale cap: Alex sells 1,000 wheat, more than the old 640 a day, a minute at a time. Each minute's
sale stops at the 5% band (250 wheat at 50 a 1%), and the price is lower after each. Returns the coins and lines."""
results = {}
def worth():
lines = alex.say("/worth wheat")
line = next(l for l in lines if "wheat: " in l and "each, to buy or sell." in l)
return float(re.search(r"wheat: ([\d.,]+) coins? each", line)[1].replace(",", "")), lines
server.console("give Alex minecraft:wheat 1000")
time.sleep(2)
assert alex.count("minecraft:wheat") == 1000, alex.run("inventory")
price, lines = worth()
results["worth_before"] = lines
assert not any("today" in l for l in lines), lines
got, prices, sales = 0, [price], []
for minute in range(4):
if minute:
time.sleep(61) # the next minute's band
lines = alex.say("/sell wheat")
sold = [re.search(r"Sold (\d+) wheat at [\d.,]+ coins? each: ([\d,]+) coins?\.", l) for l in lines]
sold = [m for m in sold if m]
assert len(sold) == 1, lines
n, coins = int(sold[0][1]), int(sold[0][2].replace(",", ""))
sales.append(n)
got += coins
# 250 a minute; what the band keeps stays in her inventory.
assert n == 250, lines
assert minute == 3 or any("kept: its price moved 5% this minute" in l for l in lines), lines
assert not any("limit" in l for l in lines), lines
price, wl = worth()
assert price < prices[-1], (prices, wl)
prices.append(price)
assert alex.count("minecraft:wheat") == 0, alex.run("inventory")
results["sales"], results["prices"], results["coins"] = sales, prices, got
# Four 5% steps down from 2 (a little drift back between): about 2 * e^-0.2 = 1.64.
assert 1.6 < prices[-1] < 1.7 and 1500 < got < 2000, results
return got, results
def trades(server, alex, steve, port, ctl, resources, root):
"""Every way out of a trade, then one that goes through. Alex: 10 diamonds; Steve: 5 emeralds, 5 torches."""
results = {}
@@ -826,6 +1033,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.
@@ -834,6 +1081,8 @@ def run(ctl, resources, out):
casino = importlib.util.module_from_spec(spec)
spec.loader.exec_module(casino)
return casino.run(ctl, resources, out)
if os.environ.get("SMP_PROOF") == "rtp":
return rtp_alone(ctl, resources, out)
out = Path(out)
out.mkdir(parents=True, exist_ok=True)
game = Path(ctl).with_name("horizon-client")
@@ -897,12 +1146,10 @@ def run(ctl, resources, out):
assert any("You paid 120 coins to Steve. Balance: 380 coins" in l for l in results["pay"]), results
results["paid"] = steve.chat()[-3:]
assert any("Alex paid you 120 coins." in l for l in results["paid"]), results
# 9999 is past what Steve may still receive today (2,000 a day, 120 received): money paid counts toward the
# same daily limit as selling, so alts can't sell for a main past it.
results["refused"] = [alex.say(l) for l in ("/pay Steve 1500", "/pay Steve 9999", "/pay Alex 1",
"/pay Nobody 1")]
for lines, error in zip(results["refused"], ("You only have 380 coins.",
"Steve can only receive 1,880 coins more today",
"You only have 380 coins.",
"You can't pay yourself.",
"No player named Nobody has an account.")):
assert any(error in l for l in lines), results["refused"]
@@ -943,12 +1190,14 @@ def run(ctl, resources, out):
results["trades"], steve = trades(server, alex, steve, port, ctl, resources, root)
# Before the coinflips: their last offer must still wait when the server stops.
results["places"] = places(server, alex, steve)
results["rtp"] = rtp(server, steve, data)
results["claims"] = claims(server, alex, steve)
results["world_guard"] = world_guard(server, alex, steve)
results["board"] = board(alex, steve, out)
results["polish"] = polish(server, alex, steve, out)
results["presence"] = presence(server, alex, steve)
results["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)
@@ -997,7 +1246,10 @@ def run(ctl, resources, out):
assert not any("joined for the first time" in l for l in alex.chat()), alex.chat()
added, results["shop"] = shop(server, alex)
spent, results["market"] = market(server, alex, out, data)
farmed, results["farm"] = farm(server, alex)
added += farmed
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]
@@ -1017,19 +1269,18 @@ def run(ctl, resources, out):
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_")]
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"]
"sell 1 minecraft:iron_ingot", "sell 1 minecraft:iron_ingot", "sell 9 minecraft:diamond", "buy 1 minecraft:diamond",
*["sell 250 minecraft:wheat"] * 4]
assert kinds[-len(shop_kinds):] == shop_kinds, kinds[-8:]
kinds = kinds[:-len(shop_kinds)]
assert kinds[-2] in ("held_returned", "coinflip_cancel") and kinds[-1] == "daily_reward", kinds[-3:]
results["stake_back"] = kinds[-2]
sold = json.loads((data / "market.json").read_text())["sold"]
# Steve's sales of the first run's day, and Alex's; coins received by /pay count too (#139): Steve got 120 + 1
# from Alex, Alex 1 from Steve.
assert sorted((s["coins"], s["items"]) for s in sold.values()) == [
(441, {"minecraft:diamond": 1, "minecraft:wheat": 60}),
(1776, {"minecraft:diamond": 9, "minecraft:iron_ingot": 1})], sold
print(json.dumps({"proof": "smp", "ok": True, **results}, indent=2))
finally:
(out / "server.log").write_text("\n".join(server.lines))
+4
View File
@@ -47,5 +47,9 @@ pub mod smp_report;
pub mod smp_shop;
#[path = "../mixins/smp/smp_store.rs"]
pub mod smp_store;
#[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;