Compare commits

...
Author SHA1 Message Date
Ludovic Marieandrepositorykreml1n 239ce420fa Update basic_hud 0.5.0 (#217)
Submitted by @repositorykreml1n through the form.

Submission: jEsjBlLxuH_fH0gi0d1ZaAgPL2A5TB4NH7laEwo_zpY

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

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

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

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

Submitted by @Domanffe through the form.

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

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

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

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

---------

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

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

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

* customhud: list version 1.6.0 explicitly instead of repo

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

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

---------

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

* Delete mods/mod_menu-0.3.0.hnmod

* Add Horizon Physics 0.12.1 (native mod)

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

* Horizon Physics 0.13.0

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

* Horizon Physics 0.14.0

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

* horizon_physics 0.15.0

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

* horizon_physics 0.15.1

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

---------

Co-authored-by: Claude <[email protected]>
2026-11-03 20:04:20 +01:00
22 changed files with 1510 additions and 171 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+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"
+9 -6
View File
@@ -2,18 +2,21 @@ id = "chatheads"
kind = "native-mod"
name = "ChatHeads"
summary = "A player's head left of every chat line they wrote."
description = """
Each chat line from a player gets their 8x8 skin face in the strip to its left, like the Chat Heads Java mod. Lines without a player (system messages, /me, wrapped continuations) stay as they are; the face fades with its line.
Settings: Options > Mods > ChatHeads > Settings (on or off).
Known limit: the chat's hit-test does not know about the 10 px indent, so clicking a link in chat lands 10 px left of its pixels."""
description = "Each chat line from a player gets their 8x8 skin face in the strip to its left; lines without a player stay as they are, and the face fades with its line. The switch lives in Options > Mods."
authors = ["Domanffe"]
owners = ["Domanffe"]
license = "MIT"
source = "https://github.com/Domanffe/chatheads"
icon = "https://horizon-rust.github.io/horizon-mods/icons/chatheads.png"
[[versions]]
version = "0.1.1"
published = "2026-11-02"
game = "26.3"
file = "chatheads-0.1.1.hnmod"
sha256 = "16f5e1db33255c550c087fcf3ed982a4be9abf61b547b470a08f471d309ad1fe"
changelog = "Rebuilt on the 2026-11-01 Nightly (Horizon `4aa6854`). The three hook points it shares — `mods`, `settings`, `settings_click` — are named now instead of pinned to raw source targets, so a Horizon update that moves them no longer breaks the bundle."
[[versions]]
version = "0.1.0"
published = "2026-10-30"
+28
View File
@@ -0,0 +1,28 @@
id = "customhud"
kind = "native-mod"
name = "Beautiful Day Counter"
summary = "A lightweight day counter, clock and day/night cycle bar for the HUD, with an in-game settings screen."
description = """
Beautiful Day Counter for Horizon, a native mod.
Shows the current survival day, an in-game clock and a color-shifting bar that follows the day/night cycle, without changing gameplay. It is a port of the author's own Fabric/NeoForge mod [CustomHUD](https://github.com/sonoFrangu/CustomHUD), with the same HUD and the same settings screen.
- Day counter with the game mode icon; a golden apple or totem, a rainbow counter and a sound on milestone days (50, 100, 365, every 100).
- 12-hour or 24-hour clock; the clock icon turns into a bed when you can sleep.
- Day/night bar with four color themes (Classic, Pastel, Colorblind, Mono), night marks, countdown to night or day, moon phase.
- Top or bottom placement, size, opacity, offsets, optional background; vanilla boss bars move below the HUD when it is at the top.
- Settings screen opened with H (or from Options > Mods): Elements, Look and Behavior, with tooltips.
- English, Italian, German, Spanish, French and Portuguese."""
authors = ["SCARABOCCHIO100"]
owners = ["sonoFrangu"]
license = "MIT"
source = "https://github.com/sonoFrangu/CustomHUD-Horizon"
icon = "icons/customhud.png"
[[versions]]
version = "1.6.0"
published = "2026-10-31"
game = "26.3"
file = "customhud-1.6.0.hnmod"
sha256 = "ab255d3c722b40f4d068a762857d6c027c11381308bfe23196e2974307814a7f"
changelog = "First release for Horizon: the CustomHUD 1.6.0 HUD and settings screen as a native mod."
+31
View File
@@ -0,0 +1,31 @@
id = "horizon_physics"
kind = "native-mod"
name = "Horizon Physics"
summary = "Physics for blocks, mobs, items, snow, water, smoke, capes and more, with its own settings screen."
description = """
By tariksabanuz. Physics for Horizon in the spirit of PhysicsMod: broken blocks fall apart into pieces, mobs die as
ragdolls, snow gives way under your feet, water waves, smoke rises and plants sway in the wind.
- Broken blocks crumble into pieces (blocky, particles, crumbled voxel or fractured). Mobs die as ragdolls or come apart.
- Structures and trees fall when what holds them up is broken. Dropped items tumble and settle.
- Snow layers are pressed down by you and the mobs, water has waves that boats (and you in them) ride, and leaves, plants and vines sway.
- Hit a ragdoll or a piece and it flies off; grass and flowers bend away as you walk through them.
- Fire, campfires and lava give off smoke and embers that gather and darken under ceilings, capes and banners are cloth, doors swing open and shut, leads and fishing lines are ropes.
- Rain splashes and rings the water, snow falls, and the wind blows harder with the weather.
- A Physics Settings button in Options opens a screen with a page for every part, and a Quick Setup the first time you start.
Needs the Performance build in the launcher. Inspired by PhysicsMod by Haubna; this is a clean-room version for
Horizon and contains none of its code or assets. It uses your own game's textures at runtime, and the icon is my own."""
authors = ["tariksabanuz"]
owners = ["tariksabanuz"]
license = "MIT"
source = "https://github.com/tariksabanuz/PhysicsModHorizon"
icon = "icons/horizon_physics.png"
[[versions]]
version = "0.15.1"
published = "2026-11-03"
game = "26.3"
file = "horizon_physics-0.15.1.hnmod"
sha256 = "d7cf1201f0a47a852aa1a4c9684ef9e658ec3c86852104d44716d05f11281ca3"
changelog = "First release: block, mob, item, snow, water, smoke, cloth, weather and collapse physics, with a Physics Settings screen."
+34
View File
@@ -0,0 +1,34 @@
id = "killc"
kind = "native-mod"
owners = ["Vakarux12"]
key = "RWS5u/7lEyKbzVHC6uMJRlhM/N7vzKp5fZe2LCDUlHsDK4lvPupxJbqQ"
name = "KillC"
summary = "/killc, /killc <player> and /suicide, with an optional random respawn near (0, 0)."
description = """
A Horizon port of [adasjusk's KillC](https://github.com/adasjusk/KillC), the kill command plugin for Paper, Folia, Velocity and Fabric.
- `/killc` and `/suicide` kill you.
- `/killc <player>` kills another player (operator level 2).
- After a death in the Overworld, players respawn at a random surface spot within `randomSpawnRadius` of (0, 0).
- Config: `config/killc.json` (`enabled`, `useRandomSpawn`, `randomSpawnRadius`), the same file as the Fabric build.
Vanilla keeps `/kill`. Runs where the world is hosted: single player and worlds opened to LAN (Horizon's dedicated server cannot build native mods yet). Native mods run with the player's account permissions and are not sandboxed.
Source: `horizon/` in https://github.com/Vakarux12/KillC, a fork of the original. CC-BY-SA-4.0, the original's licence, with a grant for Horizon's reviewers in `horizon/REVIEW-GRANT.md`.
"""
authors = ["adasjusk", "Vakarux12"]
license = "CC-BY-SA-4.0"
source = "https://github.com/Vakarux12/KillC/tree/main/horizon"
[[versions]]
version = "5.1.0"
published = "2026-11-01"
game = "26.3"
file = "killc-5.1.0.hnmod"
sha256 = "6c44d0a5e93aef42f2a33df8286980d9957a04526ff2c1884dfe7aa83f837086"
signature = """
untrusted comment: signature from minisign secret key
RUS5u/7lEyKbzbHxeuREFeXgBYM6FjB/wiwBtmdu5na35k0DXINdt3FILWvtW7yDekrFYjn4Xrl5pv9ttijPbocgpNbqZpPqLgY=
trusted comment: timestamp:1793694719 file:killc-5.1.0.hnmod hashed
vC2AGwA5JYAw1yjFLScjYyy+ef03gA5lxE3mwpD/D6CqKRblrpIKtHacgjYViC714ob5w1fgXFPya8AobYmVBg=="""
changelog = "First Horizon release, ported from adasjusk's KillC."
+50
View File
@@ -0,0 +1,50 @@
id = "right_click_crops"
kind = "native-mod"
name = "Right-Click Crops"
summary = "Right-click harvest for 16 plant families, with replanting and individual settings."
description = """
Harvest mature plants with a right-click, replant crops, and keep your garden growing.
### Features
- Supports 16 plant families.
- Replants crops using one planting item from the harvest, or from your inventory.
- Keeps the base of tall plants and the stems of pumpkins and melons.
- Lets you enable or disable each plant separately in **Options > Mods > Right-Click Crops > Settings**.
### Supported plants
Wheat, carrots, potatoes, beetroot, nether wart, cocoa, torchflower, pitcher crop, sugar cane, cactus, bamboo, kelp, twisting vines, weeping vines, pumpkin and melon.
### Good to know
- Harvesting works in survival, one targeted plant at a time.
- Without a suitable planting item, the crop is harvested but not replanted.
- Torchflower needs spare seeds; pitcher crop needs spare pitcher pods.
- Twisting and weeping vines can drop nothing when harvested by hand. Use shears for guaranteed drops.
- Bone meal, ordinary breaking and planting, sweet berries and glow berries keep their normal behaviour.
### Compatibility
Last checked on **November 1, 2026**, for Minecraft 26.3. The release is pinned to Horizon Nightly 2026-10-31 (`9d979ca`), where single-player and LAN were tested. A compilation-only check also passed on the newest Nightly available on that date (`d91cab6`); gameplay on that newer revision was not tested.
This is a native mod: install only if you trust its source and accept the launcher's native-code warning.
[Watch the demo](https://youtu.be/kll-qvj5s54) · [Harvesting guide](https://github.com/AyanoKaito/horizon-right-click-crops/blob/main/docs/harvesting.md)
"""
authors = ["AyanoKaito"]
owners = ["AyanoKaito"]
license = "MIT"
source = "https://github.com/AyanoKaito/horizon-right-click-crops"
icon = "icons/right_click_crops.png"
key = "RWRwbuaByP5mM4VUm4kZEQVPiuw+3IEVJ7CZcEHIjY5fPKf9lhQKUZRy"
[[versions]]
version = "1.0.0"
published = "2026-11-01"
game = "26.3"
file = "right_click_crops-1.0.0.hnmod"
sha256 = "681ce896c50dd041132900e7bbab71167f133b3f3862f86e8b1a0cac52754b97"
signature = """
untrusted comment: signature from minisign secret key
RURwbuaByP5mM4x8HGWHv/S/WlHPsmamRwsi70PTq7E5u4uJ7wyNzNT4rwpR/vGA9V2H6w9yLhmF1CPtS1grEfWorPixnk6efAw=
trusted comment: timestamp:1793523764\tfile:right_click_crops-1.0.0.hnmod\thashed
seiWz0D/X34FIKhiE1yfZc2uAkkSGuwAnTHbGrK63sHg+HdL+EgqEkzaxNgJTiMOdnf/iIdSkDVVwuF+Od/SCQ==
"""
changelog = "First public release: right-click harvesting for 16 plant families, ordinary Horizon loot, paid replanting, retained bases and stems, and individual plant settings. Validated in single-player and for LAN host and guest."
+34
View File
@@ -0,0 +1,34 @@
id = "rmotd"
kind = "native-mod"
owners = ["Vakarux12"]
key = "RWS5u/7lEyKbzVHC6uMJRlhM/N7vzKp5fZe2LCDUlHsDK4lvPupxJbqQ"
name = "RMotd"
summary = "Rotating server list MOTDs, managed in game with /randommotd and /motd."
description = """
A Horizon port of [adasjusk's RMotd](https://github.com/adasjusk/RMotd), the rotating MOTD plugin.
- The MOTD in the server list changes every 10 seconds, through the list in `config/rmotd.json` (the Fabric build's file).
- `/randommotd` turns rotation on and off.
- `/motd create <name> <motd>` adds an MOTD, `/motd reload` reads the file again (operator level 2).
- MOTDs take `&` colour codes.
It sets the MOTD of a world opened to LAN. The server list ping shows the rotation, the LAN broadcast keeps the first one. Horizon has no MOTD hook point, so a support module in `horizon_client::lan` writes the private MOTD field of the LAN server. Its injections are all hook points, but that field is not a stable API, so a later Nightly can break the build. Runs where the world is hosted: single player and worlds opened to LAN (Horizon's dedicated server cannot build native mods yet). Native mods run with the player's account permissions and are not sandboxed.
Source: `horizon/` in https://github.com/Vakarux12/RMotd, a fork of the original. CC-BY-SA-4.0, the original's licence, with a grant for Horizon's reviewers in `horizon/REVIEW-GRANT.md`.
"""
authors = ["adasjusk", "Vakarux12"]
license = "CC-BY-SA-4.0"
source = "https://github.com/Vakarux12/RMotd/tree/main/horizon"
[[versions]]
version = "1.0.0"
published = "2026-11-01"
game = "26.3"
file = "rmotd-1.0.0.hnmod"
sha256 = "68d43b760e95178a67bc1c7d54fbde004eee4bb764d9891d83bd4e4fa4bd8ce5"
signature = """
untrusted comment: signature from minisign secret key
RUS5u/7lEyKbzSnV+LLOgmX/1ryZ/5d3asf4KyfdUUOJKALk9isprehFuFIDN5X+GZy0C7jzHAImFnq9FRt2yc1J3N5lbA1oUgM=
trusted comment: timestamp:1793694734 file:rmotd-1.0.0.hnmod hashed
x+Ecd8KV0OFBcUEONAvAHxw87IG2MnjWZzcrIiMLjRNtl7KcuZQXvakrAm6slC1qADzj+n+HpwYoGT1QmQ07Bg=="""
changelog = "First Horizon release, ported from adasjusk's RMotd."
+35
View File
@@ -0,0 +1,35 @@
id = "stuck"
kind = "native-mod"
owners = ["Vakarux12"]
key = "RWS5u/7lEyKbzVHC6uMJRlhM/N7vzKp5fZe2LCDUlHsDK4lvPupxJbqQ"
name = "Stuck?"
summary = "/stuck and /escape move you to a safe spot nearby, off the Nether roof too."
description = """
A Horizon port of [adasjusk's Stuck?](https://github.com/adasjusk/Stuck), the unstuck plugin for Spigot, Paper, Folia and Fabric.
- `/stuck` and `/escape` search random columns near you for solid ground with two blocks of air above, and teleport you there.
- Skips lava, fire, magma, cactus and other hazards, and leaves and snow.
- A player on the Nether's bedrock roof is brought back under it.
- Cooldown between uses, 30 seconds by default.
- Config: `config/stuck.properties`, the same keys as the Fabric build.
Only loaded chunks are searched. Runs where the world is hosted: single player and worlds opened to LAN (Horizon's dedicated server cannot build native mods yet). Native mods run with the player's account permissions and are not sandboxed.
Source: `horizon/` in https://github.com/Vakarux12/Stuck, a fork of the original. CC-BY-SA-4.0, the original's licence, with a grant for Horizon's reviewers in `horizon/REVIEW-GRANT.md`.
"""
authors = ["adasjusk", "Vakarux12"]
license = "CC-BY-SA-4.0"
source = "https://github.com/Vakarux12/Stuck/tree/main/horizon"
[[versions]]
version = "1.0.0"
published = "2026-11-01"
game = "26.3"
file = "stuck-1.0.0.hnmod"
sha256 = "8e6292d9c89f660e72f01e54be4103b5273017955c7bd8034dd5ae8ec2e98630"
signature = """
untrusted comment: signature from minisign secret key
RUS5u/7lEyKbzf0/fn5Is2b8jJq55073yA8Sl7hSeLN1jAg9itcMlCO2kcEMVJaGyYrxFZIwPYfQJMCb61o/0g8XD8iYIUCeswU=
trusted comment: timestamp:1793694727 file:stuck-1.0.0.hnmod hashed
/JDVcy8eoaPHppahV7p+f+3LTx0CtfE34jqqZq7bU4TNvnSOx7ISCgp6Eg3+X1xYMRJRwTYM2aJwgbytRGzOCA=="""
changelog = "First Horizon release, ported from adasjusk's Stuck."
+34 -9
View File
@@ -9,7 +9,7 @@ lines, vanilla chest menus the server fills with ordinary items (the trade menu)
bar, boss bars and the sidebar).
This version: the economy, the auction house, the server market and its `/shop` menu, player trades, the coinflip, the lottery, places (spawn, homes, warps,
teleport requests, the rules), land claims, and the scoreboard (a sidebar with your balance, playtime and the richest players, the Tab list's
teleport requests, the rules), land claims, and the scoreboard (a sidebar with your balance and playtime, the Tab list's
header and footer).
| Command | Does |
@@ -54,8 +54,10 @@ header and footer).
| `/nick <name>`, `/nick off`, `/nick` | operators of level 3 (with `ops_only`, the default): another name for you in chat and the Tab list, shown as `~name` (hovering it gives your real one); `/nick off` (anyone with a nickname) takes it off; `/nick` alone says how |
| `/nick <player> <name\|off>` | operators of level 3: sets or takes off a player's nickname |
| `/realname <nick>` | who goes by that nickname |
| `/vanish`, `/vanish list` | operators of level 3: hides you from the other players, or shows you again; who is vanished |
| `/invsee <player>`, `/endersee <player>` | operators of level 3: the player's inventory (main, hotbar, armour, off hand) or ender chest in a chest menu, on or off the server; click one of theirs to take it, one of yours to give it; every move in `audit.log`, a summary in `staff.log` |
| `/sidebar off`, `/sidebar on`, `/sidebar` | hides the scoreboard sidebar for you (remembered), shows it again |
| `/smp status`, `/smp off [part\|all] [reason]`, `/smp on [part\|all]` | operators of level 3: the emergency switches (below) |
The market trades only plain items (not worn, named or enchanted), and only with players in survival or adventure
mode. Items move first, then money, both logged; a sale the log refuses gives the items back, and a purchase checks the
@@ -110,6 +112,14 @@ before it is made (`invsee_take`, `invsee_put`: operator, player, inventory, ite
its reason); closing the menu writes one line to the server log (`[smp] staff: ...`), `staff.log`, and the chat of
the other operators on. Needs horizon#6106 (`native_inventory`, `native_move_stack`).
**Vanish.** `/vanish` takes an operator of level 3 out of the Tab list and the world of every player below level 3,
whatever the game mode (survival and creative too), and those players don't read them joining or leaving; operators of
level 3 or more still see them, and are told who vanished. A vanished operator reads a subtitle the whole time
(*Vanished: only operators see you*), isn't counted online (the sidebar, the Tab list's footer), doesn't hold the night up, isn't
announced as AFK or on a first join, and can't be reached by name by those who don't see them (`/tpa`, `/trade`,
`/coinflip`, `/playtime`). It lasts over a relog and a restart (`vanish.json`) until `/vanish` again. Needs
horizon#7003 (`native_hide_player`).
**Nicknames.** With `ops_only` (the default since 0.22) only operators of level 3 set nicknames: their own, or a
player's with `/nick <player> <name>`; a player may take off the one given to them (`/nick off`). At each start, a
nickname a player who isn't an operator picked themselves is taken off and logged (`for operators only`). 3 to 16 letters, digits or `_`, shown with a `~` in front (`~Rocky`) so a nickname never passes for
@@ -230,14 +240,14 @@ items, shearing, leads), which waits for Horizon's hook (#4648). The server's `s
the spawn.
**The scoreboard.** A sidebar on the right, titled `board.title` ("Horizon SMP"): your own block first (your name, or
your nickname; your balance, `125.4k`, `3.2M`; your playtime, `2h 05m`; the players online), then the `board.top` (3)
richest players with their balance, then `board.footer` (`play.playhorizon.dev`, empty for none). The ranking is
built once for everyone, only when a balance changed (at most every `board.min_seconds`, 5) or every
`board.refresh_seconds` (60); each player's own lines are looked at on the same `board.refresh_seconds`, and a
your nickname; your balance, `125.4k`, `3.2M`; your playtime, `2h 05m`; the players online), then `board.footer`
(`play.playhorizon.dev`, empty for none). It lists no ranking: `/baltop` does (`board.top`, of 0.14 to 0.29, is
ignored). The balances are read once for everyone, only when one changed (at most every `board.min_seconds`, 5) or
every `board.refresh_seconds` (60); each player's own lines are looked at on the same `board.refresh_seconds`, and a
sidebar is sent only to the players whose lines changed and to newcomers: nothing runs per tick per player. The Tab
list shows `board.tab_header` and `board.tab_footer` (`{server}`, `{tps}`, `{players}` filled in, every
`board.tab_refresh_seconds`, 5). `board.sidebar` and `board.tab` turn either off; `board.server_name` names the Tab
list. A server that kept 0.13's defaults (`Richest players`, the top 10) gets these at its first start of 0.14. The
list. A server that kept 0.13's title (`Richest players`) gets `Horizon SMP` at its first start. The
sidebar uses one scoreboard objective of its own (`horizon:native_sidebar`) and leaves the world's scoreboard alone.
**Presence.** A player's first join is announced to everyone (`presence.first_join`, empty for none) and they read
@@ -297,6 +307,19 @@ pool: the stake goes in, the payout comes out, never more than the pool holds),
The currency is earned in game only: it can't be bought with real money or cashed out (Mojang's usage guidelines).
**Emergency switches.** An operator of level 3 turns parts of the SMP off while the server runs: `/smp off`
(everything but land protection), `/smp off <part> [reason]`, `/smp on [part]`, `/smp status` (what is off, by whom,
when and why). Parts: `economy` (balances, `/pay`, `/eco`, the daily
reward), `market` (`/worth`, `/sell`, `/buy`, `/shop`, `/market`), `auction`, `trades`, `gambling` (coinflips, the
lottery, the casino), `places` (spawn, homes, warps, `/tpa`, `/back`), `claims` (land protection and
`/claim`: only by name, `/smp off claims`), `board` (the sidebar and the Tab list's header and footer), `presence`
(welcomes, AFK, the commands' rate limit), `nicks`, `invsee`. A part that is off answers its commands with "... is
turned off for now by the server's staff", and its menus close at the next click. Turning one off ends open trades
with nothing moved, closes the shop, market and auction menus, calls off teleports waiting, takes the
sidebars and nicknames away. Its timed work waits (the lottery's draw, auctions ending, the daily reward); nothing held is lost: coinflips not taken still go back and a casino
round under way still ends and is paid. The other operators on are told, and each change is in the server's log and
`audit.log` (`switch_off`, `switch_on`). What is off is kept in `switches.json`, so a restart keeps it off.
## Data
Everything is in the world's folder, `world/horizon-smp/`, so a backup of the world carries it. A world without that
@@ -308,12 +331,14 @@ folder is untouched until the mod first runs on it; it changes no block and no p
| `economy.json` | balances by player UUID, with the name last seen; `held`: coinflip stakes and lottery tickets being decided; `pools`: the casino's pot and the auction house's bids; `ext`: other parts' state written in the same step as the money (`auction`: the listings, bids, mail and notes) |
| `places.json` | the spawn, the warps, each player's homes, and the seconds each player has played (written once a minute) |
| `nicks.json` | each player's nickname and when they last changed it |
| `vanish.json` | the vanished operators by UUID, with their name |
| `staff.log` | one line per `/invsee` or `/endersee` menu in which something moved: who, whose, what was taken and put |
| `market.json` | what each player sold today (coins and items), for the daily limits |
| `market_prices.json` | each item's price factor and history (minutes for 24 h, hours for 30 days: `[time, mid, traded, sold]`), what each player bought in the last day (no selling it back), the day's summary |
| `web/market.json` | for the website (`/hub/market`, through Kira), written by a thread each minute prices move: `schema` 1, `generated`, `currency`, `tax_percent` (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 +354,13 @@ acting player's items (API 9), the server menus and `native_exchange_items` (API
`native_run_command` (API 21), `native_destroy_block` (API 14), `native_right_click_block`, the acting player and the
action bar (API 21), `native_block_spread`, `native_piston_move`, `native_explosion_detonate`, `native_fill_bucket` and
`native_attack_entity` (API 22), `native_sidebar`, `native_tab_list` and `native_tick_stats` (API 49),
`native_set_nickname` and `native_text_allowed` (API 62), the server tick and the world lifecycle.
`native_set_nickname` and `native_text_allowed` (API 62), `native_hide_player` (API 314, horizon#7003), the server tick and the world lifecycle.
`compatibility.json` names `"hooks": 58`.
| File | What it is |
|---|---|
| `mixins/smp/horizon-mixin.toml` | The bundle: twenty-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"
}
+296 -26
View File
@@ -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,
@@ -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);
@@ -164,7 +174,7 @@ fn open(dir: PathBuf) -> Smp {
let mut config = config;
// Prices never edited: today's defaults (the balances are untouched).
let write = smp_market::migrate(&mut config.market) | write;
// 0.13's sidebar (the richest ten), never edited: 0.14's scoreboard.
// 0.13's sidebar title, never edited: today's.
let write = smp_board::migrate(&mut config.board) | write;
if write && let Err(e) = smp_store::write_json(&config_path, &config) {
log::error!("[smp] {e}");
@@ -194,6 +204,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 +232,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 +265,9 @@ fn open(dir: PathBuf) -> Smp {
nicks,
nicks_shown: Default::default(),
shows: Default::default(),
vanish,
vanish_titled: Default::default(),
switches,
}
}
@@ -312,6 +336,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.
@@ -394,9 +426,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"] {
@@ -618,6 +655,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 +673,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 +732,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 +819,10 @@ 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) => {
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(),
)],
@@ -830,6 +913,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 +950,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 +973,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 +988,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 +1166,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 +1222,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) {
@@ -2019,7 +2240,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 +2272,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`.
@@ -2069,9 +2314,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 +2398,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 +2419,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 +2460,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 +2480,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 +2549,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);
}
@@ -2402,14 +2680,6 @@ fn sidebar(title: &str, lines: &[Line]) -> NativeSidebar {
)
}
Line::Gap => (Text::literal(""), None),
Line::Heading => (Text::literal("Richest").with_color(text::YELLOW), None),
Line::Top { rank, name, amount, me } => {
let color = if *me { text::GREEN } else { text::WHITE };
(
Text::literal(format!("{rank}. ")).with_color(text::GRAY).append(Text::literal(name.clone()).with_color(color)),
Some(Text::literal(amount.clone()).with_color(text::GOLD)),
)
}
Line::Footer(address) => (Text::literal(address.clone()).with_color(text::AQUA), None),
})
.collect();
+15 -3
View File
@@ -3,10 +3,10 @@ id = "smp"
version = "0.31.0"
priority = 100
# The commit of horizon it was tested on: it needs horizon#6106 (merged at 975c07d): native_inventory,
# native_move_stack, native_send_map, lore on the wire.
# 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"
+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.
+58 -90
View File
@@ -1,10 +1,10 @@
// The SMP's screens: a scoreboard sidebar, the player's own block first (their
// name or nickname, balance, playtime, the players on), then the three
// richest and the server's address, which a player hides with `/sidebar off`
// (kept in `board.json`); and the Tab list's header and footer (the server's
// name, its TPS and the players on). Nothing happens per tick: the ranking is
// built once for everyone when a balance changed (at most once each
// `min_seconds`) or every `refresh_seconds`; the players' own lines are looked
// name or nickname, balance, playtime, the players on), then the server's
// address, which a player hides with `/sidebar off` (kept in `board.json`);
// and the Tab list's header and footer (the server's name, its TPS and the
// players on). Nothing happens per tick: the balances are read once for
// everyone when one changed (at most once each `min_seconds`) or every
// `refresh_seconds`; the players' own lines are looked
// at on the same `refresh_seconds`, and a sidebar is sent only to the players
// whose lines changed and to newcomers. The Tab list goes out every
// `tab_refresh_seconds`, and to a player who just joined. No game types. Added to the game as `game::smp_board`;
@@ -16,10 +16,6 @@ use serde::{Deserialize, Serialize};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};
/// The most of the richest a sidebar lists: vanilla's 15 lines, less the
/// player's four, the two gaps, the heading and the address.
const MAX_TOP: usize = 7;
/// `config.json`'s `board` section.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -27,11 +23,9 @@ pub struct Config {
/// The sidebar (each player may hide it).
pub sidebar: bool,
pub title: String,
/// How many of the richest players it lists (at most 7; 0: none).
pub top: usize,
/// Its last line, the server's address ("": none).
pub footer: String,
/// The ranking is built again, and the players' own lines looked at,
/// The balances are read again, and the players' own lines looked at,
/// at least this often.
pub refresh_seconds: i64,
/// After a balance changed, at most this often.
@@ -50,7 +44,6 @@ impl Default for Config {
Self {
sidebar: true,
title: "Horizon SMP".into(),
top: 3,
footer: "play.playhorizon.dev".into(),
refresh_seconds: 60,
min_seconds: 5,
@@ -63,19 +56,14 @@ impl Default for Config {
}
}
/// SMP 0.13's sidebar defaults (the richest ten), moved to 0.14's when a
/// server never changed them. True when something changed.
/// SMP 0.13's sidebar title (`Richest players`), moved to today's when a
/// server never changed it. True when something changed.
pub fn migrate(config: &mut Config) -> bool {
let mut changed = false;
if config.title == "Richest players" {
config.title = Config::default().title;
changed = true;
return true;
}
if config.top == 10 {
config.top = Config::default().top;
changed = true;
}
changed
false
}
/// `board.json`.
@@ -95,16 +83,6 @@ pub enum Line {
Stat { label: &'static str, value: String },
/// An empty line.
Gap,
/// The heading over the richest.
Heading,
/// One of the richest: their rank, name and balance; `me` on the
/// player's own line.
Top {
rank: usize,
name: String,
amount: String,
me: bool,
},
/// The server's address.
Footer(String),
}
@@ -131,10 +109,9 @@ pub struct Board {
path: PathBuf,
data: Data,
changed: bool,
/// Every account by balance, richest first: UUID, name, balance.
ranked: Vec<(u128, String, i64)>,
rank_of: HashMap<u128, usize>,
/// When the ranking was built, and over which economy revision.
/// Every account's balance.
balances: HashMap<u128, i64>,
/// When the balances were read, and at which economy revision.
built_at: Option<i64>,
built_revision: u64,
/// What each player on was last sent.
@@ -145,6 +122,8 @@ pub struct Board {
personal_at: Option<i64>,
tab_at: Option<i64>,
tab_sent: HashSet<u128>,
/// The vanished players (the glue sets it): on, but not counted online.
pub quiet: HashSet<u128>,
}
/// `1,250`, `125.4k`, `3.2M`: short enough for the sidebar's right side.
@@ -180,8 +159,7 @@ impl Board {
path,
data,
changed: false,
ranked: Vec::new(),
rank_of: HashMap::new(),
balances: HashMap::new(),
built_at: None,
built_revision: 0,
sent: HashMap::new(),
@@ -189,6 +167,7 @@ impl Board {
personal_at: None,
tab_at: None,
tab_sent: HashSet::new(),
quiet: HashSet::new(),
})
}
@@ -200,11 +179,19 @@ impl Board {
Ok(())
}
/// Forgets what each player was sent: everyone gets theirs again at the
/// next update (the sidebars were taken away).
pub fn forget(&mut self) {
self.sent.clear();
self.tab_sent.clear();
self.stale = true;
}
pub fn hidden(&self, uuid: u128) -> bool {
self.data.hidden.contains(&smp_store::uuid_string(uuid))
}
/// Whether the ranking should be built again: the economy changed
/// Whether the balances should be read again: the economy changed
/// (`revision`) and `min_seconds` passed, or `refresh_seconds` passed.
pub fn due(&self, now: i64, revision: u64) -> bool {
if !self.config.sidebar {
@@ -218,27 +205,17 @@ impl Board {
|| since >= self.config.refresh_seconds * 1000
}
/// The ranking from the accounts (UUID, name, balance, any order).
pub fn rebuild(&mut self, mut accounts: Vec<(u128, String, i64)>, now: i64, revision: u64) {
accounts.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.1.cmp(&b.1)));
self.rank_of = accounts
.iter()
.enumerate()
.map(|(i, a)| (a.0, i + 1))
.collect();
self.ranked = accounts;
/// The balances from the accounts (UUID, name, balance).
pub fn rebuild(&mut self, accounts: Vec<(u128, String, i64)>, now: i64, revision: u64) {
self.balances = accounts.into_iter().map(|(u, _, b)| (u, b)).collect();
self.built_at = Some(now);
self.built_revision = revision;
self.stale = true;
}
/// `uuid`'s sidebar: their own block, the richest from the ranking, the
/// address.
/// `uuid`'s sidebar: their own block, then the address.
pub fn lines(&self, uuid: u128, me: &Me, online: usize) -> Vec<Line> {
let balance = self
.rank_of
.get(&uuid)
.map_or(0, |&rank| self.ranked[rank - 1].2);
let balance = self.balances.get(&uuid).copied().unwrap_or(0);
let mut lines = vec![
Line::Name(me.name.clone()),
Line::Stat {
@@ -254,19 +231,6 @@ impl Board {
value: online.to_string(),
},
];
let top = self.config.top.min(MAX_TOP);
if top > 0 && !self.ranked.is_empty() {
lines.push(Line::Gap);
lines.push(Line::Heading);
lines.extend(self.ranked.iter().take(top).enumerate().map(
|(i, (u, name, balance))| Line::Top {
rank: i + 1,
name: name.clone(),
amount: compact(*balance),
me: *u == uuid,
},
));
}
if !self.config.footer.is_empty() {
lines.push(Line::Gap);
lines.push(Line::Footer(self.config.footer.clone()));
@@ -297,11 +261,12 @@ impl Board {
}
let all = std::mem::take(&mut self.stale) || personal;
let mut out = Vec::new();
let online = on.iter().filter(|u| !self.quiet.contains(u)).count();
for &uuid in on {
if self.hidden(uuid) || (!all && self.sent.contains_key(&uuid)) {
continue;
}
let lines = self.lines(uuid, &me(uuid), on.len());
let lines = self.lines(uuid, &me(uuid), online);
if self.sent.get(&uuid) != Some(&lines) {
self.sent.insert(uuid, lines.clone());
out.push((uuid, lines));
@@ -429,7 +394,7 @@ mod tests {
}
#[test]
fn the_sidebar_shows_the_player_then_the_three_richest_then_the_address() {
fn the_sidebar_shows_the_player_then_the_address_and_no_ranking() {
let d = dir();
let mut b = Board::open(&d, Config::default()).unwrap();
assert!(b.due(0, 0));
@@ -442,12 +407,6 @@ mod tests {
label,
value: value.into(),
};
let top = |rank, name: &str, amount: &str, me| Line::Top {
rank,
name: name.into(),
amount: amount.into(),
me,
};
assert_eq!(
b.lines(11, &rocky, 42),
vec![
@@ -456,27 +415,20 @@ mod tests {
stat("Playtime", "2h 05m"),
stat("Online", "42"),
Line::Gap,
Line::Heading,
top(1, "P12", "1,200", false),
top(2, "P11", "1,100", true),
top(3, "P10", "1,000", false),
Line::Gap,
Line::Footer("play.playhorizon.dev".into()),
]
);
// A player with no account yet; a server with no address and no top.
// A player with no account yet; a server with no address.
assert_eq!(b.lines(99, &rocky, 1)[1], stat("Balance", "0"));
b.config.footer.clear();
b.config.top = 0;
assert_eq!(b.lines(99, &rocky, 1).len(), 4);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn the_old_defaults_move_to_the_new_ones_and_set_ones_stay() {
fn the_old_title_moves_to_the_new_one_and_a_set_one_stays() {
let mut old = Config {
title: "Richest players".into(),
top: 10,
..Config::default()
};
assert!(migrate(&mut old));
@@ -484,15 +436,21 @@ mod tests {
assert!(!migrate(&mut old));
let mut own = Config {
title: "My server".into(),
top: 5,
..Config::default()
};
assert!(!migrate(&mut own));
assert_eq!((own.title.as_str(), own.top), ("My server", 5));
assert_eq!(own.title, "My server");
}
#[test]
fn sidebars_go_out_on_join_on_a_new_ranking_and_on_the_refresh() {
fn an_old_config_with_a_top_still_reads() {
let old = serde_json::json!({ "title": "Horizon SMP", "top": 3 });
let c: Config = serde_json::from_value(old).unwrap();
assert_eq!(c, Config::default());
}
#[test]
fn sidebars_go_out_on_join_on_a_balance_change_and_on_the_refresh() {
let d = dir();
let mut b = Board::open(&d, Config::default()).unwrap();
b.rebuild(accounts(3), 0, 1);
@@ -511,11 +469,12 @@ mod tests {
assert!(b.due(5_000, 2));
b.rebuild(accounts(3), 5_000, 2);
assert!(b.updates(5_000, &[1, 2], me).is_empty(), "same lines");
// Player 1 gets richer: everyone's top changes.
// Player 1 gets richer: only their own sidebar changes.
let mut a = accounts(3);
a[0].2 = 1_000;
b.rebuild(a, 10_000, 3);
assert_eq!(b.updates(10_000, &[1, 2], me).len(), 2);
let sent = b.updates(10_000, &[1, 2], me);
assert_eq!(sent.iter().map(|s| s.0).collect::<Vec<_>>(), vec![1]);
// A newcomer gets theirs at once, and only they are looked at.
asked.set(0);
assert_eq!(b.updates(11_000, &[1, 2, 3], me).len(), 1);
@@ -529,6 +488,15 @@ mod tests {
let sent = b.updates(60_000, &[1, 2, 3], me);
assert_eq!(sent.iter().map(|s| s.0).collect::<Vec<_>>(), vec![2]);
assert!(b.due(70_000, 3), "a minute passed");
// A vanished player isn't counted, at the next refresh.
b.quiet.insert(3);
let sent = b.updates(120_000, &[1, 2, 3], me);
assert_eq!(sent.len(), 3, "everyone's count changed");
let online = Line::Stat {
label: "Online",
value: "2".into(),
};
assert!(sent[0].1.contains(&online), "{:?}", sent[0].1);
let _ = std::fs::remove_dir_all(d);
}
+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]);
+497
View File
@@ -0,0 +1,497 @@
// The SMP's emergency switches: an operator of level 3 turns parts of the SMP
// off (`/smp off [part] [reason]`) and on again (`/smp on [part]`) while the
// server runs, kept in `switches.json` so a restart keeps them off. A part
// that is off takes no command and no menu click, and its timed work (daily
// rewards, auctions ending, the lottery's draw) waits; nothing held is lost: it goes on when the part is on
// again. `/smp off` alone turns off every part but land protection, which an
// operator turns off by name (`/smp off claims`). No game types. Added to the
// game as `game::smp_switch`; also a module of this crate, for its tests.
use super::smp_economy::{Out, Player};
use super::smp_store;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// The operator level that turns parts off and on.
pub const LEVEL: u8 = 3;
pub const COMMANDS: [&str; 1] = ["smp"];
/// A part of the SMP that can be turned off.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Part {
/// Balances, `/pay`, `/eco` and the daily reward.
Economy,
/// `/worth`, `/sell`, `/buy`, `/shop`, `/market`.
Market,
/// `/ah`.
Auction,
/// `/trade`.
Trades,
/// `/coinflip`, `/lottery`, `/casino`.
Gambling,
/// Spawn, homes, warps, `/tpa`, `/back`.
Places,
/// Land protection and `/claim`.
Claims,
/// The sidebar and the Tab list's header and footer.
Board,
/// Welcomes, AFK and the commands' rate limit.
Presence,
/// Nicknames.
Nicks,
/// `/invsee`, `/endersee`.
Invsee,
}
impl Part {
pub const ALL: [Part; 11] = [
Part::Economy,
Part::Market,
Part::Auction,
Part::Trades,
Part::Gambling,
Part::Places,
Part::Claims,
Part::Board,
Part::Presence,
Part::Nicks,
Part::Invsee,
];
pub fn name(self) -> &'static str {
match self {
Part::Economy => "economy",
Part::Market => "market",
Part::Auction => "auction",
Part::Trades => "trades",
Part::Gambling => "gambling",
Part::Places => "places",
Part::Claims => "claims",
Part::Board => "board",
Part::Presence => "presence",
Part::Nicks => "nicks",
Part::Invsee => "invsee",
}
}
pub fn named(name: &str) -> Option<Part> {
Part::ALL
.into_iter()
.find(|p| p.name().eq_ignore_ascii_case(name))
}
/// The part a command belongs to (its first word); None for `/smp` and
/// what isn't the SMP's.
pub fn of_command(word: &str) -> Option<Part> {
Some(match word {
"balance" | "bal" | "pay" | "baltop" | "eco" | "money" => Part::Economy,
"worth" | "sell" | "buy" | "shop" | "market" => Part::Market,
"ah" => Part::Auction,
"trade" => Part::Trades,
"coinflip" | "lottery" | "casino" => Part::Gambling,
"claim" | "unclaim" | "claims" | "trust" | "untrust" => Part::Claims,
"sidebar" => Part::Board,
"afk" => Part::Presence,
"nick" | "realname" => Part::Nicks,
"invsee" | "endersee" => Part::Invsee,
"spawn" | "setspawn" | "home" | "sethome" | "delhome" | "homes" | "warp" | "warps"
| "setwarp" | "delwarp" | "tpa" | "tpaccept" | "tpdeny" | "rules" | "back"
| "playtime" => Part::Places,
_ => return None,
})
}
}
/// Why and by whom a part is off.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Off {
pub by: String,
/// When (ms).
pub at: i64,
pub reason: String,
}
/// `switches.json`: the parts that are off, by name.
#[derive(Default, Serialize, Deserialize)]
#[serde(default)]
struct Data {
off: BTreeMap<String, Off>,
}
pub struct Switches {
path: PathBuf,
data: Data,
changed: bool,
}
/// What a `/smp` command changed, for the glue to carry out.
#[derive(Debug, Default, PartialEq)]
pub struct Changed {
pub off: Vec<Part>,
pub on: Vec<Part>,
}
impl Switches {
/// `switches.json` in `dir`. A file that can't be read turns nothing off
/// (and is logged by the caller): the switches are for emergencies, not a
/// reason for the SMP to stop.
pub fn open(dir: &Path) -> (Self, Option<String>) {
let path = dir.join("switches.json");
let (data, problem) = match smp_store::read_json(&path) {
Ok(d) => (d, None),
Err(e) => (Data::default(), Some(e)),
};
let mut s = Self {
path,
data,
changed: false,
};
// Names of parts this version doesn't know are dropped.
let before = s.data.off.len();
s.data.off.retain(|name, _| Part::named(name).is_some());
s.changed = s.data.off.len() != before;
(s, problem)
}
pub fn save(&mut self) -> Result<(), String> {
if self.changed {
smp_store::write_json(&self.path, &self.data)?;
self.changed = false;
}
Ok(())
}
pub fn on(&self, part: Part) -> bool {
!self.data.off.contains_key(part.name())
}
/// Why a part is off, as the operator said.
pub fn why(&self, part: Part) -> Option<&str> {
self.data.off.get(part.name()).map(|o| o.reason.as_str())
}
/// `/smp`, `/smp status`, `/smp off [part|all] [reason]`,
/// `/smp on [part|all]`: the answer and what changed. None for another
/// command.
pub fn command(&mut self, me: &Player, now: i64, line: &str) -> Option<(Vec<Out>, Changed)> {
let words: Vec<&str> = line.split_whitespace().collect();
if words.first() != Some(&"smp") {
return None;
}
let none = Changed::default();
if me.level < LEVEL {
return Some((
vec![Out::Error(
"Only operators turn the SMP's parts off and on.".into(),
)],
none,
));
}
let usage = || {
Out::Error(format!(
"Usage: /smp status, /smp off [part|all] [reason], /smp on [part|all]. Parts: {}.",
Part::ALL.map(Part::name).join(", ")
))
};
let (turn_off, rest) = match words[1..] {
[] | ["status"] => return Some((self.status(), none)),
["off", ref rest @ ..] => (true, rest),
["on", ref rest @ ..] => (false, rest),
_ => return Some((vec![usage()], none)),
};
// The part, or every one: `all`, or none named.
let (parts, reason): (Vec<Part>, &[&str]) = match rest.first() {
None => (everything(turn_off), &[]),
Some(w) if w.eq_ignore_ascii_case("all") => (everything(turn_off), &rest[1..]),
Some(w) => match Part::named(w) {
Some(p) => (vec![p], &rest[1..]),
// `/smp off <reason>` turns off everything (but claims).
None if turn_off => (everything(true), rest),
None => return Some((vec![usage()], none)),
},
};
let mut changed = Changed::default();
for p in parts {
if turn_off && self.on(p) {
let off = Off {
by: me.name.clone(),
at: now,
reason: reason.join(" "),
};
self.data.off.insert(p.name().into(), off);
changed.off.push(p);
} else if !turn_off && self.data.off.remove(p.name()).is_some() {
changed.on.push(p);
}
}
self.changed |= !changed.off.is_empty() || !changed.on.is_empty();
let names = |ps: &[Part]| ps.iter().map(|p| p.name()).collect::<Vec<_>>().join(", ");
let out = match (turn_off, changed.off.is_empty() && changed.on.is_empty()) {
(true, true) => vec![Out::Reply("Already off.".into())],
(false, true) => vec![Out::Reply("Already on.".into())],
(true, false) => {
let why = if reason.is_empty() {
String::new()
} else {
format!(" ({})", reason.join(" "))
};
let mut out = vec![Out::Reply(format!(
"Turned off: {}{why}. /smp on turns them on again.",
names(&changed.off)
))];
if !self.on(Part::Claims) || changed.off.contains(&Part::Claims) {
out.push(Out::Reply(
"Land is not protected while claims are off.".into(),
));
} else {
out.push(Out::Reply(
"Land protection stays on (/smp off claims turns it off too).".into(),
));
}
out
}
(false, false) => vec![Out::Reply(format!(
"Turned on again: {}.",
names(&changed.on)
))],
};
Some((out, changed))
}
/// What is off, by whom, since when and why.
pub fn status(&self) -> Vec<Out> {
if self.data.off.is_empty() {
return vec![Out::Reply("Every part of the SMP is on.".into())];
}
let mut out = vec![Out::Reply(format!(
"Off: {} of {} parts.",
self.data.off.len(),
Part::ALL.len()
))];
for (name, off) in &self.data.off {
let when = utc_minute(off.at);
let why = if off.reason.is_empty() {
String::new()
} else {
format!(": {}", off.reason)
};
out.push(Out::Reply(format!(
"- {name}, by {} at {when} UTC{why}",
off.by
)));
}
out
}
}
/// The answer to a command of a part that is off.
pub fn refusal(part: Part) -> Out {
Out::Error(format!(
"{} is turned off for now by the server's staff.",
match part {
Part::Economy => "The economy",
Part::Market => "The market",
Part::Auction => "The auction house",
Part::Trades => "Trading",
Part::Gambling => "Gambling",
Part::Places => "Teleporting",
Part::Claims => "Claiming",
Part::Board => "The sidebar",
Part::Presence => "AFK",
Part::Nicks => "Nicknames",
Part::Invsee => "Inventory viewing",
}
))
}
/// Every part, but land protection when turning off.
fn everything(turn_off: bool) -> Vec<Part> {
Part::ALL
.into_iter()
.filter(|&p| !turn_off || p != Part::Claims)
.collect()
}
/// `2026-11-02 14:05` from ms since the epoch (UTC).
fn utc_minute(ms: i64) -> String {
let secs = ms.div_euclid(1000);
let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
// Howard Hinnant's civil_from_days.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!(
"{y:04}-{m:02}-{d:02} {:02}:{:02}",
rem / 3600,
rem % 3600 / 60
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
fn dir() -> PathBuf {
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("smp-switch-{}-{n}", std::process::id()))
}
fn op() -> Player {
Player {
uuid: 1,
name: "luzW".into(),
level: 3,
}
}
fn texts(out: &[Out]) -> String {
out.iter()
.map(|o| match o {
Out::Reply(s) | Out::Error(s) | Out::Tell(_, s) | Out::All(s) => s.as_str(),
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn every_command_of_the_smp_belongs_to_a_part() {
// The glue's own (its `COMMANDS`), then each part's.
let glue = [
"balance", "bal", "pay", "baltop", "eco", "money", "worth", "sell", "buy", "shop",
"market", "trade", "coinflip", "lottery", "ah", "casino",
];
let parts = [
&super::super::smp_places::COMMANDS[..],
&super::super::smp_claims::COMMANDS,
&super::super::smp_board::COMMANDS,
&super::super::smp_presence::COMMANDS,
&super::super::smp_nick::COMMANDS,
&super::super::smp_invsee::COMMANDS,
];
for word in glue.into_iter().chain(parts.into_iter().flatten().copied()) {
assert!(Part::of_command(word).is_some(), "{word}");
}
assert_eq!(
Part::of_command("smp"),
None,
"the switch itself is never off"
);
assert_eq!(
Part::of_command("tp"),
None,
"vanilla's commands are not the SMP's"
);
}
#[test]
fn off_alone_turns_off_everything_but_claims_and_survives_a_restart() {
let d = dir();
let (mut s, problem) = Switches::open(&d);
assert!(problem.is_none());
let (out, changed) = s
.command(&op(), 1_762_092_300_000, "smp off market exploit")
.unwrap();
assert_eq!(changed.off, vec![Part::Market], "a named part alone");
assert!(
texts(&out).contains("Turned off: market (exploit)"),
"{}",
texts(&out)
);
let (out, changed) = s
.command(&op(), 1_762_092_300_000, "smp off duping bug")
.unwrap();
assert_eq!(
changed.off.len(),
Part::ALL.len() - 2,
"all but claims and the market already off"
);
assert!(!changed.off.contains(&Part::Claims));
assert!(
texts(&out).contains("Land protection stays on"),
"{}",
texts(&out)
);
assert!(s.on(Part::Claims) && !s.on(Part::Economy) && !s.on(Part::Invsee));
s.save().unwrap();
let (s, _) = Switches::open(&d);
assert!(!s.on(Part::Market) && !s.on(Part::Gambling) && s.on(Part::Claims));
let status = texts(&s.status());
assert!(
status.contains("- market, by luzW at 2025-11-02 14:05 UTC: exploit"),
"{status}"
);
assert!(
status.contains("- economy, by luzW at 2025-11-02 14:05 UTC: duping bug"),
"{status}"
);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn claims_go_off_only_by_name_and_on_with_the_rest() {
let d = dir();
let (mut s, _) = Switches::open(&d);
s.command(&op(), 0, "smp off all");
assert!(s.on(Part::Claims));
let (out, changed) = s.command(&op(), 0, "smp off claims grief").unwrap();
assert_eq!(changed.off, vec![Part::Claims]);
assert!(
texts(&out).contains("Land is not protected"),
"{}",
texts(&out)
);
let (_, changed) = s.command(&op(), 0, "smp on").unwrap();
assert_eq!(changed.on.len(), Part::ALL.len());
assert!(Part::ALL.iter().all(|&p| s.on(p)));
let (out, changed) = s.command(&op(), 0, "smp on market").unwrap();
assert_eq!(changed, Changed::default());
assert_eq!(texts(&out), "Already on.");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn players_below_level_three_change_nothing() {
let d = dir();
let (mut s, _) = Switches::open(&d);
let player = Player {
uuid: 2,
name: "Steve".into(),
level: 2,
};
let (out, changed) = s.command(&player, 0, "smp off").unwrap();
assert_eq!(changed, Changed::default());
assert!(matches!(&out[0], Out::Error(_)));
assert!(Part::ALL.iter().all(|&p| s.on(p)));
assert!(s.command(&player, 0, "balance").is_none());
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn a_damaged_file_turns_nothing_off_and_is_reported() {
let d = dir();
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("switches.json"), "{ not json").unwrap();
let (s, problem) = Switches::open(&d);
assert!(problem.is_some());
assert!(Part::ALL.iter().all(|&p| s.on(p)));
// A part this version doesn't know is dropped.
std::fs::write(
d.join("switches.json"),
r#"{"off": {"rockets": {}, "market": {"by": "x"}}}"#,
)
.unwrap();
let (s, _) = Switches::open(&d);
assert!(!s.on(Part::Market));
assert_eq!(s.data.off.len(), 1);
let _ = std::fs::remove_dir_all(d);
}
}
+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);
}
}
+83 -5
View File
@@ -28,6 +28,8 @@ Steve, `horizon-ctl --offscreen`) join it. Checked from the clients' own chat an
- 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.
@@ -452,7 +454,7 @@ def world_guard(server, alex, steve):
def board(alex, steve, out):
"""The scoreboard sidebar (the player's own block, the richest, the address) and the Tab list; /sidebar off
"""The scoreboard sidebar (the player's own block, then the address) and the Tab list; /sidebar off
hides it, on shows it again."""
r = {}
side = lambda c: c.run("status").get("sidebar")
@@ -462,9 +464,8 @@ def board(alex, steve, out):
break
time.sleep(0.5)
assert r["sidebar"]["objective"] == "horizon:native_sidebar" and r["sidebar"]["title"] == "Horizon SMP", r
# Steve's name, balance, playtime and the players on; a gap, "Richest", Alex and Steve (the two accounts); a gap,
# the address.
assert len(r["sidebar"]["scores"]) == 10, r
# Steve's name, balance, playtime and the players on; a gap, the address. No ranking of the richest.
assert len(r["sidebar"]["scores"]) == 6, r
# Holders starting with '#' would be hidden from the sidebar (only the title would show).
assert not any(o.startswith("#") for o in r["sidebar"]["scores"]), r
steve.run("key", "tab", "down")
@@ -513,6 +514,37 @@ def polish(server, alex, steve, out):
return r
def switches(alex, data):
"""/smp off and on (Alex is an operator): a part turned off refuses its commands, everything (but land
protection) off takes the sidebar away, and on again brings it all back; switches.json keeps what is off."""
r = {}
side = lambda: alex.run("status").get("sidebar")
r["off_market"] = alex.say("/smp off market proof")
assert any("Turned off: market (proof)" in l for l in r["off_market"]), r
r["worth"] = alex.say("/worth diamond")
assert any("The market is turned off for now" in l for l in r["worth"]), r
r["balance"] = alex.say("/balance")
assert any("Balance:" in l for l in r["balance"]), r
r["status"] = alex.say("/smp status")
assert any("- market, by Alex" in l and ": proof" in l for l in r["status"]), r
r["off_all"] = alex.say("/smp off")
assert any("Land protection stays on" in l for l in r["off_all"]), r
r["balance_off"] = alex.say("/balance")
assert any("The economy is turned off for now" in l for l in r["balance_off"]), r
time.sleep(1.5)
assert not side(), "the sidebar is taken away"
r["on"] = alex.say("/smp on")
assert any("Turned on again:" in l and "claims" not in l for l in r["on"]), r
r["balance_on"] = alex.say("/balance")
assert any("Balance:" in l for l in r["balance_on"]), r
for _ in range(10):
if side():
break
time.sleep(0.5)
assert side(), "the sidebar is back"
return r
def presence(server, alex, steve):
"""/afk keeps Steve out of the sleep count (Alex alone in bed passes the night); moving brings him back; too many
commands at once wait."""
@@ -827,6 +859,46 @@ def trades(server, alex, steve, port, ctl, resources, root):
return results, steve
def vanish(server, alex, steve, out):
"""/vanish: Alex (an operator) leaves Steve's world and Tab list, and reads a subtitle the whole time; Steve can't
reach her by name; /vanish again brings her back. Steve can't vanish."""
r = {}
players = lambda c: [e for e in c.run("entities", 64, "player")["entities"]]
subtitle = lambda c: c.run("messages", 1).get("subtitle") or ""
r["seen_before"] = len(players(steve))
r["refused"] = steve.say("/vanish")
assert any("Unknown" in l or "Only operators" in l or "permission" in l for l in r["refused"]), r
r["vanish"] = alex.say("/vanish")
assert any("You vanished" in l for l in r["vanish"]), r
time.sleep(2.5)
# Over a few seconds, not just once: the subtitle never lapses.
r["subtitles"] = []
for _ in range(4):
r["subtitles"].append(subtitle(alex))
time.sleep(1)
assert all(s == "Vanished: only operators see you" for s in r["subtitles"]), r
assert "Vanished" not in subtitle(steve), r
if r["seen_before"]:
assert not players(steve), "Steve no longer sees Alex"
alex.run("screenshot", str(Path(out).resolve() / "vanish-subtitle.png"))
steve.run("key", "tab", "down")
time.sleep(1)
steve.run("screenshot", str(Path(out).resolve() / "vanish-tab.png"))
steve.run("key", "tab", "up")
r["tpa"] = steve.say("/tpa Alex")
assert any("Alex is not on." in l or "Alex isn't on." in l for l in r["tpa"]), r
r["list"] = alex.say("/vanish list")
assert any("Vanished: Alex." in l for l in r["list"]), r
r["back"] = alex.say("/vanish")
assert any("You are visible again." in l for l in r["back"]), r
time.sleep(3)
assert "Vanished" not in subtitle(alex), r
if r["seen_before"]:
assert players(steve), "Steve sees Alex again"
time.sleep(6) # the spam guard's window, for the steps after
return r
def run(ctl, resources, out):
if os.environ.get("SMP_PROOF") == "casino":
# The casino's own proof (proof_casino.py), apart: it plays its games for a few minutes.
@@ -950,6 +1022,7 @@ def run(ctl, resources, out):
results["polish"] = polish(server, alex, steve, out)
results["presence"] = presence(server, alex, steve)
results["nicks"] = nicks(server, alex, steve, out)
results["vanish"] = vanish(server, alex, steve, out)
results["coinflip"], alex_wins = coinflips(alex, steve)
results["lottery"], alex_lotto = lottery(alex, steve, alex_wins)
alex_after = 415 + (100 if alex_wins else -100) + (20 if alex_lotto else -30)
@@ -999,6 +1072,7 @@ def run(ctl, resources, out):
added, results["shop"] = shop(server, alex)
spent, results["market"] = market(server, alex, out, data)
results["invsee"] = invsee(alex, data)
results["switches"] = switches(alex, data)
sent = {path: (auth, doc) for path, auth, doc in receiver.got}
results["pushed"] = sorted(sent)
assert {"/smp/ingest/market.json", "/smp/ingest/economy.json"} <= set(sent), receiver.got[:2]
@@ -1018,7 +1092,11 @@ 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"]
assert kinds[-len(shop_kinds):] == shop_kinds, kinds[-8:]
+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;