mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 11:05:58 -10:00
Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48ed3ea061 | ||
|
|
d31c2d7992 | ||
|
|
c5d504a734 | ||
|
|
6c2a827c34 | ||
|
|
90f06bd473 | ||
|
|
3395149802 | ||
|
|
3fc6964447 | ||
|
|
3749d679bd | ||
|
|
d933c333e6 | ||
|
|
c86a73887a | ||
|
|
83c4269800 | ||
|
|
89d90c3376 | ||
|
|
6b71454d1b | ||
|
|
57f7902809 | ||
|
|
7c2b9f3b80 | ||
|
|
2a4307638f | ||
|
|
5108050b31 | ||
|
|
706f3a6cb1 | ||
|
|
6d05ae816f | ||
|
|
f5487d5b08 | ||
|
|
4ede2e8aa7 | ||
|
|
9e1a2b70a8 | ||
|
|
969533780c | ||
|
|
31e019dff8 | ||
|
|
cccfc99d37 | ||
|
|
9c12738fb0 | ||
|
|
6d9e3a2064 | ||
|
|
9f7346e2e5 | ||
|
|
a04b258daf | ||
|
|
1b4ab4efe8 | ||
|
|
0eee668de4 | ||
|
|
a5fdef87fb | ||
|
|
44fa384ad6 | ||
|
|
f25ae007a7 | ||
|
|
181b9af7de | ||
|
|
c6c309d01b | ||
|
|
a258ef03d9 | ||
|
|
acf5d8896a | ||
|
|
4dcb8fed29 | ||
|
|
f56752d74f | ||
|
|
455a8b800f | ||
|
|
238d9cd1f4 | ||
|
|
ddab3fa269 | ||
|
|
a301fcc469 | ||
|
|
c1ce388d41 | ||
|
|
8bbf748e1f | ||
|
|
50c23cb7d9 | ||
|
|
8d5c1ed7a3 | ||
|
|
b6c1af58cd | ||
|
|
3cf1b6aa8f | ||
|
|
a11db16db3 | ||
|
|
dca26bf044 | ||
|
|
95b8f15c6b | ||
|
|
be6b796a20 | ||
|
|
90e26686bd | ||
|
|
2cd564f16b | ||
|
|
c64087836d | ||
|
|
40f3d42984 | ||
|
|
9a7b8a0ffc | ||
|
|
b571298b58 | ||
|
|
fefd3e8c6b | ||
|
|
4364dbfba3 | ||
|
|
8c837ede87 | ||
|
|
36b3c994e6 | ||
|
|
1ab3acaf70 | ||
|
|
ca4f82c665 | ||
|
|
bcf749c4d8 | ||
|
|
ff69e902c8 | ||
|
|
97170b869d | ||
|
|
e09a094d25 | ||
|
|
c5ba9aaf03 | ||
|
|
f7709827f6 | ||
|
|
72250afe73 | ||
|
|
5495b461fd | ||
|
|
aaf6ccefcc | ||
|
|
b0e3d98b52 | ||
|
|
803907aaa5 | ||
|
|
ec056f9f02 | ||
|
|
63f2fa9f01 | ||
|
|
1ec378ac96 | ||
|
|
04c0ee173a | ||
|
|
0eb6ca75c3 | ||
|
|
26079e71ed | ||
|
|
b9bd159dcf | ||
|
|
5b03ea2d0c | ||
|
|
08ca24a176 |
No files matched your search
@@ -36,6 +36,21 @@ Submitting a project, through the form or a pull request, means you confirm that
|
||||
You alone are responsible for what you publish and for any claim about it. The Horizon project may refuse, hide or
|
||||
remove any project, at any time, without having to give a reason.
|
||||
|
||||
## Licences reviewers can read
|
||||
|
||||
Reviewers read a project's source before each version is listed; that is how they check it holds nothing harmful.
|
||||
They may only read source under a licence that lets them, so a new project, and every new version of a listed one,
|
||||
needs source under one of:
|
||||
|
||||
- MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, 0BSD, Apache-2.0, ISC, zlib, Unlicense or CC0-1.0;
|
||||
- or any licence, with a written grant from its authors that lets the Horizon reviewers read the source to review it
|
||||
(say so in the pull request, with a link to the grant).
|
||||
|
||||
Source under GPL, LGPL, MPL or all rights reserved, without such a grant, can't be reviewed, so it can't be listed or
|
||||
updated. The projects already listed under other licences when this rule was written (classic_zoom,
|
||||
distant_horizons, fly_speed, serverify) stay listed as they are; their next version needs a licence above or a
|
||||
grant. A port also has to follow promise 2 above.
|
||||
|
||||
## Copyright and takedowns
|
||||
|
||||
If a project in this registry uses your work without your permission:
|
||||
|
||||
@@ -18,8 +18,8 @@ and the launcher to have shipped first.
|
||||
|
||||
**Who is responsible.** Except the projects marked Official (made by the Horizon project), everything here is made and
|
||||
owned by its own authors, not by the Horizon project or its maintainers. A listing is not an endorsement. See the
|
||||
[mod policy](MOD-POLICY.md): what authors promise when they submit (ports of other mods included) and how to have
|
||||
a project taken down.
|
||||
[mod policy](MOD-POLICY.md): what authors promise when they submit (ports of other mods included), the licences
|
||||
whose source reviewers can read, and how to have a project taken down.
|
||||
|
||||
## Submit
|
||||
|
||||
@@ -41,8 +41,9 @@ cargo install --git https://github.com/horizon-rust/horizon cargo-horizon-mod
|
||||
cargo horizon-mod check target/horizon-mod/my_mod.hmod
|
||||
```
|
||||
|
||||
**Updates** come the same way: the form adds a version to your entry. Only an entry's `owners` publish its updates
|
||||
without a reviewer's extra look. A published file never changes: publish a new version.
|
||||
**Updates** come the same way: the form adds a version to your entry, and a reviewer reads it before it is
|
||||
published. Only an entry's `owners` can add versions, and every version of a native mod is reviewed, owner or
|
||||
not: its code runs with the player's account permissions. A published file never changes: publish a new version.
|
||||
|
||||
**Signing** is optional and recommended: put your [minisign](https://jedisct1.github.io/minisign/) public key in your
|
||||
entry, and every later version must be signed with it, so nobody else can publish an update in your name.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 855 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,29 @@
|
||||
id = "appleskin_horizon"
|
||||
kind = "native-mod"
|
||||
name = "AppleSkin"
|
||||
summary = "1:1 Minecraft Java parity food value preview & saturation HUD overlay."
|
||||
description = """
|
||||
1:1 Minecraft Java parity port of AppleSkin for the native Horizon Rust client.
|
||||
|
||||
Features
|
||||
Saturation Overlay: Golden border and glow around active saturation drumsticks matching original icons.png pixel art.
|
||||
Food Value Preview: Pulsing preview drumsticks and preview saturation border when holding edible items.
|
||||
Clean Integration: Easily toggled via Options > Mods > AppleSkin."""
|
||||
authors = ["CayMac"]
|
||||
owners = ["CayMac97"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/CayMac97/appleskin"
|
||||
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
|
||||
|
||||
[[versions]]
|
||||
version = "1.0.0"
|
||||
published = "2026-10-31"
|
||||
game = "26.3"
|
||||
file = "appleskin_horizon-1.0.0.hnmod"
|
||||
sha256 = "38a729f1aad00f6148f29578a2725cb5e7f9aea79a870fcf3cee76f39838c5ff"
|
||||
signature = """
|
||||
untrusted comment: signature from minisign secret key\r
|
||||
RUS411U2KXdTNzxiQEELascXv6v/RXLycX9k/IkVf5ggI7lL2qjyDEVxdDl5nPxbdCJ3epwwHIE6D8ZHw6QGXCFQjRz3I6Z/UA0=\r
|
||||
trusted comment: timestamp:1793440355\tfile:appleskin_horizon-1.0.0.hnmod\thashed\r
|
||||
6kGj1vQ1DJypzS0iDdvwksQ2FFAB1jBcycZL83OgBgQgUzbQzKRhGtleKwqAuIrRLdpQO93ale4hSMAv1z/EDg=="""
|
||||
changelog = "Initial release for Horizon 26.3."
|
||||
@@ -0,0 +1,30 @@
|
||||
id = "armor_status"
|
||||
kind = "native-mod"
|
||||
name = "Armor Status"
|
||||
summary = "Displays equipped armor & held items with authentic Java durability colors."
|
||||
description = """
|
||||
A native ArmorStatusHUD mod for the Horizon Rust client.
|
||||
|
||||
Features
|
||||
Displays all 4 armor slots plus main and off-hand items on the HUD.
|
||||
Authentic Java durability color coding (Green > Yellow > Orange > Red).
|
||||
Item count display for non-damageable held items (torches, food, blocks).
|
||||
Default position: Bottom Right (freely customizable in Options > Mods)."""
|
||||
authors = ["CayMac"]
|
||||
owners = ["CayMac97"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/CayMac97/armor-status"
|
||||
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
|
||||
|
||||
[[versions]]
|
||||
version = "1.0.0"
|
||||
published = "2026-10-31"
|
||||
game = "26.3"
|
||||
file = "armor_status-1.0.0.hnmod"
|
||||
sha256 = "82c8672fa2c43d7946a26a560fd56fdd8f34beecf4bcb459c2d508b3d09eeee6"
|
||||
signature = """
|
||||
untrusted comment: signature from minisign secret key\r
|
||||
RUS411U2KXdTN+dR+lW2uDL5+FJfKRnHJvelJnWmhWGQeS/p0lNFVDFfq3BjZ2LCuNEtza8cZvcuKuuYF5asmxsnbtB/MMykYQA=\r
|
||||
trusted comment: timestamp:1793439927\tfile:armor_status-1.0.0.hnmod\thashed\r
|
||||
LTwf1kuPLucJIBfHPdx1nsp0YuHSlxbFG6PYKG2HnMxMf4acAsZbWR0QdQnzkn+7Oc/T60iQPh3X14RBST3+Bg=="""
|
||||
changelog = "Initial release for Horizon 26.3."
|
||||
@@ -0,0 +1,34 @@
|
||||
id = "basic_hud"
|
||||
kind = "native-mod"
|
||||
name = "The Basic HUD"
|
||||
summary = "Effect timers, potions, golden apples and totems carried, totems used by each player, and armour, elytra and shield durability."
|
||||
description = """
|
||||
A small HUD box in a corner of the screen.
|
||||
|
||||
## What it shows
|
||||
|
||||
- Active effects with their time left. Potion effects are pinned by default; any other effect can be pinned on the mod's settings page, or all of them shown at once.
|
||||
- Potions of each kind, golden apples, enchanted golden apples and totems in your inventory.
|
||||
- Totems each player has used, above that player's name. Your own count is in the box.
|
||||
- Durability of worn armour, elytra and a held shield.
|
||||
|
||||
The count of a player's totems resets when they die or when you leave the world.
|
||||
|
||||
## What it does not do
|
||||
|
||||
- It builds on one Horizon commit only, 68ac6f6 (Minecraft 26.3). Counting totems and writing above a player have no hook point, so it hooks two of the game's own functions (totem_event, name_tag_geometry). The rest uses hook points but reads the game's internal data.
|
||||
- It knows only what the client is told: a totem used out of your sight is not counted.
|
||||
- Its own texts are in English. Effect and item names follow the game's language.
|
||||
- Tested on Windows in single player and over LAN with two clients, not on a dedicated server."""
|
||||
authors = ["repositorykreml1n"]
|
||||
owners = ["repositorykreml1n"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/repositorykreml1n/the-basic-hud"
|
||||
|
||||
[[versions]]
|
||||
version = "0.1.0"
|
||||
published = "2026-11-01"
|
||||
game = "26.3"
|
||||
file = "basic_hud-0.1.0.hnmod"
|
||||
sha256 = "fe2c5d9da681b4f575bff867df70d6e2f61614b0215a206fb7a0c3b0027db994"
|
||||
changelog = "First release."
|
||||
@@ -3,7 +3,7 @@ kind = "native-mod"
|
||||
name = "BiomeCompass"
|
||||
summary = "Craft a Biome Compass, pick a biome and its needle points to the nearest one."
|
||||
description = """
|
||||
**A native Horizon mod**: the launcher builds it into the game (Settings → Game → Native mods, then Build). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
|
||||
**A native Horizon mod**: the launcher builds it into the game (install it on the launcher's Mods page, then press Build there). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
|
||||
|
||||
Craft a **Biome Compass** (a compass with a sapling on each of its four sides, any kinds), right-click it and pick a biome from the list (with a search box). The world generator is searched around you, a little every tick, and the compass's needle then points to the nearest one, with its distance and direction on the action bar.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
id = "blockscope"
|
||||
kind = "mod"
|
||||
kind = "native-mod"
|
||||
name = "Blockscope"
|
||||
summary = "The block under your crosshair: its name, id, distance and icon, with reach, placement and opacity on its own settings page. A Jade-style tooltip for Horizon."
|
||||
description = "Jade-style block info for Horizon: the block under your crosshair, its name in your language, its block id, how far it is and its icon."
|
||||
@@ -9,6 +9,14 @@ license = "MIT"
|
||||
source = "https://github.com/Domanffe/blockscope"
|
||||
icon = "https://horizon-rust.github.io/horizon-mods/icons/blockscope.png"
|
||||
|
||||
[[versions]]
|
||||
version = "0.3.0"
|
||||
published = "2026-10-30"
|
||||
game = "26.3"
|
||||
file = "blockscope-0.3.0.hnmod"
|
||||
sha256 = "ba4a1c887ab26a2a9189c671cbdbea3ce2ed212a6e3d32f1fffaa60386198358"
|
||||
changelog = "Native: the WebAssembly mod rewritten as a mixin bundle for the launcher's native mods, with hardness and the tool's icon, block states, crop growth, a container's slots and a creature's health, each on its own switch. Its settings carry over from 0.2.0."
|
||||
|
||||
[[versions]]
|
||||
version = "0.2.0"
|
||||
published = "2026-10-29"
|
||||
|
||||
@@ -3,7 +3,7 @@ kind = "native-mod"
|
||||
name = "Carry Block"
|
||||
summary = "Sneak and right-click with an empty hand to pick up a chest (with its items) or a small mob, carry it and put it down."
|
||||
description = """
|
||||
**A native Horizon mod**: the launcher builds it into the game (Settings → Game → Native mods, then Build). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
|
||||
**A native Horizon mod**: the launcher builds it into the game (install it on the launcher's Mods page, then press Build there). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
|
||||
|
||||
Sneak and right-click with an empty hand to pick up a block with its contents (chests, furnaces, barrels...) or a small, friendly mob, and carry it; right-click to put it down where you want, with everything it held.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Both can be installed at once: they use different keys.
|
||||
- Written from scratch for Horizon's mod API. Not affiliated with Wurst-Imperium.
|
||||
- Some servers forbid zoom mods: check the rules before you join.
|
||||
|
||||
**Now a native mod (2.0.0)**: the launcher builds it into the game (Settings → Game → Native mods (experimental), then Build). Native mods are not sandboxed and run with your account's permissions, so read the source before you build it: it is one small bundle on Horizon's native hook points (key, wheel, zoom, mods list). Tested against Horizon `6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a` with Horizon Mixins `0d6b103c70633d8fd9443d8d0acd3aadaacedb06`. A key set in 1.0.0 carries over. 1.0.0 is the WebAssembly version.
|
||||
**Now a native mod (2.0.0)**: the launcher builds it into the game (install it on the launcher's Mods page, then press Build there). Native mods are not sandboxed and run with your account's permissions, so read the source before you build it: it is one small bundle on Horizon's native hook points (key, wheel, zoom, mods list). Tested against Horizon `6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a` with Horizon Mixins `0d6b103c70633d8fd9443d8d0acd3aadaacedb06`. A key set in 1.0.0 carries over.
|
||||
|
||||
Source and issues: [github.com/SU4G3/classic-zoom](https://github.com/SU4G3/classic-zoom)"""
|
||||
authors = ["Surge"]
|
||||
|
||||
@@ -11,7 +11,7 @@ Your block position and the direction you face in the top-left corner of the scr
|
||||
It reads the player's position, draws on the HUD and writes to chat: nothing else.
|
||||
|
||||
Now a native mod (2.0.0): built into the game by the launcher, on the Horizon you play, like any native mod
|
||||
(Settings → Game → Native mods (experimental), then Build). It was the WebAssembly SDK's example mod; the time of
|
||||
(install it on the launcher's Mods page, then press Build there). The time of
|
||||
day line is gone, since Horizon's hook points don't give the game's clock yet. Turn it off in Options > Mods: it
|
||||
rests at once, and the next build leaves it out. Its source is
|
||||
[horizon-mods/native/coords-hud](https://github.com/horizon-rust/horizon-mods/tree/main/native/coords-hud).
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
id = "crosshair_indicator"
|
||||
kind = "native-mod"
|
||||
name = "Crosshair Indicator"
|
||||
summary = "Dynamic target reach awareness: visual feedback on your crosshair when hovering over an entity in attack range."
|
||||
description = """
|
||||
Crosshair Indicator
|
||||
|
||||
A native Horizon mod by CayMac that ports the popular Java Crosshair Indicator mod into Horizon:
|
||||
|
||||
Dynamic Target Reach Awareness: Real-time visual feedback directly on your crosshair when hovering over an entity in attack reach (<= 3.0 blocks).
|
||||
Iconic PvP Target Reticle: Default crisp white corner brackets [ + ] hugging the crosshair, matching classic competitive PvP clients.
|
||||
Multiple Styles: Choose between Brackets [ + ], Center Dot, Diamond, and Tactical Chevron.
|
||||
Custom Color Themes: Classic White (Default), Dynamic (Green/Orange cooldown awareness), Combat Red, Pink Aesthetic, Diamond Cyan, and Gold.
|
||||
Cooldown Bar: Optional subtle weapon cooldown progress indicator below the crosshair.
|
||||
Configurable: Easily customize styles, themes, and keybinds in Options > Mods > Crosshair Indicator."""
|
||||
authors = ["CayMac"]
|
||||
owners = ["CayMac97"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/CayMac97/crosshair-indicator"
|
||||
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
|
||||
|
||||
[[versions]]
|
||||
version = "1.0.0"
|
||||
published = "2026-10-31"
|
||||
game = "26.3"
|
||||
file = "crosshair_indicator-1.0.0.hnmod"
|
||||
sha256 = "96beba1f7c94fca492b552fb12af0d20047dc1799f0801607841d56b12786226"
|
||||
signature = """
|
||||
untrusted comment: signature from minisign secret key\r
|
||||
RUS411U2KXdTNz67XDjUwaJ8if7+jDk+qXnJLdhtyjh8mk04UuZ2bme/Tzcpi1Od4ppYmvqzfZ+rEAeG2hb9Lytn+J2nFy1u8gc=\r
|
||||
trusted comment: timestamp:1793461576\tfile:crosshair_indicator-1.0.0.hnmod\thashed\r
|
||||
zF7EJHDjJPwVG+cYcSgvGJv4kjjYRMdMZipUQyABH0DEMIZ2Fe13hb2nbYYRZlNDG1j4kjhgEYQTODFiAlaCBQ=="""
|
||||
changelog = "Initial release for 26.3 Horizon."
|
||||
@@ -12,7 +12,7 @@ An unofficial Rust port of [Distant Horizons](https://gitlab.com/distant-horizon
|
||||
|
||||
Settings: Options > Mods > Distant Horizons. In chat: `/dh` for status, `/dh distance 512`, `/dh quality high`, `/dh debug on` and more (Tab completes them).
|
||||
|
||||
Native source mod: enable Settings → Game → Native mods (experimental), choose Build and confirm the native-code warning. The launcher downloads its own Rust and builds a separate game (10–30 minutes the first time). It runs with your account's permissions.
|
||||
Native source mod: install it on the launcher's Mods page, press Build there and confirm the native-code warning. The launcher downloads its own Rust and builds a separate game (10–30 minutes the first time). It runs with your account's permissions.
|
||||
|
||||
Pinned to Horizon nightly `d009ebd140476b5bdc958c00f56a75e82abec691` (2026-10-29) and Horizon Mixins `0d6b103c70633d8fd9443d8d0acd3aadaacedb06`; the launcher builds these exact revisions. Other Horizon revisions have not been validated. It builds together with Horizon's official native mods; it can't be built with a native mod pinned to a different Horizon commit. Turn Video Settings > LOD Distance off: Horizon's own far terrain draws in the same place.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ kind = "native-mod"
|
||||
name = "Enchantment Hints"
|
||||
summary = "A short description under every enchantment in item tooltips, in English and Russian."
|
||||
description = """
|
||||
**A native Horizon mod**: the launcher builds it into the game (Settings → Game → Native mods, then Build). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
|
||||
**A native Horizon mod**: the launcher builds it into the game (install it on the launcher's Mods page, then press Build there). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
|
||||
|
||||
Every enchantment on books and gear gets a one-line description in its tooltip: what it does, in plain words. Our own text for every 26.3 enchantment, in English and Russian.
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
id = "fair_freecam"
|
||||
kind = "native-mod"
|
||||
name = "Fair Freecam"
|
||||
summary = "Fly the camera freely while your player stays where it is, with an optional fair play mode."
|
||||
description = """
|
||||
Press **F4** to leave your body and fly the camera anywhere. Your player stays where it is and you see it from outside. Press **F4** again to snap back.
|
||||
|
||||
**Controls while flying:**
|
||||
- **W A S D**: fly where you look
|
||||
- **Space / Shift**: up / down
|
||||
- **Ctrl**: 2.5x faster
|
||||
- **Mouse wheel**: change the speed
|
||||
|
||||
**Settings** (Options > Mods > Fair Freecam):
|
||||
- **Speed**: 2, 5, 10, 20, 40 or 80 blocks per second.
|
||||
- **Fair play**: the camera can't fly through blocks, so no peeking into caves or bases through walls. Glass, fences and doors stop it, grass and flowers don't.
|
||||
- **Allowed on servers**: turn it off to only use freecam in your own worlds.
|
||||
|
||||
While flying, your player doesn't move, attack, mine or use items.
|
||||
|
||||
Some servers don't allow freecam, so check their rules first."""
|
||||
authors = ["Lutziano888"]
|
||||
owners = ["Lutziano888"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/Lutziano888/horizon-freecam"
|
||||
icon = "icons/fair_freecam.png"
|
||||
|
||||
[[versions]]
|
||||
version = "1.2.0"
|
||||
published = "2026-10-31"
|
||||
game = "26.3"
|
||||
file = "fair_freecam-1.2.0.hnmod"
|
||||
sha256 = "4fdd0b3067a00d23fe30ee4d594293ef0705325bb5e6e5b292f90c1e8b8a5f8a"
|
||||
changelog = "First release as Fair Freecam (id fair_freecam), so it doesn't clash with other freecam mods."
|
||||
@@ -12,7 +12,7 @@ Horizon's own FPS counter, formerly an extra in Options > Horizon Extras.
|
||||
It reads the game's own frame counter and draws on the HUD: nothing else.
|
||||
|
||||
Now a native mod (2.0.0): built into the game by the launcher, on the Horizon you play, like any native mod
|
||||
(Settings → Game → Native mods (experimental), then Build). Your settings carry over from 1.0.0. Turn it off in
|
||||
(install it on the launcher's Mods page, then press Build there). Your settings carry over from 1.0.0. Turn it off in
|
||||
Options > Mods: it rests at once, and the next build leaves it out. Its source is
|
||||
[horizon-mods/native/fps-overlay](https://github.com/horizon-rust/horizon-mods/tree/main/native/fps-overlay).
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
id = "freecam"
|
||||
kind = "native-mod"
|
||||
name = "Freecam"
|
||||
summary = "Freecam mod ported for Horizon"
|
||||
description = "A rewrite for Horizon of the Freecam mod by the Freecam authors (https://github.com/MinecraftFreecam/Freecam, MIT; on Modrinth as freecam). Its licence notice is kept in the source."
|
||||
authors = ["hakergeniusz"]
|
||||
owners = ["hakergeniusz"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/hakergeniusz/freecam"
|
||||
key = "RWR4cJC6Jj3EWGi26o29Rs3hAIPWqf8koFdXV4bBLF/Tm84QPfRAYjO5"
|
||||
|
||||
[[versions]]
|
||||
version = "0.1.0"
|
||||
published = "2026-10-31"
|
||||
game = "26.3"
|
||||
file = "freecam-0.1.0.hnmod"
|
||||
sha256 = "6314ed87d3b58ed0244bed388201c64bffb8deab4f5ff7beed2a3791b391a6da"
|
||||
signature = """
|
||||
untrusted comment: signature from rsign secret key
|
||||
RUR4cJC6Jj3EWN7yDLuEFss9Xir36oWeHI3yyIK+PlIffoXzHXcZ4eBAN/UcOwGpniH+lJl9uU1vzg8yRe9i6SKNMFhI6qbHkAQ=
|
||||
trusted comment: freecam 0.1.0 for Horizon (Minecraft 26.3), built from github.com/hakergeniusz/freecam commit 78d8f05
|
||||
6sfFvM54vZFE+02rwu0EJVbCnv5dJAJ7PDEWp64QnyEjlWYEnzXDzDAyLzKdZAfXwtvX+igaCS2KJKCk5UY5CA=="""
|
||||
changelog = "Release."
|
||||
@@ -0,0 +1,27 @@
|
||||
id = "freecam_plus"
|
||||
kind = "native-mod"
|
||||
name = "Freecam+"
|
||||
summary = "Fly the camera anywhere while your player stays perfectly still."
|
||||
description = """
|
||||
Press U and your camera leaves your body. Fly it with WASD, Space and Shift, hold Ctrl to go faster and use the mouse wheel to change the speed. Your player does not move or turn, so the server sees you standing still. Through walls and speed can be changed in Options, Mods, Freecam.
|
||||
|
||||
Since this is a native mod it will not work in v0.2.97 as there are no native hooks possible. This works on all nightly releases and LIKELY on version upwards from v0.2.97."""
|
||||
authors = ["_idom_"]
|
||||
owners = ["umbx-dot"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/umbx-dot/horizon-freecam"
|
||||
icon = "icons/freecam_plus.png"
|
||||
key = "RWT9Ujo/oCprPGHHiSLHFNOJV1ZzoJdAU6EdZyiQcBwT2iMXfiYms4hD"
|
||||
|
||||
[[versions]]
|
||||
version = "1.0.0"
|
||||
published = "2026-10-31"
|
||||
game = "26.3"
|
||||
file = "freecam_plus-1.0.0.hnmod"
|
||||
sha256 = "264218191c32e7107982eab297b30a43076c5f1467d4e292cb844213258b5eb1"
|
||||
signature = """
|
||||
untrusted comment: signature from minisign secret key\r
|
||||
RUT9Ujo/oCprPAR7aucj7Ez3exPIc7MGDVH8ns9azj3ilxe+G4JTWb0PnF0YYxAm0FQJJs3DcmHxHjEEfiiWymsAnTG/cdggrQI=\r
|
||||
trusted comment: timestamp:1793450278\tfile:freecam_plus-1.0.0.hnmod\thashed\r
|
||||
/4Di5+zUUSOmYB56u/GDYran5upJjtmfdUff5uKER6lknPGYjsgeLfENH8trB+d8g2hhn70b5+lGxy9mrBfJBg=="""
|
||||
changelog = "Initial Release. "
|
||||
@@ -0,0 +1,19 @@
|
||||
id = "freelook"
|
||||
kind = "native-mod"
|
||||
name = "Freelook"
|
||||
summary = "Hold a key for a third person camera you can look around in"
|
||||
description = """
|
||||
Freelook is a Freelook/Perspective Mod for Fabric, allowing you to move your camera freely without changing your player's rotation.
|
||||
Camera distance can be adjusted by scrolling."""
|
||||
authors = ["Krzysix17"]
|
||||
owners = ["Krzysix17"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/Krzysix17/horizon-freelook"
|
||||
|
||||
[[versions]]
|
||||
version = "0.3.1"
|
||||
published = "2026-10-31"
|
||||
game = "26.3"
|
||||
file = "freelook-0.3.1.hnmod"
|
||||
sha256 = "9c0c92ce9f0fc8e7bc1bd34349e26d42b32b8abd2c7a09ec710936f9b6ce9aaf"
|
||||
changelog = "First release"
|
||||
@@ -3,7 +3,7 @@ kind = "native-mod"
|
||||
name = "MoreGamma"
|
||||
summary = "Brightness past 100%: set your own gamma with /gamma, or toggle full brightness with /fullbright."
|
||||
description = """
|
||||
A native Horizon mod: the launcher builds it into the game (Settings → Game → Native mods, then Build). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Uses only Horizon's native hook points (hooks 1); tested on Horizon 41672b1 and horizon-mixins 0d6b103.
|
||||
A native Horizon mod: the launcher builds it into the game (install it on the launcher's Mods page, then press Build there). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Uses only Horizon's native hook points (hooks 1); tested on Horizon 41672b1 and horizon-mixins 0d6b103.
|
||||
|
||||
See in the dark without torches or night vision. MoreGamma lets you set the brightness (gamma) far past the video settings' 100% "Bright" limit, with a chat command, and switch full brightness on and off.
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
id = "fullbright_horizon"
|
||||
kind = "native-mod"
|
||||
name = "Fullbright"
|
||||
summary = "Toggle maximum 1000% gamma cave and night brightness with smooth fade via keybind G."
|
||||
description = """
|
||||
Maximum brightness and night illumination for the Horizon Rust client.
|
||||
|
||||
Features
|
||||
Press G (rebindable in Options > Mods > Fullbright) to immediately toggle 1000% Gamma cave and night illumination without shadows.
|
||||
Smooth 250ms brightness fade transition and HUD status notification.
|
||||
100% client-side lightmap modification, safe for multiplayer."""
|
||||
authors = ["CayMac"]
|
||||
owners = ["CayMac97"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/CayMac97/fullbright"
|
||||
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
|
||||
|
||||
[[versions]]
|
||||
version = "1.0.0"
|
||||
published = "2026-10-31"
|
||||
game = "26.3"
|
||||
file = "fullbright_horizon-1.0.0.hnmod"
|
||||
sha256 = "db6175eec1711dbc5ab2a2edc40eee9cbc4080ec1d706a3cef688e54ba9cb017"
|
||||
signature = """
|
||||
untrusted comment: signature from minisign secret key\r
|
||||
RUS411U2KXdTN70A+lNWzZF2M2MiOMfjOIqVnTGNY05bQXW47Ov/mHp8io8o9HZiOYQXOYbxhazjWOfGU3WWuRzqj05swkoG/QY=\r
|
||||
trusted comment: timestamp:1793440355\tfile:fullbright_horizon-1.0.0.hnmod\thashed\r
|
||||
mJGiv7YnJHRGreiX5K3mx6zwEtRTC4rAygoIC+rkp1tjUeazIwyehtojb6T+iQBQaq2p97PTor410KnbK675BA=="""
|
||||
changelog = "Initial release for Horizon 26.3."
|
||||
@@ -0,0 +1,23 @@
|
||||
id = "inventory_profiles"
|
||||
kind = "native-mod"
|
||||
name = "Inventory Profiles"
|
||||
summary = "R-key & UI sorting (Category, A-Z, Count, ID), Auto-Refill depleted stacks, Tool Break Protection, and Quick-Stack to chests."
|
||||
description = "A 1:1 native Rust port of Inventory Profiles Next & Inventory Sorter for Horizon. Press R or click the UI sort buttons to merge partial stacks and sort chests or your inventory using 4 selectable modes: Category (Creative Tab), Alphabetical (A-Z), Stack Count (High-Low), or Item ID. Includes automatic hand stack refilling when placing blocks or consuming items, Tool Break Protection that swaps out low-durability tools before they break, and G-key Quick-Stacking to move matching items into open containers."
|
||||
authors = ["CayMac"]
|
||||
owners = ["CayMac97"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/CayMac97/inventory-profiles"
|
||||
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
|
||||
|
||||
[[versions]]
|
||||
version = "1.0.0"
|
||||
published = "2026-10-31"
|
||||
game = "26.3"
|
||||
file = "inventory_profiles-1.0.0.hnmod"
|
||||
sha256 = "1e5d1c75d05230172820b5bc016d7d3ac792adf473de7dbfb84394d8b795a633"
|
||||
signature = """
|
||||
untrusted comment: signature from minisign secret key\r
|
||||
RUS411U2KXdTNzTcNlnhp0xG8ZqxNqwzpSnc+ghT4/m+aR0dga48gdD04aioDSIPK5DBUtd4i2Vf+e4r7LKX3zb6T90aDdYJjgI=\r
|
||||
trusted comment: timestamp:1793443789\tfile:inventory_profiles-1.0.0.hnmod\thashed\r
|
||||
RcTkkWUMpp4nRQ4uXRFCD4m67ir4J0y3lo2nhOVUgriLIMndinAsVjnNsrfAoJLvKN9DO5vT/xp2+oW1iW7xAQ=="""
|
||||
changelog = "Initial release for 26.3 Horizon. "
|
||||
+1
-1
@@ -3,7 +3,7 @@ kind = "native-mod"
|
||||
name = "Item New"
|
||||
summary = "Newly picked-up items glow in your hotbar and inventory until you look at them."
|
||||
description = """
|
||||
**A native Horizon mod**: the launcher builds it into the game (Settings → Game → Native mods, then Build). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
|
||||
**A native Horizon mod**: the launcher builds it into the game (install it on the launcher's Mods page, then press Build there). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
|
||||
|
||||
Items you just got (picked up, crafted, taken from a chest, given) get a soft glow in the hotbar and in inventory and container screens, until you point at them or close the screen.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Horizon's own keystrokes overlay, formerly an extra in Options > Horizon Extras.
|
||||
It reads which keys you hold and your clicks, and draws on the HUD: nothing else.
|
||||
|
||||
Now a native mod (2.0.0): built into the game by the launcher, on the Horizon you play, like any native mod
|
||||
(Settings → Game → Native mods (experimental), then Build). Your settings carry over from 1.0.0. Turn it off in
|
||||
(install it on the launcher's Mods page, then press Build there). Your settings carry over from 1.0.0. Turn it off in
|
||||
Options > Mods: it rests at once, and the next build leaves it out. Its source is
|
||||
[horizon-mods/native/keystrokes](https://github.com/horizon-rust/horizon-mods/tree/main/native/keystrokes).
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
id = "light_overlay"
|
||||
kind = "native-mod"
|
||||
name = "Light Overlay"
|
||||
summary = "1:1 Light Overlay & MiniHUD (F7): visualizes mob-spawnable blocks with 3D Yellow (night spawn) and Red (always/cave spawn) crosses (X) or 3D light level numbers"
|
||||
description = "A 1:1 native Rust port of Light Overlay and MiniHUD's F7 Light Level Overlay for Horizon. Press F7 during gameplay to scan nearby spawnable block surfaces and render depth-tested 3D markers directly on block tops. Red markers indicate blocks where hostile mobs can spawn at any time (caves/indoor darkness), Yellow markers indicate blocks where hostile mobs can spawn at night, and optional Green markers show safe blocks. Supports Crosses (X), 3D Numbers (0-15), or Both, with configurable scan radius (8 to 20 blocks) and Modern (== 0) or Legacy (<= 7) spawn thresholds."
|
||||
authors = ["CayMac"]
|
||||
owners = ["CayMac97"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/CayMac97/light-overlay"
|
||||
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
|
||||
|
||||
[[versions]]
|
||||
version = "1.0.0"
|
||||
published = "2026-10-31"
|
||||
game = "26.3"
|
||||
file = "light_overlay-1.0.0.hnmod"
|
||||
sha256 = "b4aeb56b13d9c73df267a1dc7b1f588f4db39bfd55f7beaef97a014fffb5b076"
|
||||
signature = """
|
||||
untrusted comment: signature from minisign secret key\r
|
||||
RUS411U2KXdTNy4CWemZ33Twp2eJ5FybX79KHuEbkVXRfv1C8+lWasqO1vOdx2A+8oN9eTR9XIh+QKjjw6xwrkG1u2nDoYkk5ww=\r
|
||||
trusted comment: timestamp:1793444149\tfile:light_overlay-1.0.0.hnmod\thashed\r
|
||||
3mGbUejwekJoW98TqQVK9QKS0aXMamM9rfCVOEwot/Xq/ZbaT1kUdDJMo6mn0iJIeodvdAFd7TnDEoguj7jkCA=="""
|
||||
changelog = "Initial release for 26.3 Horizon. "
|
||||
+1
-1
@@ -11,7 +11,7 @@ The block or entity under your crosshair at the top of the screen, drawn as a va
|
||||
|
||||
**Settings** (Options → Mods → LookAt): range, position (top, top left, top right), size, background opacity, item icon, where it comes from, position and distance, entities, health, harvest tool. Boxes keep clear of F3, boss bars, the minimap and effect icons.
|
||||
|
||||
**Now a native mod (0.2.1)**: the launcher builds it into the game (Settings → Game → Native mods (experimental), then Build). Native mods are not sandboxed and run with your account's permissions, so read the source before you build it. Tested against Horizon `142388e1c136e622652ec7505af15d8ad195e041` with Horizon Mixins `0d6b103c70633d8fd9443d8d0acd3aadaacedb06`; the launcher builds these exact pinned revisions. Settings from 0.1.0 carry over. 0.1.0 is the WebAssembly version."""
|
||||
**Now a native mod (0.2.1)**: the launcher builds it into the game (install it on the launcher's Mods page, then press Build there). Native mods are not sandboxed and run with your account's permissions, so read the source before you build it. Tested against Horizon `142388e1c136e622652ec7505af15d8ad195e041` with Horizon Mixins `0d6b103c70633d8fd9443d8d0acd3aadaacedb06`; the launcher builds these exact pinned revisions. Settings from 0.1.0 carry over."""
|
||||
authors = ["d3grando"]
|
||||
owners = ["d3grando"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
id = "menu_panorama"
|
||||
kind = "resourcepack"
|
||||
name = "Menu Panorama"
|
||||
summary = "Put your own wallpaper behind the main menu in Horizon — Minecraft 26.3, rewritten in Rust."
|
||||
description = """
|
||||
Menu Panorama is a resource pack for **Horizon** — Minecraft: Java Edition 26.3, rewritten from scratch in **Rust**.
|
||||
|
||||
It replaces the six textures of the main-menu background with a picture you choose. It is a plain resource pack, not a Java mod: no mod loader, no Java, and no code from the pack is executed. Horizon loads it natively.
|
||||
|
||||
- **Any photo** in the `room` mode: the picture is shown whole on the four walls, with mirrored seams and soft colours above and below.
|
||||
- **A 360° equirectangular panorama** in the `360` mode: the sphere is cut into six cube faces, so the view stays seamless when the camera turns.
|
||||
- **Auto** picks the mode from the image's aspect ratio.
|
||||
|
||||
Build a pack in the browser with `panorama-maker.html`: drop an image, choose a face size, download the `.zip`. Everything runs locally; the picture never leaves the computer. `make_panorama.py` does the same from the command line.
|
||||
|
||||
The pack only changes `assets/minecraft/textures/gui/title/background/panorama_0..5.png`; gameplay, sounds and other textures are untouched. `pack.mcmeta` is made for Horizon 26.3 (resource format 97) and also declares formats 15–999, so the same file loads in Minecraft Java 1.21+. A demo pack is attached to every release.
|
||||
|
||||
MIT licensed. Source, the browser generator and the CLI are in the repository."""
|
||||
authors = ["form1t"]
|
||||
owners = ["form1t"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/form1t/menu-panorama"
|
||||
icon = "icons/menu_panorama.png"
|
||||
|
||||
[[versions]]
|
||||
version = "1.0.0"
|
||||
published = "2026-10-31"
|
||||
game = "26.3"
|
||||
file = "menu_panorama-1.0.0.zip"
|
||||
sha256 = "e0403b003e6c93dec683037202cebb47880025cfe21e2d8fc7818321ee362401"
|
||||
changelog = """
|
||||
First release.
|
||||
|
||||
- Demo sunset pack: six 1024×1024 faces and a 128×128 icon.
|
||||
- `panorama-maker.html`: browser generator with drag & drop, auto/room/360 modes and 512/1024/2048 face sizes.
|
||||
- `make_panorama.py`: CLI with the same modes, preview output and `--description`.
|
||||
- `pack.mcmeta` for Horizon 26.3: resource format 97, `pack_format` 34, formats 15–999.
|
||||
- MIT licence."""
|
||||
+1
-1
@@ -12,7 +12,7 @@ Horizon's own minimap, formerly an extra in Options > Horizon Extras.
|
||||
It shows a map in a corner of the screen: nothing else.
|
||||
|
||||
Now a native mod (2.0.0): built into the game by the launcher, on the Horizon you play, like any native mod
|
||||
(Settings → Game → Native mods (experimental), then Build). Your settings carry over from 1.0.0. Turn it off in
|
||||
(install it on the launcher's Mods page, then press Build there). Your settings carry over from 1.0.0. Turn it off in
|
||||
Options > Mods: it rests at once, and the next build leaves it out. Its source is
|
||||
[horizon-mods/native/minimap](https://github.com/horizon-rust/horizon-mods/tree/main/native/minimap).
|
||||
"""
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
id = "mod_menu"
|
||||
kind = "mod"
|
||||
name = "Mod Menu"
|
||||
summary = "Browse installed mods, including native mods, with versions, enabled states, filters and search."
|
||||
description = """
|
||||
An independent, sandboxed mod browser by levzzz5154.
|
||||
|
||||
Press **M** while playing, run **`/modmenu`**, or open
|
||||
**Options → Mods → Mod Menu → Settings**. Use `/modmenu text` to search names,
|
||||
ids and mod types. Filter all/enabled/disabled mods, browse pages, select a row
|
||||
for details, and refresh after changing mods in the launcher.
|
||||
|
||||
Lists installed sandboxed, content and native source mods in the current instance.
|
||||
Enabled means the launcher's selected state, not proof a mod is running. This is
|
||||
a read-only menu: it does not toggle other mods, run commands, or start native builds.
|
||||
|
||||
**Requires a launcher with the schema-1 Mod Menu inventory feed**
|
||||
([Horizon #3112](https://github.com/horizon-rust/horizon/pull/3112)). On a launcher
|
||||
without it, the menu shows an explicit update message. The launcher supplies display
|
||||
metadata through this mod's own sandbox storage; the mod has no general filesystem
|
||||
or network access. Direct external `--dev-mod` paths and runtime failure status
|
||||
are not included in the feed.
|
||||
|
||||
Tested on Horizon nightly `83f15fa27` (Minecraft 26.3), mod API 0.1 / API level 0.
|
||||
Other Horizon revisions have unverified support. This `.hmod` does not require Rust,
|
||||
Git, Horizon Mixins or any native build to install.
|
||||
|
||||
Independently written, not a port of another project's Mod Menu. No Minecraft code
|
||||
or artwork is included. MIT licensed; source, tests and feed specification are linked below.
|
||||
"""
|
||||
authors = ["levzzz5154"]
|
||||
owners = ["levzzz5154"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/levzzz5154/horizon-mod-menu"
|
||||
|
||||
[[versions]]
|
||||
version = "0.1.1"
|
||||
published = "2026-10-28"
|
||||
game = "26.3"
|
||||
file = "mod_menu-0.1.1.hmod"
|
||||
sha256 = "3833b79bbb9607c5cab6dbdc4a6d3e31754c2ed183b1c6bf431f3615b93ef3b5"
|
||||
changelog = "First release: installed mods, enabled states, native/content/sandboxed types, paging, filters, search and refresh. Requires the launcher inventory feed."
|
||||
@@ -3,7 +3,7 @@ kind = "native-mod"
|
||||
name = "Mouse Tricks"
|
||||
summary = "Inventory mouse tricks: drag to spread or gather, the wheel moves items one by one, and R sorts."
|
||||
description = """
|
||||
**A native Horizon mod**: the launcher builds it into the game (Settings → Game → Native mods, then Build). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
|
||||
**A native Horizon mod**: the launcher builds it into the game (install it on the launcher's Mods page, then press Build there). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
|
||||
|
||||
- **Right-drag**: one item into each slot you pass.
|
||||
- **Left-drag**: gather the same items; with Shift, quick-move many stacks.
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ Horizon's own recipe viewer.
|
||||
It reads the game's items and recipes, hears its own keys and your clicks on the panel, and places recipes or gives items only when you ask: nothing else.
|
||||
|
||||
Now a native mod (2.0.0): built into the game by the launcher, on the Horizon you play, like any native mod
|
||||
(Settings → Game → Native mods (experimental), then Build). It needs Horizon's native hook points version 2, in the
|
||||
(install it on the launcher's Mods page, then press Build there). It needs Horizon's native hook points version 2, in the
|
||||
Nightlies from 4baa571 on. Your settings and keys carry over from 1.0.0. Turn it off in Options > Mods: it rests at
|
||||
once, and the next build leaves it out. Its source is
|
||||
[horizon-mods/native/recipes](https://github.com/horizon-rust/horizon-mods/tree/main/native/recipes).
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ Horizon cannot join servers that check Minecraft accounts yet, which is most pub
|
||||
|
||||
## Install
|
||||
|
||||
Install it from the launcher's Mods page, then Settings > Native mods (experimental) > Build, and play the Native mods instance. Guide: https://github.com/Louchatfroff/serverify/tree/main/horizon/serverify#readme
|
||||
Install it from the launcher's Mods page, then press Build there, and play that instance. Guide: https://github.com/Louchatfroff/serverify/tree/main/horizon/serverify#readme
|
||||
|
||||
Native mods are not sandboxed. The source is public and licensed GPL-3.0-only: https://github.com/Louchatfroff/serverify/tree/main/horizon/serverify"""
|
||||
authors = ["Louchatfroff"]
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ kind = "native-mod"
|
||||
name = "SmartF3"
|
||||
summary = "A tidier, configurable F3 debug screen: the same information in modules you can colour, reorder and turn off."
|
||||
description = """
|
||||
**A native Horizon mod**: the launcher builds it into the game (Settings → Game → Native mods, then Build). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
|
||||
**A native Horizon mod**: the launcher builds it into the game (install it on the launcher's Mods page, then press Build there). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
|
||||
|
||||
The F3 debug screen, grouped into 14 modules: FPS, server, coordinates, chunk, facing, world, biome, light, chart keys, system, rendering, and the targeted block, fluid and entity.
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
id = "status_effect_hud"
|
||||
kind = "native-mod"
|
||||
name = "Status Effect HUD"
|
||||
summary = "1:1 Status Effect Bars & Inventory HUD+ Potion HUD: displays active potion/beacon effects with icons, Roman numeral amplifiers, color-coded mm:ss countdown time"
|
||||
description = "A 1:1 native Rust port of Status Effect Bars and Inventory HUD+ (Potion HUD) for Horizon. Renders all active potion and beacon status effects directly on the HUD during gameplay with their official mob_effect sprites, translated effect names, and Roman numeral potency levels (II, III, IV). Includes color-coded mm:ss countdown timers (White for >30s, Yellow for 10-30s, Flashing Red for <=10s urgent expiration warning, and Aqua infinity symbol for permanent effects) alongside smooth duration progress bars colored in each effect's authentic vanilla potion color."
|
||||
authors = ["CayMac"]
|
||||
owners = ["CayMac97"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/CayMac97/status-effect-hud"
|
||||
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
|
||||
|
||||
[[versions]]
|
||||
version = "1.0.0"
|
||||
published = "2026-10-31"
|
||||
game = "26.3"
|
||||
file = "status_effect_hud-1.0.0.hnmod"
|
||||
sha256 = "afa30125bb8b402892f1719e550b66d4eb4d7264895f71bb9c98a4caf5c7b432"
|
||||
signature = """
|
||||
untrusted comment: signature from minisign secret key\r
|
||||
RUS411U2KXdTNzV+wcfmnupP9b3ECNs7/D2jW8vAV9eAd1rxLpETdSVxvuPpnbTPB/W9APouT8uLbsZLrB9wFsOoBtrhuUPmCw0=\r
|
||||
trusted comment: timestamp:1793443789\tfile:status_effect_hud-1.0.0.hnmod\thashed\r
|
||||
MkNOcfvQQP8dWwvaSdoGIasz6tYOCB13U0OVFIApxz9VzhCUGRZXTYaSitlSSoBfGqYCQk87zvliwvklR0lbDQ=="""
|
||||
changelog = "Initial release for 26.3 Horizon. "
|
||||
@@ -0,0 +1,23 @@
|
||||
id = "toggle_sprint"
|
||||
kind = "native-mod"
|
||||
name = "Toggle Sprint"
|
||||
summary = "1:1 Better Sprinting & Lunar ToggleSprint HUD: toggle sprint and toggle sneak with [Sprinting (Toggled)] / [Key Held] / [Flying] status HUD and configurable Cre"
|
||||
description = "A 1:1 native Rust port of Better Sprinting and the Lunar/Badlion ToggleSprint HUD for Horizon. Allows toggling continuous sprint (V or vanilla Sprint key) and sneak (C) without holding keys down, while automatically pausing toggled sneak inside inventory and chest screens so Shift-clicking items works normally. Renders the classic movement status HUD ([Sprinting (Toggled)], [Sprinting (Key Held)], [Sneaking (Toggled)], [Sneaking (Key Held)], [Flying (2.0x Boost)], [Swimming], [Gliding (Elytra)], [Riding]) and includes a configurable Creative Fly Speed Boost multiplier (1.0x to 4.0x)."
|
||||
authors = ["CayMac"]
|
||||
owners = ["CayMac97"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/CayMac97/toggle-sprint"
|
||||
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
|
||||
|
||||
[[versions]]
|
||||
version = "1.0.0"
|
||||
published = "2026-10-31"
|
||||
game = "26.3"
|
||||
file = "toggle_sprint-1.0.0.hnmod"
|
||||
sha256 = "b2a4190f9c19adbceae4a639342b7630bfc3047339eabf57673b7807db5edc6a"
|
||||
signature = """
|
||||
untrusted comment: signature from minisign secret key\r
|
||||
RUS411U2KXdTN3mUUTEw+mmiuQhcddYpNrjRnGA43/GS3y/qgY1EnL0kGp4NTBouTlVJbiThEkQfOrqIjnwlshUB18PrQloxAAw=\r
|
||||
trusted comment: timestamp:1793443789\tfile:toggle_sprint-1.0.0.hnmod\thashed\r
|
||||
2kNF92yIivWMWhgUH7ypwU3pciKG3ouiHhpZrQmX8fWZHay2da9HgdOYFAUp5KaHJ1NAgBuwL1zMfwVf61X+Cw=="""
|
||||
changelog = "Initial release for 26.3 Horizon. "
|
||||
+13
-6
@@ -7,16 +7,15 @@ An unofficial Rust port by levzzz5154. Mines connected veins, including diagonal
|
||||
through Horizon's player-mining code, preserving loot, XP, tool wear and server block events.
|
||||
Includes per-player toggles, configurable block/tool groups, a block limit and cooldown.
|
||||
|
||||
Native source mod: enable Settings → Game → Native mods (experimental), choose Build,
|
||||
Native source mod: install it on the launcher's Mods page, press Build there,
|
||||
and confirm the native-code warning. A supported launcher downloads its own Rust toolchain
|
||||
and builds a separate game using Horizon Mixins. Requires Git and Horizon's platform build
|
||||
dependencies, and runs with your account's permissions.
|
||||
|
||||
Tested against Horizon nightly `83f15fa276c44711276b6ceb5f0fe0c9c534dc66`
|
||||
(`nightly/build-2026-10-28-3`) with Horizon Mixins 0.5.0 at
|
||||
`0500ff24aa5933fca95a877ef41125353c4b4fe3`. The launcher builds these exact pinned revisions.
|
||||
Other Horizon revisions have not been validated. Ordinary Play does not compile mods;
|
||||
rebuild after changing the enabled native-mod combination.
|
||||
Each version is pinned to one Horizon Nightly and Horizon Mixins revision, named in its
|
||||
changelog; the launcher builds those exact revisions. Other Horizon revisions have not
|
||||
been validated. Ordinary Play does not compile mods; rebuild after changing the enabled
|
||||
native-mod combination.
|
||||
|
||||
Use `/veinminer toggle`, `/veinminer on`, `/veinminer off` or `/veinminer status`.
|
||||
`/veinminer reload` requires operator permission. Survival mining only; install on the
|
||||
@@ -37,3 +36,11 @@ game = "26.3"
|
||||
file = "veinminer-0.3.0.hnmod"
|
||||
sha256 = "ec308c02c65bad64043828a19ef6254d1eb9d422bb1123cd065cb9e6e7b126c8"
|
||||
changelog = "Native launcher bundle, pinned to nightly 83f15fa27 and Horizon Mixins 0.5.0."
|
||||
|
||||
[[versions]]
|
||||
version = "0.3.2"
|
||||
published = "2026-10-31"
|
||||
game = "26.3"
|
||||
file = "veinminer-0.3.2.hnmod"
|
||||
sha256 = "31a4249da1b35972ae8b34201373c86352f4adc2f5cb3e015749d1fbfecf00a0"
|
||||
changelog = "Pinned to Nightly 2026-10-31 (d91cab6) and Horizon Mixins 4342a0b. Reads each level's height from the loaded world, so data-pack dimension heights are respected."
|
||||
@@ -0,0 +1,19 @@
|
||||
id = "worldedit"
|
||||
kind = "native-mod"
|
||||
name = "WorldEdit"
|
||||
summary = "working port of java world edit"
|
||||
description = """
|
||||
//we for commands
|
||||
|
||||
working schematics."""
|
||||
authors = ["_cykoo_"]
|
||||
owners = ["XnotCykoX"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/XnotCykoX/WorldEdit-Horizon"
|
||||
|
||||
[[versions]]
|
||||
version = "1.2.1"
|
||||
published = "2026-10-30"
|
||||
game = "26.3"
|
||||
file = "worldedit-1.2.1.hnmod"
|
||||
sha256 = "b2bb3019ecd4d5392644c54c70a87d6c3277538a6e18d0c311f3a051f7d2f015"
|
||||
+1
-1
@@ -13,7 +13,7 @@ Horizon's own zoom, formerly an extra in Options > Horizon Extras.
|
||||
It hears its own key and the mouse wheel while you hold it, and zooms the camera: nothing else.
|
||||
|
||||
Now a native mod (2.0.0): built into the game by the launcher, on the Horizon you play, like any native mod
|
||||
(Settings → Game → Native mods (experimental), then Build). Your settings carry over from 1.0.0. Turn it off in
|
||||
(install it on the launcher's Mods page, then press Build there). Your settings carry over from 1.0.0. Turn it off in
|
||||
Options > Mods: it rests at once, and the next build leaves it out. Its source is
|
||||
[horizon-mods/native/zoom](https://github.com/horizon-rust/horizon-mods/tree/main/native/zoom).
|
||||
"""
|
||||
|
||||
@@ -6,12 +6,13 @@ Your block position and the direction you face in a box in the top-left corner o
|
||||
- `/coords` hides or shows the box, and the game keeps your choice (also Options > Mods > Coordinates HUD > Settings).
|
||||
|
||||
It is a [horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundle (`mixins/coords_hud/`) that the Horizon
|
||||
launcher builds into the game. It replaces the WebAssembly Coordinates HUD 0.1.0, the WebAssembly SDK's example mod.
|
||||
launcher builds into the game. It is a small example mod.
|
||||
The time of day line is gone: Horizon's hook points don't give the game's clock yet.
|
||||
|
||||
## Install
|
||||
|
||||
In the Horizon launcher: Mods → Coordinates HUD → Install, then Settings → Game → Native mods (experimental) → Build. The
|
||||
In the Horizon launcher: Mods → Coordinates HUD → Install, then **Build** on the same page (once built, switch it
|
||||
on or off in the game under Options → Mods). The
|
||||
launcher downloads its own Rust and builds the game with your native mods; see Horizon's
|
||||
[native mods](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md).
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
Horizon's official FPS counter: frames per second in a box in a corner of the screen, and, if you like, the slowest frame and the 1% low of the last 5 seconds. Its settings page (Options > Mods > FPS Counter > Settings) picks the corner and the lows.
|
||||
|
||||
It is a [horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundle (`mixins/fps_overlay/`) that the Horizon
|
||||
launcher builds into the game. It replaces the WebAssembly FPS Counter 1.x and keeps its settings: the same files (`horizon-mods/.horizon-mod-data/fps_overlay/corner`, `lows`).
|
||||
launcher builds into the game. It keeps the settings of earlier versions: the same files (`horizon-mods/.horizon-mod-data/fps_overlay/corner`, `lows`).
|
||||
|
||||
## Install
|
||||
|
||||
In the Horizon launcher: Mods → FPS Counter → Install, then Settings → Game → Native mods (experimental) → Build. The
|
||||
In the Horizon launcher: Mods → FPS Counter → Install, then **Build** on the same page (once built, switch it
|
||||
on or off in the game under Options → Mods). The
|
||||
launcher downloads its own Rust and builds the game with your native mods; see Horizon's
|
||||
[native mods](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md).
|
||||
|
||||
@@ -38,6 +39,6 @@ python3 native/tools/native.py proof fps-overlay --resources /path/to/.local/hor
|
||||
```
|
||||
|
||||
The proof needs resources prepared from your own Minecraft 26.3 (`horizon-prepare`); none are in this repository.
|
||||
It seeds the WebAssembly mod's settings (bottom right, lows on) in a temporary game folder, then checks that Options > Mods shows them, and that the Corner button moves the box to the top left and writes the same file. CI runs `cargo test`, packages the `.hnmod` and checks the game on the tested commit, and every Monday on the newest Nightly.
|
||||
It seeds an earlier version's settings (bottom right, lows on) in a temporary game folder, then checks that Options > Mods shows them, and that the Corner button moves the box to the top left and writes the same file. CI runs `cargo test`, packages the `.hnmod` and checks the game on the tested commit, and every Monday on the newest Nightly.
|
||||
|
||||
License: MIT.
|
||||
@@ -3,11 +3,12 @@
|
||||
Horizon's official Keystrokes: your movement keys, mouse buttons and space bar in a corner, lit while held, with clicks per second. Its settings page (Options > Mods > Keystrokes > Settings) picks the corner and the clicks per second.
|
||||
|
||||
It is a [horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundle (`mixins/keystrokes/`) that the Horizon
|
||||
launcher builds into the game. It replaces the WebAssembly Keystrokes 1.x and keeps its settings: the same files (`horizon-mods/.horizon-mod-data/keystrokes/corner`, `cps`).
|
||||
launcher builds into the game. It keeps the settings of earlier versions: the same files (`horizon-mods/.horizon-mod-data/keystrokes/corner`, `cps`).
|
||||
|
||||
## Install
|
||||
|
||||
In the Horizon launcher: Mods → Keystrokes → Install, then Settings → Game → Native mods (experimental) → Build. The
|
||||
In the Horizon launcher: Mods → Keystrokes → Install, then **Build** on the same page (once built, switch it
|
||||
on or off in the game under Options → Mods). The
|
||||
launcher downloads its own Rust and builds the game with your native mods; see Horizon's
|
||||
[native mods](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md).
|
||||
|
||||
@@ -38,6 +39,6 @@ python3 native/tools/native.py proof keystrokes --resources /path/to/.local/hori
|
||||
```
|
||||
|
||||
The proof needs resources prepared from your own Minecraft 26.3 (`horizon-prepare`); none are in this repository.
|
||||
It seeds the WebAssembly mod's settings (bottom right, no clicks per second) in a temporary game folder, then checks that Options > Mods shows them, and that the Corner button moves the keys to the top left and writes the same file. CI runs `cargo test`, packages the `.hnmod` and checks the game on the tested commit, and every Monday on the newest Nightly.
|
||||
It seeds an earlier version's settings (bottom right, no clicks per second) in a temporary game folder, then checks that Options > Mods shows them, and that the Corner button moves the keys to the top left and writes the same file. CI runs `cargo test`, packages the `.hnmod` and checks the game on the tested commit, and every Monday on the newest Nightly.
|
||||
|
||||
License: MIT.
|
||||
@@ -3,11 +3,12 @@
|
||||
Horizon's official Minimap: a map of the terrain around you in a corner, in map colours, square or round, north up or turning with you, drawn by Horizon's own minimap. Its settings page (Options > Mods > Minimap > Settings) picks the corner, size, shape, zoom and turning.
|
||||
|
||||
It is a [horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundle (`mixins/minimap/`) that the Horizon
|
||||
launcher builds into the game. It replaces the WebAssembly Minimap 1.x and keeps its settings: the same files (`horizon-mods/.horizon-mod-data/minimap/corner`, `size`, `shape`, `zoom`, `rotate`).
|
||||
launcher builds into the game. It keeps the settings of earlier versions: the same files (`horizon-mods/.horizon-mod-data/minimap/corner`, `size`, `shape`, `zoom`, `rotate`).
|
||||
|
||||
## Install
|
||||
|
||||
In the Horizon launcher: Mods → Minimap → Install, then Settings → Game → Native mods (experimental) → Build. The
|
||||
In the Horizon launcher: Mods → Minimap → Install, then **Build** on the same page (once built, switch it
|
||||
on or off in the game under Options → Mods). The
|
||||
launcher downloads its own Rust and builds the game with your native mods; see Horizon's
|
||||
[native mods](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md).
|
||||
|
||||
@@ -38,6 +39,6 @@ python3 native/tools/native.py proof minimap --resources /path/to/.local/horizon
|
||||
```
|
||||
|
||||
The proof needs resources prepared from your own Minecraft 26.3 (`horizon-prepare`); none are in this repository.
|
||||
It seeds the WebAssembly mod's settings (bottom right, round, 2 blocks per pixel) in a temporary game folder, then checks that Options > Mods shows them, and that the Corner button moves the map to the top left and writes the same file. CI runs `cargo test`, packages the `.hnmod` and checks the game on the tested commit, and every Monday on the newest Nightly.
|
||||
It seeds an earlier version's settings (bottom right, round, 2 blocks per pixel) in a temporary game folder, then checks that Options > Mods shows them, and that the Corner button moves the map to the top left and writes the same file. CI runs `cargo test`, packages the `.hnmod` and checks the game on the tested commit, and every Monday on the newest Nightly.
|
||||
|
||||
License: MIT.
|
||||
@@ -7,13 +7,14 @@ Settings): a click on an item in the panel gives you one, a right click a stack.
|
||||
Options > Mods.
|
||||
|
||||
It is a [horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundle (`mixins/recipes/`) that the Horizon
|
||||
launcher builds into the game. It replaces the WebAssembly Recipes 1.x and keeps its settings: the same file
|
||||
launcher builds into the game. It keeps the settings of earlier versions: the same file
|
||||
(`horizon-mods/.horizon-mod-data/recipes/give`) and the same keys (`key.recipes.recipe`, `key.recipes.uses` in
|
||||
`horizon-mods.txt`).
|
||||
|
||||
## Install
|
||||
|
||||
In the Horizon launcher: Mods → Recipes → Install, then Settings → Game → Native mods (experimental) → Build. The
|
||||
In the Horizon launcher: Mods → Recipes → Install, then **Build** on the same page (once built, switch it
|
||||
on or off in the game under Options → Mods). The
|
||||
launcher downloads its own Rust and builds the game with your native mods; see Horizon's
|
||||
[native mods](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md).
|
||||
|
||||
@@ -44,7 +45,7 @@ python3 native/tools/native.py proof recipes --resources /path/to/.local/horizon
|
||||
```
|
||||
|
||||
The proof needs resources prepared from your own Minecraft 26.3 (`horizon-prepare`); none are in this repository.
|
||||
It seeds the WebAssembly mod's setting ("Click gives items" on) in a temporary game folder, checks that Options > Mods
|
||||
It seeds an earlier version's setting ("Click gives items" on) in a temporary game folder, checks that Options > Mods
|
||||
shows the mod, its keys and the setting, then opens the inventory: the panel shows items, a click on one in creative
|
||||
mode gives it, and with the setting turned off the same click opens its recipe page.
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-smp"
|
||||
version = "0.5.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "horizon-mod-smp"
|
||||
version = "0.5.0"
|
||||
version = "0.15.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
+129
-9
@@ -8,15 +8,19 @@ Players install nothing: it speaks only what a stock Horizon client already show
|
||||
lines, vanilla chest menus the server fills with ordinary items (the trade menu); later sounds, titles, the action
|
||||
bar, boss bars and the sidebar).
|
||||
|
||||
This version: the economy, the server market, player trades, the coinflip and the lottery.
|
||||
This version: the economy, the server market and its `/shop` menu, player trades, the coinflip, the lottery, places (spawn, homes, warps,
|
||||
teleport requests, the rules), land claims, and the scoreboard (a sidebar with your balance, playtime and the richest players, the Tab list's
|
||||
header and footer).
|
||||
|
||||
| Command | Does |
|
||||
|---|---|
|
||||
| `/balance [player]`, `/bal` | your balance, or another player's |
|
||||
| `/pay <player> <amount>` | gives money to a player who has an account (anyone who has played since the mod was on) |
|
||||
| `/baltop [page]` | the richest players, 10 a page |
|
||||
| `/eco give\|take\|set <player> <amount>` | operators of level 3: corrections, logged |
|
||||
| `/worth [item]` | what the market pays and asks for an item (the one in your hand without a name) |
|
||||
| `/eco give\|take\|set <player> <amount>`, `/money ...` | operators of level 3: corrections, logged (`/money` is the same command); the player given money is told |
|
||||
| `/eco give * <amount>` | operators of level 3: to every player on, one logged change each |
|
||||
| `/shop` | the market as a chest menu: categories, a click buys one, a shift-click 64, a click on a stack of yours sells it, "Sell all" |
|
||||
| `/worth [item]` | what the market pays and asks for an item (the one in your hand without a name), and what you may still sell today |
|
||||
| `/sell hand [count]`, `/sell <item> [count]`, `/sell all` | sells to the market: the stack in your hand, an item, or everything it buys |
|
||||
| `/buy <item> [count]` | buys from the market, only what fits in your inventory |
|
||||
| `/trade <player>` | asks a player to trade (60 s to answer) |
|
||||
@@ -26,11 +30,79 @@ This version: the economy, the server market, player trades, the coinflip and th
|
||||
| `/coinflip <amount> [player]` | stakes an amount for anyone (or one player) to match; off unless `gambling.enabled` |
|
||||
| `/lottery`, `/lottery buy <tickets>` | the pot, your tickets and the time to the draw; buys tickets (off unless `gambling.enabled`) |
|
||||
| `/coinflip accept <player>`, `/coinflip cancel`, `/coinflip list` | takes an offer (a fair coin decides, the winner gets both stakes), withdraws yours, lists those waiting |
|
||||
| `/spawn` | to the server's spawn; `/setspawn` (operators of level 3) sets it where you stand |
|
||||
| `/home [name]`, `/sethome [name]`, `/delhome [name]`, `/homes` | your homes (`home` when unnamed): go, set or move one where you stand, delete, list |
|
||||
| `/warp <name>`, `/warps` | to a warp, the list; `/setwarp <name>`, `/delwarp <name>` for operators of level 3 |
|
||||
| `/tpa <player>`, `/tpaccept [player]`, `/tpdeny [player]` | asks to go to a player (60 s to answer); answers a request |
|
||||
| `/rules` | the server's rules |
|
||||
| `/back` | back to where you died, or where your last teleport took you from (warmup and cooldown as any teleport) |
|
||||
| `/afk` | marks you away from the keyboard until you move, turn or run a command |
|
||||
| `/playtime [player]` | how long you (or a player who is on) have played here |
|
||||
| `/claim`, `/claim info`, `/unclaim` | claims the chunk you stand in (all its height), says whose it is, frees it (operators of level 3: anyone's) |
|
||||
| `/claim show` | draws the border of the chunk you stand in for 10 s, for you only: green yours or a trusting owner's, red someone else's, white free |
|
||||
| `/claims` | your chunks, how many you may hold, and who you trust |
|
||||
| `/trust <player>`, `/untrust <player>` | lets a player (or no longer) build and use things on all your land |
|
||||
| `/nick <name>`, `/nick off`, `/nick` | another name for you in chat and the Tab list, shown as `~name` (hovering it gives your real one); one change every 10 minutes, after an hour of play; `/nick` alone says how to take it off |
|
||||
| `/nick <player> <name\|off>` | operators of level 3: sets or takes off a player's nickname |
|
||||
| `/realname <nick>` | who goes by that nickname |
|
||||
| `/sidebar off`, `/sidebar on`, `/sidebar` | hides the scoreboard sidebar for you (remembered), shows it again |
|
||||
|
||||
The market trades only plain items (not worn, named or enchanted), and only with players in survival or adventure
|
||||
mode. Items move first, then money, both logged; a sale the log refuses gives the items back, and a purchase checks the
|
||||
money and the room before anything moves.
|
||||
|
||||
**No way to get rich from the market.** The market makes money only from what players gather, and only so much a day:
|
||||
- Buying always costs more than selling: an item whose `sell` is not below its `buy` isn't bought (the server log
|
||||
says so at the start).
|
||||
- No chain of recipes turns what the market sells into something it buys for more: the cheapest way to get each item
|
||||
from the market (bought, or crafted, smelted, cut from bought items, through any number of recipes) is worked out,
|
||||
and an item the market would buy for more than that isn't bought. The tests walk every vanilla recipe
|
||||
(`SMP_VANILLA_DATA=<.local/horizon-client/26.3/data/minecraft> cargo test`); the server checks the iron, gold,
|
||||
copper, diamond, emerald, coal, redstone, lapis, wheat, wood and food chains at each start.
|
||||
- Each player sells at most `daily_sell_coins` (2,000 coins) and `daily_sell_items` (640 of each item) a UTC day,
|
||||
`/worth` and the shop show what is left, and `market.json` keeps the day's count across restarts.
|
||||
- Amounts are checked: no price above 1,000,000,000,000, no sum that overflows.
|
||||
- The coinflip and the lottery give the winner the stakes (less `fee_percent`, 0 by default): they move money
|
||||
between players and never make any.
|
||||
|
||||
Default prices (coins, buy / sell; `-` not traded that way):
|
||||
|
||||
| Category | Items |
|
||||
|---|---|
|
||||
| Farming | wheat 3/1, wheat seeds 2/-, carrot 3/1, potato 3/1, beetroot 3/1, sugar cane 3/1, pumpkin 8/3, melon slice 2/1, cactus 3/1, bone meal 3/- |
|
||||
| Mining | coal 8/2, copper ingot 6/2, iron ingot 30/8, gold ingot 40/10, redstone 4/1, lapis 8/2, quartz 8/2, emerald 80/20, diamond 400/100 |
|
||||
| Wood | the eight logs 4/1 |
|
||||
| Mob drops | rotten flesh -/1, bone 3/1, string 3/1, gunpowder 8/2, spider eye 4/1, slime ball 10/2, blaze rod 30/8, leather 6/2, feather 2/1 |
|
||||
| Building | cobblestone 1/-, stone 2/-, dirt 1/-, sand 2/-, gravel 2/-, glass 3/-, bricks 4/-, stone bricks 3/- |
|
||||
| Food | bread 5/-, cooked beef 6/-, cooked porkchop 6/-, cooked chicken 5/-, baked potato 4/-, apple 4/-, golden carrot 40/- |
|
||||
| Tools | torch 1/-, ladder 2/-, crafting table 4/-, chest 6/-, furnace 6/- |
|
||||
|
||||
A `config.json` still holding the 18 prices of versions 0.1 to 0.11, never edited, gets these at the first start of
|
||||
0.12.0; edited prices are kept. Only items that stack to 64 belong in the market: a shift-click buys 64, and what
|
||||
doesn't fit falls at the player's feet for them.
|
||||
|
||||
**Nicknames.** 3 to 16 letters, digits or `_`, shown with a `~` in front (`~Rocky`) so a nickname never passes for
|
||||
a real name. Names are compared case-insensitively with `_` dropped and look-alikes folded (`0`→o, `1`/`i`→l, `3`→e,
|
||||
`4`→a, `5`→s, `7`→t, `8`→b, `9`→g). Refused: a nickname equal to, or containing (from 4 letters), the name of anyone
|
||||
who has played here (the economy's accounts and `usercache.json`), an operator (`ops.json`), the Team (`protected`)
|
||||
or another player's nickname; one that reads as a reserved word (`reserved`: server, console, admin, mod, owner,
|
||||
staff, op, horizon, kira, notch, mojang, herobrine...); one the server's chat filter refuses. A player needs
|
||||
`min_playtime_minutes` of play before their first nickname (operators don't). At each start, nicknames that break
|
||||
these rules are taken off, each logged (`[smp] nickname X of <uuid> taken off: why`). The change is made at the next
|
||||
second. The nickname shows in chat (messages, `/me`, `/msg`) and the Tab list, and hovering it gives the real name;
|
||||
the real name stays everywhere else: the server's log, the anticheat, selectors, the Discord bridge. `/pay`, `/tpa`,
|
||||
`/trade`, `/playtime`, `/tpaccept`, `/tpdeny` and `/eco` take a nickname (with or without `~`) wherever they take a
|
||||
player's name. The player's profile (name, UUID) never changes.
|
||||
`config.json`'s `nicks`: `enabled`, `cooldown_seconds` (600), `min_playtime_minutes` (60), `protected` (the Team's
|
||||
names), `reserved` (the words above).
|
||||
|
||||
**The shop.** `/shop` opens a 6-row chest menu on the market's prices: the categories of `config.json`'s `shop` (an
|
||||
item in none goes under "Other"), each item named with its prices. A click buys one, a shift-click 64; a click on a
|
||||
stack in your own inventory, below the menu, sells it; "Sell all" sells everything the market buys. The bottom row
|
||||
shows your balance and today's selling limit. It goes through the market's own rules (survival or adventure only,
|
||||
items then money, logged, the daily limits); the items move by the console's `clear` and `give`, measured on the
|
||||
inventory before and after, so the money follows what really moved.
|
||||
|
||||
**Trades.** Once accepted, both players see a 6-row chest menu: their own offer on the left (16 stacks), the other's
|
||||
on the right, each side's money, their confirm and cancel buttons, and whether the other has confirmed. A click on a
|
||||
stack in your inventory, below the menu, adds it to your offer; a click on it in your offer takes it back. The menu
|
||||
@@ -52,6 +124,44 @@ from a round's first ticket to its draw). Tickets are held like coinflip stakes;
|
||||
random among all sold (more tickets, more chances), its owner gets the pot (less `fee_percent`), and everyone hears
|
||||
it. A round with a single player, or one the server stopped with, gives every ticket back.
|
||||
|
||||
**Places.** A teleport waits `warmup_seconds` (3) in `config.json`'s `places`; a player who moves or is hurt meanwhile
|
||||
stays where they are, and `cooldown_seconds` (10) passes between two of one player's teleports. `/tpa` takes the
|
||||
asker to where the other player is when the warmup ends. Homes grow with the time played on the server since the mod
|
||||
was on (`homes`: `[hours, homes]`, by default 1 home, 2 from 5 hours, 3 from 20, 5 from 100). `rules` holds `/rules`'
|
||||
lines. Teleports are vanilla's `tp`, run as the server (`execute in <level> run tp`), so they cross levels and land
|
||||
as any teleport does.
|
||||
|
||||
**Claims.** In a claimed chunk, only its owner, the players they trust and operators of level 3 break blocks or right
|
||||
click blocks: doors, chests, levers, buttons, placing a block, a bucket or flint on a block. Anyone else's click does
|
||||
nothing, and the action bar says whose land it is. The chunks a player may hold grow with the time played here
|
||||
(`claims.chunks`: `[hours, chunks]`, by default 4, 16 from 5 hours, 36 from 20, 64 from 100); `claims.enabled`
|
||||
turns them off. The world stays out too: no water or lava flows, no fire spreads or burns, and no piston pushes into
|
||||
a claim from land that isn't its owner's (or a player's they trust), and explosions break no claimed block, whoever
|
||||
lit them. Buckets are guarded where they fill or empty, and hitting the claim's item frames, armour stands,
|
||||
paintings, boats, minecarts and animals does nothing for others (`claims.protected_entities`, entity ids; monsters
|
||||
and players aren't in it). Not guarded yet: right clicking an entity (rotating a frame's item, an armour stand's
|
||||
items, shearing, leads), which waits for Horizon's hook (#4648). The server's `spawn-protection` (vanilla's) guards
|
||||
the spawn.
|
||||
|
||||
**The scoreboard.** A sidebar on the right, titled `board.title` ("Horizon SMP"): your own block first (your name, or
|
||||
your nickname; your balance, `125.4k`, `3.2M`; your playtime, `2h 05m`; the players online), then the `board.top` (3)
|
||||
richest players with their balance, then `board.footer` (`play.playhorizon.dev`, empty for none). The ranking is
|
||||
built once for everyone, only when a balance changed (at most every `board.min_seconds`, 5) or every
|
||||
`board.refresh_seconds` (60); each player's own lines are looked at on the same `board.refresh_seconds`, and a
|
||||
sidebar is sent only to the players whose lines changed and to newcomers: nothing runs per tick per player. The Tab
|
||||
list shows `board.tab_header` and `board.tab_footer` (`{server}`, `{tps}`, `{players}` filled in, every
|
||||
`board.tab_refresh_seconds`, 5). `board.sidebar` and `board.tab` turn either off; `board.server_name` names the Tab
|
||||
list. A server that kept 0.13's defaults (`Richest players`, the top 10) gets these at its first start of 0.14. The
|
||||
sidebar uses one scoreboard objective of its own (`horizon:native_sidebar`) and leaves the world's scoreboard alone.
|
||||
|
||||
**Presence.** A player's first join is announced to everyone (`presence.first_join`, empty for none) and they read
|
||||
`presence.welcome` and the rules. Each player gets `presence.daily_reward` (50) once a day (UTC) for being on, logged
|
||||
as `daily_reward` (0 turns it off). A player who hasn't moved, turned or run a command for `presence.afk_minutes` (5),
|
||||
or typed `/afk`, is AFK: they don't count for sleeping (vanilla's `players_sleeping_percentage`) until they move or
|
||||
get in a bed; `presence.afk_announce` tells everyone instead of only them. A player runs at most
|
||||
`presence.spam_commands` (8) of the SMP's commands in `presence.spam_seconds` (5); operators of level 3 aren't
|
||||
limited. All of it is looked at once a second.
|
||||
|
||||
The currency is earned in game only: it can't be bought with real money or cashed out (Mojang's usage guidelines).
|
||||
|
||||
## Data
|
||||
@@ -61,8 +171,14 @@ folder is untouched until the mod first runs on it; it changes no block and no p
|
||||
|
||||
| File | Holds |
|
||||
|---|---|
|
||||
| `config.json` | `currency` (default `coins`), `starting_balance` (default 0), and `market`: `enabled` and `items`, each item's `buy` (what the market asks) and `sell` (what it pays), 0 closing that side; written with the defaults the first time, and again when a newer version adds keys (your values kept) |
|
||||
| `config.json` | `currency` (default `coins`), `starting_balance` (default 0), `market`: `enabled`, `items` (each item's `buy`, what the market asks, and `sell`, what it pays, 0 closing that side), `daily_sell_coins` (2000) and `daily_sell_items` (640), 0 for no limit; `shop`: `enabled`, `title`, `categories` (`name`, `icon`, `items`); written with the defaults the first time, and again when a newer version adds keys (your values kept) |
|
||||
| `economy.json` | balances by player UUID, with the name last seen, and `held`: coinflip stakes and lottery tickets being decided |
|
||||
| `places.json` | the spawn, the warps, each player's homes, and the seconds each player has played (written once a minute) |
|
||||
| `nicks.json` | each player's nickname and when they last changed it |
|
||||
| `market.json` | what each player sold today (coins and items), for the daily limits |
|
||||
| `presence.json` | the players who have joined (no welcome again), and the day each last got the daily reward |
|
||||
| `board.json` | the players who hid the sidebar (`/sidebar off`) |
|
||||
| `claims.json` | each claimed chunk (`<dimension> <chunk x> <chunk z>`) with its owner, and whom each owner trusts |
|
||||
| `audit.log` | one JSON line per change, written before the change: `time` (ms), `kind` (`open`, `pay`, `eco_give`...), `amount`, `from`/`to` with their balance before, `by` for an operator; a trade's line (`trade`: both players, their items and money) comes before its swap, `trade_failed` after one that didn't go through |
|
||||
|
||||
`economy.json` is written whole to a temporary file, flushed and renamed over the old one, at most once a server tick
|
||||
@@ -72,15 +188,19 @@ commands say so) rather than start from nothing.
|
||||
## Made on the hook points
|
||||
|
||||
It hooks only Horizon's native hook points: `native_register_commands` and `native_perform_command` (API 8), the
|
||||
acting player's items (API 9), the server menus and `native_exchange_items` (API 10), the server tick and the world
|
||||
lifecycle. `compatibility.json` names `"hooks": 10`.
|
||||
acting player's items (API 9), the server menus and `native_exchange_items` (API 10), the players' health and
|
||||
`native_run_command` (API 21), `native_destroy_block` (API 14), `native_right_click_block`, the acting player and the
|
||||
action bar (API 21), `native_block_spread`, `native_piston_move`, `native_explosion_detonate`, `native_fill_bucket` and
|
||||
`native_attack_entity` (API 22), `native_sidebar`, `native_tab_list` and `native_tick_stats` (API 49),
|
||||
`native_set_nickname` and `native_text_allowed` (API 62), the server tick and the world lifecycle.
|
||||
`compatibility.json` names `"hooks": 58`.
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `mixins/smp/horizon-mixin.toml` | The bundle: seven modules and the HEAD hooks |
|
||||
| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs`, `smp_trade.rs`, `smp_games.rs`, `smp_lottery.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_trade`, `game::smp_games`, `game::smp_lottery`; also this crate's modules (`src/lib.rs`), for their tests |
|
||||
| `mixins/smp/horizon-mixin.toml` | The bundle: fourteen 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` | 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`; 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}.rs` | One small helper per hook |
|
||||
| `mixins/smp/{register,command,tick,lifecycle,menu_clicked,menu_closed,destroy_block,right_click_block,block_spread,piston_move,explosion_detonate,fill_bucket,attack_entity}.rs` | One small helper per hook |
|
||||
| `proof.py` | A dedicated server built with the mod and two real clients, on fresh temporary data |
|
||||
|
||||
## Build and prove
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"game": "26.3",
|
||||
"hooks": 10,
|
||||
"hooks": 62,
|
||||
"horizon_repository": "https://github.com/horizon-rust/horizon",
|
||||
"horizon_revision": "f533c3af7d41d7ecc3b015a9fbb0dbf5eb64ae26",
|
||||
"horizon_revision": "c348d5da9c488f1d2453b0d2b03c2751b2379b05",
|
||||
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
|
||||
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// A player hits an entity: a claim's frames, armor stands and animals are
|
||||
// kept from players its owner doesn't trust.
|
||||
fn smp_attack_entity(
|
||||
game: &mut crate::game::Game<'_>,
|
||||
kind: &str,
|
||||
pos: [f64; 3],
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
crate::game::smp_glue::attack(game, kind, pos, canceled);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// A fluid flows or fire spreads into a block: kept out of the land others
|
||||
// claimed.
|
||||
fn smp_block_spread(
|
||||
dimension: crate::game::LevelDimension,
|
||||
from: horizon_core::position::BlockPos,
|
||||
to: horizon_core::position::BlockPos,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let dimension = crate::game::smp_glue::dimension_id(dimension);
|
||||
let (from, to) = ([from.x, from.y, from.z], [to.x, to.y, to.z]);
|
||||
if !crate::game::smp_glue::world_allows(dimension, from, &[to]) {
|
||||
*canceled = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// A player breaks a block: kept out of the land others claimed.
|
||||
fn smp_destroy_block(game: &mut crate::game::Game<'_>, pos: [i32; 3], canceled: &mut bool) {
|
||||
crate::game::smp_glue::guard(game, pos, canceled);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// An explosion's blocks: claimed land is spared.
|
||||
fn smp_explosion_detonate(
|
||||
game: &crate::game::Game<'_>,
|
||||
dimension: &str,
|
||||
blocks: &mut Vec<[i32; 3]>,
|
||||
) {
|
||||
crate::game::smp_glue::explosion(game, dimension, blocks);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// A player fills or empties a bucket: kept out of the land others claimed.
|
||||
fn smp_fill_bucket(game: &mut crate::game::Game<'_>, pos: [i32; 3], canceled: &mut bool) {
|
||||
crate::game::smp_glue::guard(game, pos, canceled);
|
||||
}
|
||||
+881
-24
File diff suppressed because it is too large.
Load diff
@@ -1,12 +1,12 @@
|
||||
[mod]
|
||||
id = "smp"
|
||||
version = "0.5.0"
|
||||
version = "0.15.0"
|
||||
priority = 100
|
||||
|
||||
# The commit of horizon main it was tested on. It uses only the native hook points (hooks 10 in
|
||||
# The commit of horizon main it was tested on. It uses only the native hook points (hooks 62 in
|
||||
# compatibility.json): it builds on any Horizon that has them.
|
||||
[compatibility]
|
||||
horizon_revision = "f533c3af7d41d7ecc3b015a9fbb0dbf5eb64ae26"
|
||||
horizon_revision = "c348d5da9c488f1d2453b0d2b03c2751b2379b05"
|
||||
|
||||
[[module]]
|
||||
id = "store"
|
||||
@@ -26,6 +26,18 @@ file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_market"
|
||||
source = "smp_market.rs"
|
||||
|
||||
[[module]]
|
||||
id = "recipes"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_recipes"
|
||||
source = "smp_recipes.rs"
|
||||
|
||||
[[module]]
|
||||
id = "shop"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_shop"
|
||||
source = "smp_shop.rs"
|
||||
|
||||
[[module]]
|
||||
id = "trade"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
@@ -44,6 +56,36 @@ file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_lottery"
|
||||
source = "smp_lottery.rs"
|
||||
|
||||
[[module]]
|
||||
id = "nick"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_nick"
|
||||
source = "smp_nick.rs"
|
||||
|
||||
[[module]]
|
||||
id = "places"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_places"
|
||||
source = "smp_places.rs"
|
||||
|
||||
[[module]]
|
||||
id = "board"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_board"
|
||||
source = "smp_board.rs"
|
||||
|
||||
[[module]]
|
||||
id = "presence"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_presence"
|
||||
source = "smp_presence.rs"
|
||||
|
||||
[[module]]
|
||||
id = "claims"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_claims"
|
||||
source = "smp_claims.rs"
|
||||
|
||||
[[module]]
|
||||
id = "glue"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
@@ -113,3 +155,85 @@ bindings = [
|
||||
{ name = "uuid", ty = "u128", value = "uuid" },
|
||||
{ name = "window", ty = "i32", value = "window" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "destroy_block"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_destroy_block"
|
||||
helper = "destroy_block.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
{ name = "pos", ty = "[i32; 3]", value = "pos" },
|
||||
{ name = "canceled", ty = "&mut bool", value = "&mut *canceled" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "right_click_block"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_right_click_block"
|
||||
helper = "right_click_block.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
{ name = "pos", ty = "[i32; 3]", value = "pos" },
|
||||
{ name = "canceled", ty = "&mut bool", value = "&mut *canceled" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "block_spread"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::GameLevel<'_>::native_block_spread"
|
||||
helper = "block_spread.rs"
|
||||
bindings = [
|
||||
{ name = "dimension", ty = "crate::game::LevelDimension", value = "dimension" },
|
||||
{ name = "from", ty = "horizon_core::position::BlockPos", value = "from" },
|
||||
{ name = "to", ty = "horizon_core::position::BlockPos", value = "to" },
|
||||
{ name = "canceled", ty = "&mut bool", value = "&mut *canceled" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "piston_move"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::GameLevel<'_>::native_piston_move"
|
||||
helper = "piston_move.rs"
|
||||
bindings = [
|
||||
{ name = "dimension", ty = "crate::game::LevelDimension", value = "dimension" },
|
||||
{ name = "piston", ty = "horizon_core::position::BlockPos", value = "piston" },
|
||||
{ name = "direction", ty = "[i32; 3]", value = "direction" },
|
||||
{ name = "push", ty = "&[horizon_core::position::BlockPos]", value = "push" },
|
||||
{ name = "destroy", ty = "&[horizon_core::position::BlockPos]", value = "destroy" },
|
||||
{ name = "canceled", ty = "&mut bool", value = "&mut *canceled" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "explosion_detonate"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_explosion_detonate"
|
||||
helper = "explosion_detonate.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&crate::game::Game<'_>", value = "&*self" },
|
||||
{ name = "dimension", ty = "&str", value = "dimension" },
|
||||
{ name = "blocks", ty = "&mut Vec<[i32; 3]>", value = "&mut *blocks" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "fill_bucket"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_fill_bucket"
|
||||
helper = "fill_bucket.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
{ name = "pos", ty = "[i32; 3]", value = "pos" },
|
||||
{ name = "canceled", ty = "&mut bool", value = "&mut *canceled" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "attack_entity"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_attack_entity"
|
||||
helper = "attack_entity.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
{ name = "kind", ty = "&str", value = "kind" },
|
||||
{ name = "pos", ty = "[f64; 3]", value = "pos" },
|
||||
{ name = "canceled", ty = "&mut bool", value = "&mut *canceled" },
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
// A piston pushes, pulls or destroys blocks: kept out of the land others
|
||||
// claimed.
|
||||
fn smp_piston_move(
|
||||
dimension: crate::game::LevelDimension,
|
||||
piston: horizon_core::position::BlockPos,
|
||||
direction: [i32; 3],
|
||||
push: &[horizon_core::position::BlockPos],
|
||||
destroy: &[horizon_core::position::BlockPos],
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let dimension = crate::game::smp_glue::dimension_id(dimension);
|
||||
let step = |p: &horizon_core::position::BlockPos| {
|
||||
[p.x + direction[0], p.y + direction[1], p.z + direction[2]]
|
||||
};
|
||||
let touched: Vec<[i32; 3]> = push
|
||||
.iter()
|
||||
.flat_map(|p| [[p.x, p.y, p.z], step(p)])
|
||||
.chain(destroy.iter().map(|p| [p.x, p.y, p.z]))
|
||||
.collect();
|
||||
let piston = [piston.x, piston.y, piston.z];
|
||||
if !crate::game::smp_glue::world_allows(dimension, piston, &touched) {
|
||||
*canceled = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// A player right clicks a block: kept out of the land others claimed.
|
||||
fn smp_right_click_block(game: &mut crate::game::Game<'_>, pos: [i32; 3], canceled: &mut bool) {
|
||||
crate::game::smp_glue::guard(game, pos, canceled);
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
// The SMP's screens: a scoreboard sidebar, the player's own block first (their
|
||||
// name or nickname, balance, playtime, the players on), then the three
|
||||
// richest and the server's address, which a player hides with `/sidebar off`
|
||||
// (kept in `board.json`); and the Tab list's header and footer (the server's
|
||||
// name, its TPS and the players on). Nothing happens per tick: the ranking is
|
||||
// built once for everyone when a balance changed (at most once each
|
||||
// `min_seconds`) or every `refresh_seconds`; the players' own lines are looked
|
||||
// at on the same `refresh_seconds`, and a sidebar is sent only to the players
|
||||
// whose lines changed and to newcomers. The Tab list goes out every
|
||||
// `tab_refresh_seconds`, and to a player who just joined. No game types. Added to the game as `game::smp_board`;
|
||||
// also a module of this crate, for its tests.
|
||||
|
||||
use super::smp_economy::{Out, Player};
|
||||
use super::smp_store;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The most of the richest a sidebar lists: vanilla's 15 lines, less the
|
||||
/// player's four, the two gaps, the heading and the address.
|
||||
const MAX_TOP: usize = 7;
|
||||
|
||||
/// `config.json`'s `board` section.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
/// The sidebar (each player may hide it).
|
||||
pub sidebar: bool,
|
||||
pub title: String,
|
||||
/// How many of the richest players it lists (at most 7; 0: none).
|
||||
pub top: usize,
|
||||
/// Its last line, the server's address ("": none).
|
||||
pub footer: String,
|
||||
/// The ranking is built again, and the players' own lines looked at,
|
||||
/// at least this often.
|
||||
pub refresh_seconds: i64,
|
||||
/// After a balance changed, at most this often.
|
||||
pub min_seconds: i64,
|
||||
/// The Tab list's header and footer.
|
||||
pub tab: bool,
|
||||
/// `{server}`, `{tps}`, `{mspt}`, `{players}` are filled in.
|
||||
pub tab_header: String,
|
||||
pub tab_footer: String,
|
||||
pub server_name: String,
|
||||
pub tab_refresh_seconds: i64,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sidebar: true,
|
||||
title: "Horizon SMP".into(),
|
||||
top: 3,
|
||||
footer: "play.playhorizon.dev".into(),
|
||||
refresh_seconds: 60,
|
||||
min_seconds: 5,
|
||||
tab: true,
|
||||
tab_header: "{server}".into(),
|
||||
tab_footer: "TPS {tps} · {players} online".into(),
|
||||
server_name: "Horizon SMP".into(),
|
||||
tab_refresh_seconds: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SMP 0.13's sidebar defaults (the richest ten), moved to 0.14's when a
|
||||
/// server never changed them. True when something changed.
|
||||
pub fn migrate(config: &mut Config) -> bool {
|
||||
let mut changed = false;
|
||||
if config.title == "Richest players" {
|
||||
config.title = Config::default().title;
|
||||
changed = true;
|
||||
}
|
||||
if config.top == 10 {
|
||||
config.top = Config::default().top;
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// `board.json`.
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct Data {
|
||||
/// The players who hid the sidebar, by UUID.
|
||||
hidden: BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// A line of a player's sidebar.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Line {
|
||||
/// The player's name, or their nickname.
|
||||
Name(String),
|
||||
/// A value of theirs or the server's: `Balance`, `Playtime`, `Online`.
|
||||
Stat { label: &'static str, value: String },
|
||||
/// An empty line.
|
||||
Gap,
|
||||
/// The heading over the richest.
|
||||
Heading,
|
||||
/// One of the richest: their rank, name and balance; `me` on the
|
||||
/// player's own line.
|
||||
Top {
|
||||
rank: usize,
|
||||
name: String,
|
||||
amount: String,
|
||||
me: bool,
|
||||
},
|
||||
/// The server's address.
|
||||
Footer(String),
|
||||
}
|
||||
|
||||
/// What a player's own lines show, from the other modules.
|
||||
pub struct Me {
|
||||
/// Their nickname, else their name.
|
||||
pub name: String,
|
||||
pub played_seconds: i64,
|
||||
}
|
||||
|
||||
/// `2h 05m`, `12m`: the sidebar's playtime.
|
||||
pub fn short_duration(seconds: i64) -> String {
|
||||
let (h, m) = (seconds / 3600, seconds % 3600 / 60);
|
||||
if h > 0 {
|
||||
format!("{h}h {m:02}m")
|
||||
} else {
|
||||
format!("{m}m")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Board {
|
||||
pub config: Config,
|
||||
path: PathBuf,
|
||||
data: Data,
|
||||
changed: bool,
|
||||
/// Every account by balance, richest first: UUID, name, balance.
|
||||
ranked: Vec<(u128, String, i64)>,
|
||||
rank_of: HashMap<u128, usize>,
|
||||
/// When the ranking was built, and over which economy revision.
|
||||
built_at: Option<i64>,
|
||||
built_revision: u64,
|
||||
/// What each player on was last sent.
|
||||
sent: HashMap<u128, Vec<Line>>,
|
||||
/// Built since the last `updates`: every player's lines are looked at.
|
||||
stale: bool,
|
||||
/// When the players' own lines were last looked at.
|
||||
personal_at: Option<i64>,
|
||||
tab_at: Option<i64>,
|
||||
tab_sent: HashSet<u128>,
|
||||
}
|
||||
|
||||
/// `1,250`, `125.4k`, `3.2M`: short enough for the sidebar's right side.
|
||||
pub fn compact(n: i64) -> String {
|
||||
let a = n.unsigned_abs();
|
||||
let sign = if n < 0 { "-" } else { "" };
|
||||
if a < 100_000 {
|
||||
let digits = a.to_string();
|
||||
let mut out = String::new();
|
||||
for (i, c) in digits.chars().enumerate() {
|
||||
if i > 0 && (digits.len() - i).is_multiple_of(3) {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
return format!("{sign}{out}");
|
||||
}
|
||||
let (value, unit) = match a {
|
||||
100_000..1_000_000 => (a as f64 / 1e3, "k"),
|
||||
1_000_000..1_000_000_000 => (a as f64 / 1e6, "M"),
|
||||
_ => (a as f64 / 1e9, "B"),
|
||||
};
|
||||
format!("{sign}{value:.1}{unit}")
|
||||
}
|
||||
|
||||
impl Board {
|
||||
/// `board.json` in `dir`; an error when it exists but can't be read.
|
||||
pub fn open(dir: &Path, config: Config) -> Result<Self, String> {
|
||||
let path = dir.join("board.json");
|
||||
let data = smp_store::read_json(&path)?;
|
||||
Ok(Self {
|
||||
config,
|
||||
path,
|
||||
data,
|
||||
changed: false,
|
||||
ranked: Vec::new(),
|
||||
rank_of: HashMap::new(),
|
||||
built_at: None,
|
||||
built_revision: 0,
|
||||
sent: HashMap::new(),
|
||||
stale: false,
|
||||
personal_at: None,
|
||||
tab_at: None,
|
||||
tab_sent: HashSet::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save(&mut self) -> Result<(), String> {
|
||||
if self.changed {
|
||||
smp_store::write_json(&self.path, &self.data)?;
|
||||
self.changed = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn hidden(&self, uuid: u128) -> bool {
|
||||
self.data.hidden.contains(&smp_store::uuid_string(uuid))
|
||||
}
|
||||
|
||||
/// Whether the ranking should be built again: the economy changed
|
||||
/// (`revision`) and `min_seconds` passed, or `refresh_seconds` passed.
|
||||
pub fn due(&self, now: i64, revision: u64) -> bool {
|
||||
if !self.config.sidebar {
|
||||
return false;
|
||||
}
|
||||
let Some(at) = self.built_at else {
|
||||
return true;
|
||||
};
|
||||
let since = now - at;
|
||||
(revision != self.built_revision && since >= self.config.min_seconds * 1000)
|
||||
|| since >= self.config.refresh_seconds * 1000
|
||||
}
|
||||
|
||||
/// The ranking from the accounts (UUID, name, balance, any order).
|
||||
pub fn rebuild(&mut self, mut accounts: Vec<(u128, String, i64)>, now: i64, revision: u64) {
|
||||
accounts.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.1.cmp(&b.1)));
|
||||
self.rank_of = accounts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, a)| (a.0, i + 1))
|
||||
.collect();
|
||||
self.ranked = accounts;
|
||||
self.built_at = Some(now);
|
||||
self.built_revision = revision;
|
||||
self.stale = true;
|
||||
}
|
||||
|
||||
/// `uuid`'s sidebar: their own block, the richest from the ranking, the
|
||||
/// address.
|
||||
pub fn lines(&self, uuid: u128, me: &Me, online: usize) -> Vec<Line> {
|
||||
let balance = self
|
||||
.rank_of
|
||||
.get(&uuid)
|
||||
.map_or(0, |&rank| self.ranked[rank - 1].2);
|
||||
let mut lines = vec![
|
||||
Line::Name(me.name.clone()),
|
||||
Line::Stat {
|
||||
label: "Balance",
|
||||
value: compact(balance),
|
||||
},
|
||||
Line::Stat {
|
||||
label: "Playtime",
|
||||
value: short_duration(me.played_seconds),
|
||||
},
|
||||
Line::Stat {
|
||||
label: "Online",
|
||||
value: online.to_string(),
|
||||
},
|
||||
];
|
||||
let top = self.config.top.min(MAX_TOP);
|
||||
if top > 0 && !self.ranked.is_empty() {
|
||||
lines.push(Line::Gap);
|
||||
lines.push(Line::Heading);
|
||||
lines.extend(self.ranked.iter().take(top).enumerate().map(
|
||||
|(i, (u, name, balance))| Line::Top {
|
||||
rank: i + 1,
|
||||
name: name.clone(),
|
||||
amount: compact(*balance),
|
||||
me: *u == uuid,
|
||||
},
|
||||
));
|
||||
}
|
||||
if !self.config.footer.is_empty() {
|
||||
lines.push(Line::Gap);
|
||||
lines.push(Line::Footer(self.config.footer.clone()));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// The sidebars to send now, to the players `on`: those who joined, and
|
||||
/// after a rebuild or each `refresh_seconds` those whose lines changed.
|
||||
/// `me` is asked only for the players looked at. Players who hid it get
|
||||
/// nothing; players gone are forgotten (they get it again on return).
|
||||
pub fn updates(
|
||||
&mut self,
|
||||
now: i64,
|
||||
on: &[u128],
|
||||
me: impl Fn(u128) -> Me,
|
||||
) -> Vec<(u128, Vec<Line>)> {
|
||||
self.sent.retain(|u, _| on.contains(u));
|
||||
self.tab_sent.retain(|u| on.contains(u));
|
||||
if !self.config.sidebar || self.built_at.is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
let personal = self
|
||||
.personal_at
|
||||
.is_none_or(|t| now - t >= self.config.refresh_seconds * 1000);
|
||||
if personal {
|
||||
self.personal_at = Some(now);
|
||||
}
|
||||
let all = std::mem::take(&mut self.stale) || personal;
|
||||
let mut out = Vec::new();
|
||||
for &uuid in on {
|
||||
if self.hidden(uuid) || (!all && self.sent.contains_key(&uuid)) {
|
||||
continue;
|
||||
}
|
||||
let lines = self.lines(uuid, &me(uuid), on.len());
|
||||
if self.sent.get(&uuid) != Some(&lines) {
|
||||
self.sent.insert(uuid, lines.clone());
|
||||
out.push((uuid, lines));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whom the Tab list goes to now: everyone each `tab_refresh_seconds`,
|
||||
/// and players who just joined.
|
||||
pub fn tab_due(&mut self, now: i64, on: &[u128]) -> Vec<u128> {
|
||||
if !self.config.tab {
|
||||
return Vec::new();
|
||||
}
|
||||
let all = self
|
||||
.tab_at
|
||||
.is_none_or(|t| now - t >= self.config.tab_refresh_seconds * 1000);
|
||||
if all {
|
||||
self.tab_at = Some(now);
|
||||
}
|
||||
let to: Vec<u128> = on
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|u| all || !self.tab_sent.contains(u))
|
||||
.collect();
|
||||
self.tab_sent.extend(to.iter().copied());
|
||||
to
|
||||
}
|
||||
|
||||
/// The Tab list's header and footer.
|
||||
pub fn tab_lines(&self, tps: f64, mspt: f64, players: usize) -> (String, String) {
|
||||
let fill = |s: &str| {
|
||||
s.replace("{server}", &self.config.server_name)
|
||||
.replace("{tps}", &format!("{tps:.1}"))
|
||||
.replace("{mspt}", &format!("{mspt:.1}"))
|
||||
.replace("{players}", &players.to_string())
|
||||
};
|
||||
(fill(&self.config.tab_header), fill(&self.config.tab_footer))
|
||||
}
|
||||
|
||||
/// `/sidebar [on|off]`: the answer, and the sidebar to show (true) or
|
||||
/// take away (false). None for another command.
|
||||
pub fn command(&mut self, me: &Player, line: &str) -> Option<(Vec<Out>, Option<bool>)> {
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
if words.first() != Some(&"sidebar") {
|
||||
return None;
|
||||
}
|
||||
if !self.config.sidebar {
|
||||
return Some((
|
||||
vec![Out::Error("The sidebar is off on this server.".into())],
|
||||
None,
|
||||
));
|
||||
}
|
||||
let key = smp_store::uuid_string(me.uuid);
|
||||
let show = match words[1..] {
|
||||
[] => self.hidden(me.uuid),
|
||||
["on"] => true,
|
||||
["off"] => false,
|
||||
_ => {
|
||||
return Some((vec![Out::Error("Usage: /sidebar [on|off]".into())], None));
|
||||
}
|
||||
};
|
||||
if show {
|
||||
self.changed |= self.data.hidden.remove(&key);
|
||||
self.sent.remove(&me.uuid);
|
||||
} else {
|
||||
self.changed |= self.data.hidden.insert(key);
|
||||
}
|
||||
let reply = if show {
|
||||
"The sidebar is on. /sidebar off hides it."
|
||||
} else {
|
||||
"The sidebar is off. /sidebar on shows it again."
|
||||
};
|
||||
Some((vec![Out::Reply(reply.into())], Some(show)))
|
||||
}
|
||||
}
|
||||
|
||||
pub const COMMANDS: [&str; 1] = ["sidebar"];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
fn dir() -> PathBuf {
|
||||
static N: AtomicU32 = AtomicU32::new(0);
|
||||
let n = N.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!("smp-board-{}-{n}", std::process::id()))
|
||||
}
|
||||
fn me(uuid: u128) -> Player {
|
||||
Player {
|
||||
uuid,
|
||||
name: format!("P{uuid}"),
|
||||
level: 0,
|
||||
}
|
||||
}
|
||||
fn accounts(n: u128) -> Vec<(u128, String, i64)> {
|
||||
(1..=n)
|
||||
.map(|u| (u, format!("P{u}"), u as i64 * 100))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn amounts_are_short() {
|
||||
assert_eq!(compact(0), "0");
|
||||
assert_eq!(compact(1_250), "1,250");
|
||||
assert_eq!(compact(99_999), "99,999");
|
||||
assert_eq!(compact(125_400), "125.4k");
|
||||
assert_eq!(compact(3_210_000), "3.2M");
|
||||
assert_eq!(compact(7_000_000_000), "7.0B");
|
||||
}
|
||||
|
||||
fn me_of(uuid: u128) -> Me {
|
||||
Me {
|
||||
name: format!("P{uuid}"),
|
||||
played_seconds: 7_500,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durations_are_short() {
|
||||
assert_eq!(short_duration(0), "0m");
|
||||
assert_eq!(short_duration(12 * 60 + 59), "12m");
|
||||
assert_eq!(short_duration(7_500), "2h 05m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sidebar_shows_the_player_then_the_three_richest_then_the_address() {
|
||||
let d = dir();
|
||||
let mut b = Board::open(&d, Config::default()).unwrap();
|
||||
assert!(b.due(0, 0));
|
||||
b.rebuild(accounts(12), 0, 1);
|
||||
let rocky = Me {
|
||||
name: "Rocky".into(),
|
||||
played_seconds: 7_500,
|
||||
};
|
||||
let stat = |label, value: &str| Line::Stat {
|
||||
label,
|
||||
value: value.into(),
|
||||
};
|
||||
let top = |rank, name: &str, amount: &str, me| Line::Top {
|
||||
rank,
|
||||
name: name.into(),
|
||||
amount: amount.into(),
|
||||
me,
|
||||
};
|
||||
assert_eq!(
|
||||
b.lines(11, &rocky, 42),
|
||||
vec![
|
||||
Line::Name("Rocky".into()),
|
||||
stat("Balance", "1,100"),
|
||||
stat("Playtime", "2h 05m"),
|
||||
stat("Online", "42"),
|
||||
Line::Gap,
|
||||
Line::Heading,
|
||||
top(1, "P12", "1,200", false),
|
||||
top(2, "P11", "1,100", true),
|
||||
top(3, "P10", "1,000", false),
|
||||
Line::Gap,
|
||||
Line::Footer("play.playhorizon.dev".into()),
|
||||
]
|
||||
);
|
||||
// A player with no account yet; a server with no address and no top.
|
||||
assert_eq!(b.lines(99, &rocky, 1)[1], stat("Balance", "0"));
|
||||
b.config.footer.clear();
|
||||
b.config.top = 0;
|
||||
assert_eq!(b.lines(99, &rocky, 1).len(), 4);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_old_defaults_move_to_the_new_ones_and_set_ones_stay() {
|
||||
let mut old = Config {
|
||||
title: "Richest players".into(),
|
||||
top: 10,
|
||||
..Config::default()
|
||||
};
|
||||
assert!(migrate(&mut old));
|
||||
assert_eq!(old, Config::default());
|
||||
assert!(!migrate(&mut old));
|
||||
let mut own = Config {
|
||||
title: "My server".into(),
|
||||
top: 5,
|
||||
..Config::default()
|
||||
};
|
||||
assert!(!migrate(&mut own));
|
||||
assert_eq!((own.title.as_str(), own.top), ("My server", 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebars_go_out_on_join_on_a_new_ranking_and_on_the_refresh() {
|
||||
let d = dir();
|
||||
let mut b = Board::open(&d, Config::default()).unwrap();
|
||||
b.rebuild(accounts(3), 0, 1);
|
||||
let asked = std::cell::Cell::new(0);
|
||||
let me = |u| {
|
||||
asked.set(asked.get() + 1);
|
||||
me_of(u)
|
||||
};
|
||||
assert_eq!(b.updates(0, &[1, 2], me).len(), 2);
|
||||
// Nothing new within the refresh: nobody is even looked at.
|
||||
asked.set(0);
|
||||
assert!(b.updates(1_000, &[1, 2], me).is_empty());
|
||||
assert_eq!(asked.get(), 0);
|
||||
assert!(!b.due(1_000, 1), "no change, not a minute yet");
|
||||
assert!(!b.due(1_000, 2), "changed, but within min_seconds");
|
||||
assert!(b.due(5_000, 2));
|
||||
b.rebuild(accounts(3), 5_000, 2);
|
||||
assert!(b.updates(5_000, &[1, 2], me).is_empty(), "same lines");
|
||||
// Player 1 gets richer: everyone's top changes.
|
||||
let mut a = accounts(3);
|
||||
a[0].2 = 1_000;
|
||||
b.rebuild(a, 10_000, 3);
|
||||
assert_eq!(b.updates(10_000, &[1, 2], me).len(), 2);
|
||||
// A newcomer gets theirs at once, and only they are looked at.
|
||||
asked.set(0);
|
||||
assert_eq!(b.updates(11_000, &[1, 2, 3], me).len(), 1);
|
||||
assert_eq!(asked.get(), 1);
|
||||
// One who left gets it on return.
|
||||
b.updates(12_000, &[2, 3], me);
|
||||
assert_eq!(b.updates(13_000, &[1, 2, 3], me).len(), 1);
|
||||
// Within the minute player 2 still shows 2 online; the minute's
|
||||
// refresh mends it, and sends nothing to the others.
|
||||
assert_eq!(b.updates(30_000, &[1, 2, 3], me).len(), 0);
|
||||
let sent = b.updates(60_000, &[1, 2, 3], me);
|
||||
assert_eq!(sent.iter().map(|s| s.0).collect::<Vec<_>>(), vec![2]);
|
||||
assert!(b.due(70_000, 3), "a minute passed");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_sidebar_stays_hidden_after_a_restart() {
|
||||
let d = dir();
|
||||
let mut b = Board::open(&d, Config::default()).unwrap();
|
||||
b.rebuild(accounts(3), 0, 1);
|
||||
let (out, show) = b.command(&me(1), "sidebar off").unwrap();
|
||||
assert_eq!(show, Some(false));
|
||||
assert!(matches!(&out[0], Out::Reply(s) if s.contains("off")));
|
||||
assert_eq!(b.updates(0, &[1, 2], me_of).len(), 1, "only player 2");
|
||||
b.save().unwrap();
|
||||
let mut b = Board::open(&d, Config::default()).unwrap();
|
||||
assert!(b.hidden(1));
|
||||
b.rebuild(accounts(3), 0, 1);
|
||||
assert_eq!(b.updates(0, &[1], me_of).len(), 0);
|
||||
// `/sidebar` toggles it back on, and it is sent at the next update.
|
||||
let (_, show) = b.command(&me(1), "sidebar").unwrap();
|
||||
assert_eq!(show, Some(true));
|
||||
assert_eq!(b.updates(1_000, &[1], me_of).len(), 1);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tab_list_fills_its_placeholders_and_goes_to_newcomers_at_once() {
|
||||
let d = dir();
|
||||
let mut b = Board::open(&d, Config::default()).unwrap();
|
||||
let (h, f) = b.tab_lines(19.96, 6.3, 42);
|
||||
assert_eq!(
|
||||
(h.as_str(), f.as_str()),
|
||||
("Horizon SMP", "TPS 20.0 · 42 online")
|
||||
);
|
||||
assert_eq!(b.tab_due(0, &[1, 2]), vec![1, 2]);
|
||||
assert!(b.tab_due(1_000, &[1, 2]).is_empty());
|
||||
assert_eq!(b.tab_due(2_000, &[1, 2, 3]), vec![3]);
|
||||
assert_eq!(b.tab_due(5_000, &[1, 2, 3]), vec![1, 2, 3]);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
// Land claims: a player claims the chunk they stand in (`/claim`), all its
|
||||
// height, and only they and the players they trust (`/trust`) break blocks
|
||||
// or right click blocks there (doors, chests, levers, placing, buckets on a
|
||||
// block), or hit its frames, armor stands and animals. Nothing flows, burns
|
||||
// or is pushed into it from land of someone else, and explosions spare it.
|
||||
// The chunks a player may hold grow with the time they played here.
|
||||
// Operators of level 3 pass everywhere and may `/unclaim` any chunk. Kept in
|
||||
// `claims.json`, written atomically. No game types. Added to the game as
|
||||
// `game::smp_claims`; also a module of this crate, for its tests.
|
||||
|
||||
use super::smp_economy::{Out, Player};
|
||||
use super::smp_places::Place;
|
||||
use super::smp_store;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// `config.json`'s `claims` section.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub enabled: bool,
|
||||
/// Chunks a player may claim, by hours played here: `[hours, chunks]`,
|
||||
/// the last line reached counts.
|
||||
pub chunks: Vec<[i64; 2]>,
|
||||
/// The entities only the owner and trusted players may hit in a claim.
|
||||
pub protected_entities: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
chunks: vec![[0, 4], [5, 16], [20, 36], [100, 64]],
|
||||
protected_entities: PROTECTED
|
||||
.iter()
|
||||
.map(|&e| format!("minecraft:{e}"))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames, stands, vehicles and the animals a player keeps: not monsters.
|
||||
/// Points along a chunk's four edges, every `step` blocks, at height `y`:
|
||||
/// where `/claim show` draws its border.
|
||||
pub fn border(cx: i32, cz: i32, y: f64, step: usize) -> Vec<[f64; 3]> {
|
||||
let (x0, z0) = (f64::from(cx * 16), f64::from(cz * 16));
|
||||
let mut points = Vec::new();
|
||||
for i in (0..=16).step_by(step.max(1)) {
|
||||
let t = f64::from(i);
|
||||
points.push([x0 + t, y, z0]);
|
||||
points.push([x0 + t, y, z0 + 16.0]);
|
||||
if i != 0 && i != 16 {
|
||||
points.push([x0, y, z0 + t]);
|
||||
points.push([x0 + 16.0, y, z0 + t]);
|
||||
}
|
||||
}
|
||||
points
|
||||
}
|
||||
|
||||
const PROTECTED: &[&str] = &[
|
||||
"item_frame",
|
||||
"glow_item_frame",
|
||||
"armor_stand",
|
||||
"painting",
|
||||
"leash_knot",
|
||||
"minecart",
|
||||
"chest_minecart",
|
||||
"furnace_minecart",
|
||||
"hopper_minecart",
|
||||
"tnt_minecart",
|
||||
"oak_boat",
|
||||
"spruce_boat",
|
||||
"birch_boat",
|
||||
"jungle_boat",
|
||||
"acacia_boat",
|
||||
"dark_oak_boat",
|
||||
"mangrove_boat",
|
||||
"cherry_boat",
|
||||
"pale_oak_boat",
|
||||
"bamboo_raft",
|
||||
"oak_chest_boat",
|
||||
"spruce_chest_boat",
|
||||
"birch_chest_boat",
|
||||
"jungle_chest_boat",
|
||||
"acacia_chest_boat",
|
||||
"dark_oak_chest_boat",
|
||||
"mangrove_chest_boat",
|
||||
"cherry_chest_boat",
|
||||
"pale_oak_chest_boat",
|
||||
"bamboo_chest_raft",
|
||||
"allay",
|
||||
"armadillo",
|
||||
"axolotl",
|
||||
"bee",
|
||||
"camel",
|
||||
"cat",
|
||||
"chicken",
|
||||
"cow",
|
||||
"donkey",
|
||||
"fox",
|
||||
"frog",
|
||||
"goat",
|
||||
"happy_ghast",
|
||||
"horse",
|
||||
"llama",
|
||||
"mooshroom",
|
||||
"mule",
|
||||
"ocelot",
|
||||
"panda",
|
||||
"parrot",
|
||||
"pig",
|
||||
"rabbit",
|
||||
"sheep",
|
||||
"sniffer",
|
||||
"snow_golem",
|
||||
"iron_golem",
|
||||
"copper_golem",
|
||||
"strider",
|
||||
"turtle",
|
||||
"villager",
|
||||
"wolf",
|
||||
"trader_llama",
|
||||
"wandering_trader",
|
||||
];
|
||||
|
||||
/// `claims.json`.
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct Data {
|
||||
/// Claimed chunks, `<dimension> <chunk x> <chunk z>`, to their owner's UUID.
|
||||
chunks: BTreeMap<String, String>,
|
||||
/// The players each owner trusts, by UUID.
|
||||
trust: BTreeMap<String, BTreeSet<String>>,
|
||||
}
|
||||
|
||||
pub struct Claims {
|
||||
pub config: Config,
|
||||
path: PathBuf,
|
||||
data: Data,
|
||||
changed: bool,
|
||||
/// `data.chunks` by dimension, then (chunk x, chunk z): asked on every
|
||||
/// fluid flow, so a lookup allocates nothing.
|
||||
owners: HashMap<String, HashMap<(i32, i32), u128>>,
|
||||
/// When each player was last told a chunk isn't theirs.
|
||||
told: HashMap<u128, i64>,
|
||||
}
|
||||
|
||||
fn key(dimension: &str, cx: i32, cz: i32) -> String {
|
||||
format!("{dimension} {cx} {cz}")
|
||||
}
|
||||
|
||||
fn chunk_of(at: &Place) -> (i32, i32) {
|
||||
((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4)
|
||||
}
|
||||
|
||||
impl Claims {
|
||||
/// `claims.json` in `dir`; an error when it exists but can't be read
|
||||
/// (never replaced by an empty one).
|
||||
pub fn open(dir: &Path, config: Config) -> Result<Self, String> {
|
||||
let path = dir.join("claims.json");
|
||||
let data: Data = smp_store::read_json(&path)?;
|
||||
let mut owners: HashMap<String, HashMap<(i32, i32), u128>> = HashMap::new();
|
||||
for (k, owner) in &data.chunks {
|
||||
let mut w = k.split(' ');
|
||||
let (Some(d), Some(x), Some(z), Some(u)) = (
|
||||
w.next(),
|
||||
w.next().and_then(|x| x.parse().ok()),
|
||||
w.next().and_then(|z| z.parse().ok()),
|
||||
smp_store::parse_uuid(owner),
|
||||
) else {
|
||||
return Err(format!("{}: a bad claim `{k}`", path.display()));
|
||||
};
|
||||
owners.entry(d.to_owned()).or_default().insert((x, z), u);
|
||||
}
|
||||
Ok(Self {
|
||||
config,
|
||||
path,
|
||||
data,
|
||||
changed: false,
|
||||
owners,
|
||||
told: HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save(&mut self) -> Result<(), String> {
|
||||
if self.changed {
|
||||
smp_store::write_json(&self.path, &self.data)?;
|
||||
self.changed = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Who holds the chunk of this block, if anyone.
|
||||
pub fn owner(&self, dimension: &str, x: i32, z: i32) -> Option<u128> {
|
||||
self.chunk_owner(dimension, x >> 4, z >> 4)
|
||||
}
|
||||
|
||||
fn chunk_owner(&self, dimension: &str, cx: i32, cz: i32) -> Option<u128> {
|
||||
self.owners.get(dimension)?.get(&(cx, cz)).copied()
|
||||
}
|
||||
|
||||
/// Whether the world may change the block at `to` from `from` (a fluid,
|
||||
/// fire, a piston): yes, unless `to` is claimed by someone other than
|
||||
/// `from`'s owner or a player they trust.
|
||||
pub fn spread_allowed(&self, dimension: &str, from: [i32; 3], to: [i32; 3]) -> bool {
|
||||
if !self.config.enabled {
|
||||
return true;
|
||||
}
|
||||
let Some(owner) = self.owner(dimension, to[0], to[2]) else {
|
||||
return true;
|
||||
};
|
||||
match self.owner(dimension, from[0], from[2]) {
|
||||
Some(source) => source == owner || self.trusts(owner, source),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes out of an explosion's blocks those in a claim: explosions
|
||||
/// spare claimed land, whoever lit them.
|
||||
pub fn spare(&self, dimension: &str, blocks: &mut Vec<[i32; 3]>) {
|
||||
if self.config.enabled && self.owners.get(dimension).is_some_and(|c| !c.is_empty()) {
|
||||
blocks.retain(|b| self.owner(dimension, b[0], b[2]).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// Whose land a block is to `who`: None when free, true when theirs or
|
||||
/// trusted to them, false when someone else's.
|
||||
pub fn relation(&self, dimension: &str, x: i32, z: i32, who: u128) -> Option<bool> {
|
||||
let owner = self.owner(dimension, x, z)?;
|
||||
Some(owner == who || self.trusts(owner, who))
|
||||
}
|
||||
|
||||
/// Whether entities of type `kind` (`minecraft:cow`) are kept from
|
||||
/// players a claim's owner doesn't trust.
|
||||
pub fn protects(&self, kind: &str) -> bool {
|
||||
self.config.protected_entities.iter().any(|e| e == kind)
|
||||
}
|
||||
|
||||
fn trusts(&self, owner: u128, who: u128) -> bool {
|
||||
self.data
|
||||
.trust
|
||||
.get(&smp_store::uuid_string(owner))
|
||||
.is_some_and(|t| t.contains(&smp_store::uuid_string(who)))
|
||||
}
|
||||
|
||||
/// Whether `who` may break or use the block at `pos`. When not, the line
|
||||
/// to tell them: at most one each 2 s, so holding a click doesn't flood
|
||||
/// their chat.
|
||||
pub fn check(
|
||||
&mut self,
|
||||
who: &Player,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
name_of: &dyn Fn(u128) -> String,
|
||||
now: i64,
|
||||
) -> Result<(), Option<String>> {
|
||||
if !self.config.enabled || who.level >= 3 {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(owner) = self.owner(dimension, pos[0], pos[2]) else {
|
||||
return Ok(());
|
||||
};
|
||||
if owner == who.uuid || self.trusts(owner, who.uuid) {
|
||||
return Ok(());
|
||||
}
|
||||
let last = self.told.insert(who.uuid, now);
|
||||
if last.is_some_and(|t| now - t < 2_000) {
|
||||
return Err(None);
|
||||
}
|
||||
Err(Some(format!("This land is claimed by {}.", name_of(owner))))
|
||||
}
|
||||
|
||||
/// How many chunks `uuid` holds.
|
||||
fn held(&self, uuid: u128) -> usize {
|
||||
self.owners
|
||||
.values()
|
||||
.flat_map(|c| c.values())
|
||||
.filter(|&&u| u == uuid)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// How many chunks a player may hold after `played` seconds here.
|
||||
pub fn limit(&self, played: i64) -> usize {
|
||||
self.config
|
||||
.chunks
|
||||
.iter()
|
||||
.filter(|[h, _]| played / 3600 >= *h)
|
||||
.map(|[_, n]| (*n).max(0) as usize)
|
||||
.next_back()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The claims' commands; None for another command. `at` is where the
|
||||
/// player stands; `find` a player's UUID and name from a name (on or
|
||||
/// known); `name_of` a name from a UUID.
|
||||
pub fn command(
|
||||
&mut self,
|
||||
me: &Player,
|
||||
at: &Place,
|
||||
played: i64,
|
||||
find: &dyn Fn(&str) -> Option<(u128, String)>,
|
||||
name_of: &dyn Fn(u128) -> String,
|
||||
line: &str,
|
||||
) -> Option<Vec<Out>> {
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
let command = *words.first()?;
|
||||
if !COMMANDS.contains(&command) {
|
||||
return None;
|
||||
}
|
||||
if !self.config.enabled {
|
||||
return Some(vec![Out::Error("Claims are off on this server.".into())]);
|
||||
}
|
||||
let (cx, cz) = chunk_of(at);
|
||||
let here = at.dimension.as_str();
|
||||
let blocks = format!(
|
||||
"x {} to {}, z {} to {}",
|
||||
cx * 16,
|
||||
cx * 16 + 15,
|
||||
cz * 16,
|
||||
cz * 16 + 15
|
||||
);
|
||||
let me_key = smp_store::uuid_string(me.uuid);
|
||||
Some(match (command, &words[1..]) {
|
||||
("claim", []) => match self.chunk_owner(here, cx, cz) {
|
||||
Some(u) if u == me.uuid => vec![Out::Error("This chunk is already yours.".into())],
|
||||
Some(u) => vec![Out::Error(format!("This chunk is {}'s.", name_of(u)))],
|
||||
None => {
|
||||
let (held, limit) = (self.held(me.uuid), self.limit(played));
|
||||
if held >= limit {
|
||||
vec![Out::Error(format!(
|
||||
"You hold {held} of {limit} chunks: play longer for more, or /unclaim one."
|
||||
))]
|
||||
} else {
|
||||
self.data.chunks.insert(key(here, cx, cz), me_key);
|
||||
let chunks = self.owners.entry(here.to_owned()).or_default();
|
||||
chunks.insert((cx, cz), me.uuid);
|
||||
self.changed = true;
|
||||
vec![Out::Reply(format!(
|
||||
"Claimed this chunk ({blocks}), {} of {limit}. /trust <player> lets a friend build.",
|
||||
held + 1
|
||||
))]
|
||||
}
|
||||
}
|
||||
},
|
||||
("claim", ["info"]) => vec![Out::Reply(match self.chunk_owner(here, cx, cz) {
|
||||
Some(u) => format!("This chunk ({blocks}) is {}'s.", name_of(u)),
|
||||
None => format!("This chunk ({blocks}) is free: /claim it."),
|
||||
})],
|
||||
("unclaim", []) => match self.chunk_owner(here, cx, cz) {
|
||||
Some(u) if u == me.uuid || me.level >= 3 => {
|
||||
if let Some(chunks) = self.owners.get_mut(here) {
|
||||
chunks.remove(&(cx, cz));
|
||||
}
|
||||
self.data.chunks.remove(&key(here, cx, cz));
|
||||
self.changed = true;
|
||||
vec![Out::Reply(format!("Unclaimed this chunk ({blocks})."))]
|
||||
}
|
||||
Some(u) => vec![Out::Error(format!("This chunk is {}'s.", name_of(u)))],
|
||||
None => vec![Out::Error("This chunk isn't claimed.".into())],
|
||||
},
|
||||
("claims", []) => {
|
||||
let mine: Vec<String> = self
|
||||
.data
|
||||
.chunks
|
||||
.iter()
|
||||
.filter(|(_, o)| **o == me_key)
|
||||
.map(|(k, _)| {
|
||||
let w: Vec<&str> = k.split(' ').collect();
|
||||
let n = |s: &str| s.parse::<i32>().unwrap_or(0) * 16;
|
||||
format!(
|
||||
"{} at x {} z {}",
|
||||
w[0].trim_start_matches("minecraft:"),
|
||||
n(w[1]),
|
||||
n(w[2])
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let trusted: Vec<String> = self
|
||||
.data
|
||||
.trust
|
||||
.get(&me_key)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|u| smp_store::parse_uuid(u))
|
||||
.map(name_of)
|
||||
.collect();
|
||||
vec![
|
||||
Out::Reply(format!(
|
||||
"Your chunks ({} of {}): {}",
|
||||
mine.len(),
|
||||
self.limit(played),
|
||||
if mine.is_empty() {
|
||||
"none, /claim where you stand".into()
|
||||
} else {
|
||||
mine.join("; ")
|
||||
}
|
||||
)),
|
||||
Out::Reply(format!(
|
||||
"Trusted: {}",
|
||||
if trusted.is_empty() {
|
||||
"nobody".into()
|
||||
} else {
|
||||
trusted.join(", ")
|
||||
}
|
||||
)),
|
||||
]
|
||||
}
|
||||
("trust", [name]) | ("untrust", [name]) => match find(name) {
|
||||
None => vec![Out::Error(format!(
|
||||
"No player named {name} has played here."
|
||||
))],
|
||||
Some((u, _)) if u == me.uuid => vec![Out::Error("That's you.".into())],
|
||||
Some((u, name)) => {
|
||||
let set = self.data.trust.entry(me_key.clone()).or_default();
|
||||
let changed = if command == "trust" {
|
||||
set.insert(smp_store::uuid_string(u))
|
||||
} else {
|
||||
set.remove(&smp_store::uuid_string(u))
|
||||
};
|
||||
if set.is_empty() {
|
||||
self.data.trust.remove(&me_key);
|
||||
}
|
||||
self.changed |= changed;
|
||||
vec![Out::Reply(match (command, changed) {
|
||||
("trust", true) => {
|
||||
format!("{name} may now build and open things on your land.")
|
||||
}
|
||||
("trust", false) => format!("You already trust {name}."),
|
||||
(_, true) => format!("{name} no longer may build on your land."),
|
||||
_ => format!("You didn't trust {name}."),
|
||||
})]
|
||||
}
|
||||
},
|
||||
_ => vec![Out::Error(
|
||||
"Usage: /claim, /claim info, /claim show, /unclaim, /claims, /trust <player>, /untrust <player>"
|
||||
.into(),
|
||||
)],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The claims' commands.
|
||||
pub const COMMANDS: [&str; 5] = ["claim", "unclaim", "claims", "trust", "untrust"];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
const A: u128 = 1;
|
||||
const B: u128 = 2;
|
||||
|
||||
fn dir() -> PathBuf {
|
||||
static N: AtomicU32 = AtomicU32::new(0);
|
||||
let n = N.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!("smp-claims-{}-{n}", std::process::id()))
|
||||
}
|
||||
fn at(x: f64, z: f64) -> Place {
|
||||
Place {
|
||||
dimension: "minecraft:overworld".into(),
|
||||
x,
|
||||
y: 64.0,
|
||||
z,
|
||||
}
|
||||
}
|
||||
fn player(uuid: u128, level: u8) -> Player {
|
||||
Player {
|
||||
uuid,
|
||||
name: if uuid == A { "Alex" } else { "Steve" }.into(),
|
||||
level,
|
||||
}
|
||||
}
|
||||
fn find(n: &str) -> Option<(u128, String)> {
|
||||
match n.to_ascii_lowercase().as_str() {
|
||||
"alex" => Some((A, "Alex".into())),
|
||||
"steve" => Some((B, "Steve".into())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn name_of(u: u128) -> String {
|
||||
if u == A { "Alex" } else { "Steve" }.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_border_runs_along_the_chunks_edges() {
|
||||
let points = border(1, -1, 70.0, 2);
|
||||
// 9 points on each edge, the corners once.
|
||||
assert_eq!(points.len(), 32);
|
||||
assert!(points.contains(&[16.0, 70.0, -16.0]) && points.contains(&[32.0, 70.0, 0.0]));
|
||||
assert!(
|
||||
points
|
||||
.iter()
|
||||
.all(|p| p[0] == 16.0 || p[0] == 32.0 || p[2] == -16.0 || p[2] == 0.0)
|
||||
);
|
||||
let d = dir();
|
||||
let mut c = Claims::open(&d, Config::default()).unwrap();
|
||||
c.command(&player(A, 0), &at(8.0, 8.0), 0, &find, &name_of, "claim")
|
||||
.unwrap();
|
||||
let ow = "minecraft:overworld";
|
||||
assert_eq!(c.relation(ow, 3, 3, A), Some(true));
|
||||
assert_eq!(c.relation(ow, 3, 3, B), Some(false));
|
||||
assert_eq!(c.relation(ow, 20, 3, B), None);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_world_stays_out_of_a_claim() {
|
||||
let d = dir();
|
||||
let mut c = Claims::open(&d, Config::default()).unwrap();
|
||||
c.command(&player(A, 0), &at(8.0, 8.0), 0, &find, &name_of, "claim")
|
||||
.unwrap();
|
||||
let ow = "minecraft:overworld";
|
||||
// Into Alex's chunk from outside: refused; inside it and out of it: allowed.
|
||||
assert!(!c.spread_allowed(ow, [16, 64, 8], [15, 64, 8]));
|
||||
assert!(c.spread_allowed(ow, [14, 64, 8], [15, 64, 8]));
|
||||
assert!(c.spread_allowed(ow, [15, 64, 8], [16, 64, 8]));
|
||||
assert!(c.spread_allowed("minecraft:the_nether", [16, 64, 8], [15, 64, 8]));
|
||||
// From Steve's chunk next door: allowed once Alex trusts Steve.
|
||||
c.command(&player(B, 0), &at(24.0, 8.0), 0, &find, &name_of, "claim")
|
||||
.unwrap();
|
||||
assert!(!c.spread_allowed(ow, [16, 64, 8], [15, 64, 8]));
|
||||
c.command(
|
||||
&player(A, 0),
|
||||
&at(8.0, 8.0),
|
||||
0,
|
||||
&find,
|
||||
&name_of,
|
||||
"trust steve",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(c.spread_allowed(ow, [16, 64, 8], [15, 64, 8]));
|
||||
assert!(!c.spread_allowed(ow, [15, 64, 8], [16, 64, 8]));
|
||||
// Explosions spare both claims, not the land around them.
|
||||
let mut blocks = vec![[0, 64, 0], [20, 64, 0], [40, 64, 0], [-1, 64, 0]];
|
||||
c.spare(ow, &mut blocks);
|
||||
assert_eq!(blocks, vec![[40, 64, 0], [-1, 64, 0]]);
|
||||
assert!(c.protects("minecraft:item_frame") && c.protects("minecraft:cow"));
|
||||
assert!(!c.protects("minecraft:zombie") && !c.protects("minecraft:player"));
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_claim_keeps_others_out_until_trusted() {
|
||||
let d = dir();
|
||||
let mut c = Claims::open(&d, Config::default()).unwrap();
|
||||
let out = c
|
||||
.command(&player(A, 0), &at(-3.5, 20.0), 0, &find, &name_of, "claim")
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Reply(s) if s.contains("x -16 to -1, z 16 to 31")),
|
||||
"{out:?}"
|
||||
);
|
||||
let ow = "minecraft:overworld";
|
||||
assert_eq!(
|
||||
c.check(&player(A, 0), ow, [-16, 0, 31], &name_of, 0),
|
||||
Ok(())
|
||||
);
|
||||
assert_eq!(
|
||||
c.check(&player(B, 0), ow, [-1, 100, 16], &name_of, 0),
|
||||
Err(Some("This land is claimed by Alex.".into()))
|
||||
);
|
||||
// Told once each 2 s; the next chunk and the Nether are free.
|
||||
assert_eq!(
|
||||
c.check(&player(B, 0), ow, [-1, 100, 16], &name_of, 1_000),
|
||||
Err(None)
|
||||
);
|
||||
assert_eq!(c.check(&player(B, 0), ow, [0, 64, 16], &name_of, 0), Ok(()));
|
||||
assert_eq!(
|
||||
c.check(
|
||||
&player(B, 0),
|
||||
"minecraft:the_nether",
|
||||
[-1, 64, 16],
|
||||
&name_of,
|
||||
0
|
||||
),
|
||||
Ok(())
|
||||
);
|
||||
// Operators pass.
|
||||
assert_eq!(
|
||||
c.check(&player(B, 3), ow, [-1, 64, 16], &name_of, 0),
|
||||
Ok(())
|
||||
);
|
||||
c.command(
|
||||
&player(A, 0),
|
||||
&at(0.0, 0.0),
|
||||
0,
|
||||
&find,
|
||||
&name_of,
|
||||
"trust steve",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
c.check(&player(B, 0), ow, [-1, 64, 16], &name_of, 9_000),
|
||||
Ok(())
|
||||
);
|
||||
let out = c
|
||||
.command(
|
||||
&player(B, 0),
|
||||
&at(-3.5, 20.0),
|
||||
0,
|
||||
&find,
|
||||
&name_of,
|
||||
"unclaim",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Error(s) if s.contains("Alex's")),
|
||||
"trust isn't ownership"
|
||||
);
|
||||
c.save().unwrap();
|
||||
let mut again = Claims::open(&d, Config::default()).unwrap();
|
||||
assert_eq!(again.owner(ow, -1, 16), Some(A));
|
||||
again
|
||||
.command(
|
||||
&player(A, 0),
|
||||
&at(0.0, 0.0),
|
||||
0,
|
||||
&find,
|
||||
&name_of,
|
||||
"untrust Steve",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
again
|
||||
.check(&player(B, 0), ow, [-1, 64, 16], &name_of, 20_000)
|
||||
.is_err()
|
||||
);
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunks_grow_with_time_played() {
|
||||
let d = dir();
|
||||
let mut c = Claims::open(&d, Config::default()).unwrap();
|
||||
for x in 0..4 {
|
||||
let out = c
|
||||
.command(
|
||||
&player(A, 0),
|
||||
&at(x as f64 * 16.0, 0.0),
|
||||
0,
|
||||
&find,
|
||||
&name_of,
|
||||
"claim",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(&out[0], Out::Reply(_)), "{out:?}");
|
||||
}
|
||||
let out = c
|
||||
.command(&player(A, 0), &at(64.0, 0.0), 0, &find, &name_of, "claim")
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Error(s) if s.contains("4 of 4")),
|
||||
"{out:?}"
|
||||
);
|
||||
let out = c
|
||||
.command(
|
||||
&player(A, 0),
|
||||
&at(64.0, 0.0),
|
||||
5 * 3600,
|
||||
&find,
|
||||
&name_of,
|
||||
"claim",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Reply(s) if s.contains("5 of 16")),
|
||||
"{out:?}"
|
||||
);
|
||||
let out = c
|
||||
.command(&player(B, 0), &at(0.0, 0.0), 0, &find, &name_of, "claim")
|
||||
.unwrap();
|
||||
assert_eq!(out, vec![Out::Error("This chunk is Alex's.".into())]);
|
||||
// An operator may free any chunk.
|
||||
let out = c
|
||||
.command(&player(B, 3), &at(0.0, 0.0), 0, &find, &name_of, "unclaim")
|
||||
.unwrap();
|
||||
assert!(matches!(&out[0], Out::Reply(_)));
|
||||
let out = c
|
||||
.command(&player(A, 0), &at(0.0, 0.0), 0, &find, &name_of, "claims")
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Reply(s) if s.starts_with("Your chunks (4 of 4)")),
|
||||
"{out:?}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,9 @@ pub struct Economy {
|
||||
path: PathBuf,
|
||||
audit: Audit,
|
||||
dirty: bool,
|
||||
/// Counts the changes to the accounts, so the sidebar knows when to
|
||||
/// look again.
|
||||
revision: u64,
|
||||
/// Milliseconds since 1970, for the log (the glue sets it each call).
|
||||
pub now: i64,
|
||||
}
|
||||
@@ -97,6 +100,7 @@ impl Economy {
|
||||
path,
|
||||
audit: Audit::new(dir.join("audit.log")),
|
||||
dirty: false,
|
||||
revision: 0,
|
||||
now: 0,
|
||||
};
|
||||
// Games the last run left undecided: their stakes go back.
|
||||
@@ -109,6 +113,27 @@ impl Economy {
|
||||
Ok(economy)
|
||||
}
|
||||
|
||||
fn touch(&mut self) {
|
||||
self.dirty = true;
|
||||
self.revision += 1;
|
||||
}
|
||||
|
||||
/// Changes to the accounts so far (in this run).
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
|
||||
/// Every account: UUID, name, balance.
|
||||
pub fn accounts(&self) -> Vec<(u128, String, i64)> {
|
||||
self.accounts
|
||||
.accounts
|
||||
.iter()
|
||||
.filter_map(|(k, a)| {
|
||||
Some((super::smp_store::parse_uuid(k)?, a.name.clone(), a.balance))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Money `uuid` has held for a game.
|
||||
pub fn held(&self, uuid: u128) -> i64 {
|
||||
self.accounts
|
||||
@@ -139,7 +164,7 @@ impl Economy {
|
||||
.expect("checked")
|
||||
.balance -= amount;
|
||||
*self.accounts.held.entry(key).or_default() += amount;
|
||||
self.dirty = true;
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -155,7 +180,7 @@ impl Economy {
|
||||
self.take_held(&key, amount);
|
||||
let account = self.accounts.accounts.entry(key).or_default();
|
||||
account.balance = account.balance.saturating_add(amount);
|
||||
self.dirty = true;
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -187,7 +212,7 @@ impl Economy {
|
||||
.entry(uuid_string(winner))
|
||||
.or_default();
|
||||
account.balance = account.balance.saturating_add(prize);
|
||||
self.dirty = true;
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -226,7 +251,7 @@ impl Economy {
|
||||
Some(a) if a.name == name => {}
|
||||
Some(a) => {
|
||||
a.name = name.to_owned();
|
||||
self.dirty = true;
|
||||
self.touch();
|
||||
}
|
||||
None => {
|
||||
let start = self.config.starting_balance.clamp(0, MAX_AMOUNT);
|
||||
@@ -239,7 +264,7 @@ impl Economy {
|
||||
balance: start,
|
||||
},
|
||||
);
|
||||
self.dirty = true;
|
||||
self.touch();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,6 +279,15 @@ impl Economy {
|
||||
.and_then(|(k, a)| Some((super::smp_store::parse_uuid(k)?, a)))
|
||||
}
|
||||
|
||||
/// The name last seen of a player who has an account.
|
||||
pub fn name_of(&self, uuid: u128) -> Option<&str> {
|
||||
let a = self
|
||||
.accounts
|
||||
.accounts
|
||||
.get(&super::smp_store::uuid_string(uuid))?;
|
||||
Some(&a.name)
|
||||
}
|
||||
|
||||
/// `1,250 coins`.
|
||||
pub fn money(&self, amount: i64) -> String {
|
||||
let digits = amount.unsigned_abs().to_string();
|
||||
@@ -339,7 +373,7 @@ impl Economy {
|
||||
.get_mut(&uuid_string(to))
|
||||
.expect("checked")
|
||||
.balance = to_after;
|
||||
self.dirty = true;
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -369,7 +403,7 @@ impl Economy {
|
||||
.get_mut(&key)
|
||||
.expect("checked")
|
||||
.balance = after;
|
||||
self.dirty = true;
|
||||
self.touch();
|
||||
Ok(after)
|
||||
}
|
||||
|
||||
@@ -392,20 +426,67 @@ impl Economy {
|
||||
all
|
||||
}
|
||||
|
||||
/// Runs `/balance`, `/bal`, `/pay`, `/baltop` or `/eco`; None for another
|
||||
/// command.
|
||||
pub fn command(&mut self, me: &Player, line: &str) -> Option<Vec<Out>> {
|
||||
/// Runs `/balance`, `/bal`, `/pay`, `/baltop` or the operators' `/eco`
|
||||
/// (and its other name `/money`); None for another command. `online`:
|
||||
/// the players on, for `/eco give * <amount>`.
|
||||
pub fn command(
|
||||
&mut self,
|
||||
me: &Player,
|
||||
online: &[(u128, String)],
|
||||
line: &str,
|
||||
) -> Option<Vec<Out>> {
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
let (&name, args) = words.split_first()?;
|
||||
Some(match name {
|
||||
"balance" | "bal" => self.balance_command(me, args),
|
||||
"pay" => self.pay(me, args),
|
||||
"baltop" => self.baltop(args),
|
||||
"eco" => self.eco(me, args),
|
||||
"eco" | "money" => match args {
|
||||
["give", "*", amount] => self.give_everyone(me, online, amount),
|
||||
_ => self.eco(me, args),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `/eco give * <amount>`: to every player on, each change logged.
|
||||
fn give_everyone(&mut self, me: &Player, online: &[(u128, String)], amount: &str) -> Vec<Out> {
|
||||
if me.level < 3 {
|
||||
return vec![Out::Error("Only operators of level 3 may use /eco.".into())];
|
||||
}
|
||||
let Some(amount) = parse_amount(amount) else {
|
||||
return vec![Out::Error(
|
||||
"The amount must be a positive whole number.".into(),
|
||||
)];
|
||||
};
|
||||
let by = uuid_string(me.uuid);
|
||||
let mut out = Vec::new();
|
||||
let mut given = 0;
|
||||
for (uuid, name) in online {
|
||||
self.seen(*uuid, name);
|
||||
match self.adjust("eco_give", *uuid, amount, &by) {
|
||||
Ok(_) => {
|
||||
given += 1;
|
||||
if *uuid != me.uuid {
|
||||
out.push(Out::Tell(
|
||||
*uuid,
|
||||
format!("An operator gave you {}.", self.money(amount)),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => out.push(Out::Error(format!("{name}: {e}"))),
|
||||
}
|
||||
}
|
||||
out.insert(
|
||||
0,
|
||||
Out::Reply(format!(
|
||||
"Gave {} to {given} player(s) on.",
|
||||
self.money(amount)
|
||||
)),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
fn balance_command(&mut self, me: &Player, args: &[&str]) -> Vec<Out> {
|
||||
match args {
|
||||
[] => vec![Out::Reply(format!(
|
||||
@@ -474,7 +555,7 @@ impl Economy {
|
||||
}
|
||||
let [verb, who, amount] = args else {
|
||||
return vec![Out::Error(
|
||||
"Usage: /eco give|take|set <player> <amount>".into(),
|
||||
"Usage: /eco give|take|set <player> <amount>, /eco give * <amount>".into(),
|
||||
)];
|
||||
};
|
||||
let amount = match amount.parse::<i64>() {
|
||||
@@ -495,13 +576,22 @@ impl Economy {
|
||||
"set" => amount - account.balance,
|
||||
_ => {
|
||||
return vec![Out::Error(
|
||||
"Usage: /eco give|take|set <player> <amount>".into(),
|
||||
"Usage: /eco give|take|set <player> <amount>, /eco give * <amount>".into(),
|
||||
)];
|
||||
}
|
||||
};
|
||||
let by = uuid_string(me.uuid);
|
||||
match self.adjust(&format!("eco_{verb}"), uuid, delta, &by) {
|
||||
Ok(after) => vec![Out::Reply(format!("{name} now has {}.", self.money(after)))],
|
||||
Ok(after) => {
|
||||
let mut out = vec![Out::Reply(format!("{name} now has {}.", self.money(after)))];
|
||||
if *verb == "give" && uuid != me.uuid && amount > 0 {
|
||||
out.push(Out::Tell(
|
||||
uuid,
|
||||
format!("An operator gave you {}.", self.money(amount)),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
Err(e) => vec![Out::Error(e)],
|
||||
}
|
||||
}
|
||||
@@ -559,7 +649,9 @@ mod tests {
|
||||
#[test]
|
||||
fn pay_moves_money_and_tells_both() {
|
||||
let (mut e, dir) = economy("pay");
|
||||
let out = e.command(&player(ALEX, "Alex", 0), "pay steve 30").unwrap();
|
||||
let out = e
|
||||
.command(&player(ALEX, "Alex", 0), &[], "pay steve 30")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
[
|
||||
@@ -598,7 +690,7 @@ mod tests {
|
||||
("pay Steve", "Usage: /pay <player> <amount>"),
|
||||
] {
|
||||
assert_eq!(
|
||||
e.command(&alex, line).unwrap(),
|
||||
e.command(&alex, &[], line).unwrap(),
|
||||
[Out::Error(error.into())],
|
||||
"{line}"
|
||||
);
|
||||
@@ -615,9 +707,10 @@ mod tests {
|
||||
#[test]
|
||||
fn balances_survive_a_restart_and_a_damaged_file_stops_the_economy() {
|
||||
let (mut e, dir) = economy("restart");
|
||||
e.command(&player(ALEX, "Alex", 0), "pay Steve 1,000")
|
||||
e.command(&player(ALEX, "Alex", 0), &[], "pay Steve 1,000")
|
||||
.unwrap();
|
||||
e.command(&player(ALEX, "Alex", 0), &[], "pay Steve 25")
|
||||
.unwrap();
|
||||
e.command(&player(ALEX, "Alex", 0), "pay Steve 25").unwrap();
|
||||
e.save().unwrap();
|
||||
let again = Economy::open(&dir, Config::default()).unwrap();
|
||||
assert_eq!((again.balance(ALEX), again.balance(STEVE)), (75, 125));
|
||||
@@ -626,25 +719,73 @@ mod tests {
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn money_is_eco_and_give_star_pays_everyone_on() {
|
||||
let (mut e, dir) = economy("money");
|
||||
let op = player(ALEX, "Alex", 3);
|
||||
assert_eq!(
|
||||
e.command(&op, &[], "money give Steve 7").unwrap()[0],
|
||||
Out::Reply("Steve now has 107 coins.".into())
|
||||
);
|
||||
let online = [
|
||||
(ALEX, "Alex".to_owned()),
|
||||
(STEVE, "Steve".to_owned()),
|
||||
(99, "Newcomer".to_owned()),
|
||||
];
|
||||
let out = e.command(&op, &online, "eco give * 1,000").unwrap();
|
||||
assert_eq!(
|
||||
out[0],
|
||||
Out::Reply("Gave 1,000 coins to 3 player(s) on.".into()),
|
||||
"{out:?}"
|
||||
);
|
||||
assert!(out.contains(&Out::Tell(99, "An operator gave you 1,000 coins.".into())));
|
||||
assert!(
|
||||
!out.iter()
|
||||
.any(|o| matches!(o, Out::Tell(u, _) if *u == ALEX))
|
||||
);
|
||||
assert_eq!(
|
||||
(e.balance(ALEX), e.balance(STEVE), e.balance(99)),
|
||||
(1100, 1107, 1100)
|
||||
);
|
||||
let gives = log_lines(&dir)
|
||||
.iter()
|
||||
.filter(|l| l["kind"] == "eco_give")
|
||||
.count();
|
||||
assert_eq!(gives, 4);
|
||||
assert_eq!(
|
||||
e.command(&player(STEVE, "Steve", 0), &online, "money give * 5")
|
||||
.unwrap(),
|
||||
[Out::Error("Only operators of level 3 may use /eco.".into())]
|
||||
);
|
||||
assert!(matches!(
|
||||
&e.command(&op, &online, "eco give * -5").unwrap()[0],
|
||||
Out::Error(_)
|
||||
));
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eco_is_for_level_3_and_never_goes_below_zero() {
|
||||
let (mut e, dir) = economy("eco");
|
||||
let alex = player(ALEX, "Alex", 2);
|
||||
assert_eq!(
|
||||
e.command(&alex, "eco give Steve 5").unwrap(),
|
||||
e.command(&alex, &[], "eco give Steve 5").unwrap(),
|
||||
[Out::Error("Only operators of level 3 may use /eco.".into())]
|
||||
);
|
||||
let op = player(ALEX, "Alex", 3);
|
||||
assert_eq!(
|
||||
e.command(&op, "eco give Steve 5").unwrap(),
|
||||
[Out::Reply("Steve now has 105 coins.".into())]
|
||||
e.command(&op, &[], "eco give Steve 5").unwrap(),
|
||||
[
|
||||
Out::Reply("Steve now has 105 coins.".into()),
|
||||
Out::Tell(STEVE, "An operator gave you 5 coins.".into())
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
e.command(&op, "eco take Steve 200").unwrap(),
|
||||
e.command(&op, &[], "eco take Steve 200").unwrap(),
|
||||
[Out::Error("Only 105 coins there.".into())]
|
||||
);
|
||||
assert_eq!(
|
||||
e.command(&op, "eco set Steve 1234567").unwrap(),
|
||||
e.command(&op, &[], "eco set Steve 1234567").unwrap(),
|
||||
[Out::Reply("Steve now has 1,234,567 coins.".into())]
|
||||
);
|
||||
let log = log_lines(&dir);
|
||||
@@ -659,12 +800,15 @@ mod tests {
|
||||
for i in 0..12u128 {
|
||||
e.seen(100 + i, &format!("P{i:02}"));
|
||||
}
|
||||
e.command(&player(ALEX, "Alex", 0), "pay Steve 50").unwrap();
|
||||
let first = e.command(&player(ALEX, "Alex", 0), "baltop").unwrap();
|
||||
e.command(&player(ALEX, "Alex", 0), &[], "pay Steve 50")
|
||||
.unwrap();
|
||||
let first = e.command(&player(ALEX, "Alex", 0), &[], "baltop").unwrap();
|
||||
assert_eq!(first[0], Out::Reply("Richest players (1/2):".into()));
|
||||
assert_eq!(first[1], Out::Reply("1. Steve: 150 coins".into()));
|
||||
assert_eq!(first.len(), 11);
|
||||
let last = e.command(&player(ALEX, "Alex", 0), "baltop 2").unwrap();
|
||||
let last = e
|
||||
.command(&player(ALEX, "Alex", 0), &[], "baltop 2")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
last.last().unwrap(),
|
||||
&Out::Reply("14. Alex: 50 coins".into())
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
// The server market: the server buys and sells vanilla items at the prices
|
||||
// of `config.json`'s `market`, the economy's way to earn without changing
|
||||
// survival. `/worth`, `/sell hand|all|<item> [count]`, `/buy <item> [count]`.
|
||||
// Only plain items (no damage, no name or enchantment) are traded, and only
|
||||
// by players in survival or adventure mode. Items move first, then money,
|
||||
// both logged; a sale the log refuses gives the items back. No game types:
|
||||
// the glue lends the acting player's items as a `Bag`. Added to the game as
|
||||
// survival. `/worth`, `/sell hand|all|<item> [count]`, `/buy <item> [count]`,
|
||||
// and the `/shop` menu (smp_shop) on the same prices. Only plain items (no
|
||||
// damage, no name or enchantment) are traded, and only by players in survival
|
||||
// or adventure mode. Items move first, then money, both logged; a sale the
|
||||
// log refuses gives the items back. Nobody gets rich from it: buying always
|
||||
// costs more than selling (an item priced otherwise isn't bought), no chain
|
||||
// of recipes turns bought items into dearer ones (smp_recipes; such an item
|
||||
// isn't bought either), and what a player sells is capped each UTC day, in
|
||||
// coins and per item (`market.json` counts it). No game types: the glue
|
||||
// lends the player's items as a `Bag`. Added to the game as
|
||||
// `game::smp_market`; also a module of this crate, for its tests.
|
||||
|
||||
use super::smp_economy::{Economy, MAX_AMOUNT, Out, Player, parse_amount};
|
||||
use super::smp_store::{read_json, uuid_string, write_json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
|
||||
/// A UTC day, in milliseconds.
|
||||
const DAY_MS: i64 = 86_400_000;
|
||||
|
||||
/// What the server pays for one item (`sell`) and asks for one (`buy`); 0
|
||||
/// closes that side.
|
||||
@@ -25,41 +34,94 @@ pub struct Price {
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub enabled: bool,
|
||||
/// By item id, `minecraft:diamond`.
|
||||
/// By item id, `minecraft:diamond`. Items that stack to 64.
|
||||
pub items: BTreeMap<String, Price>,
|
||||
/// The most a player earns selling to the market in a UTC day (0: no
|
||||
/// limit).
|
||||
pub daily_sell_coins: i64,
|
||||
/// The most of one item a player sells to the market in a UTC day (0:
|
||||
/// no limit).
|
||||
pub daily_sell_items: u32,
|
||||
}
|
||||
|
||||
/// The default prices: (item, buy, sell). Farm, mine and mob produce the
|
||||
/// market buys cheaply; blocks, food and tools it only sells.
|
||||
pub const PRICES: &[(&str, i64, i64)] = &[
|
||||
// Farming.
|
||||
("wheat", 3, 1),
|
||||
("wheat_seeds", 2, 0),
|
||||
("carrot", 3, 1),
|
||||
("potato", 3, 1),
|
||||
("beetroot", 3, 1),
|
||||
("sugar_cane", 3, 1),
|
||||
("pumpkin", 8, 3),
|
||||
("melon_slice", 2, 1),
|
||||
("cactus", 3, 1),
|
||||
("bone_meal", 3, 0),
|
||||
// Mining.
|
||||
("coal", 8, 2),
|
||||
("copper_ingot", 6, 2),
|
||||
("iron_ingot", 30, 8),
|
||||
("gold_ingot", 40, 10),
|
||||
("redstone", 4, 1),
|
||||
("lapis_lazuli", 8, 2),
|
||||
("quartz", 8, 2),
|
||||
("emerald", 80, 20),
|
||||
("diamond", 400, 100),
|
||||
// Wood.
|
||||
("oak_log", 4, 1),
|
||||
("spruce_log", 4, 1),
|
||||
("birch_log", 4, 1),
|
||||
("jungle_log", 4, 1),
|
||||
("acacia_log", 4, 1),
|
||||
("dark_oak_log", 4, 1),
|
||||
("mangrove_log", 4, 1),
|
||||
("cherry_log", 4, 1),
|
||||
// Mob drops.
|
||||
("rotten_flesh", 0, 1),
|
||||
("bone", 3, 1),
|
||||
("string", 3, 1),
|
||||
("gunpowder", 8, 2),
|
||||
("spider_eye", 4, 1),
|
||||
("slime_ball", 10, 2),
|
||||
("blaze_rod", 30, 8),
|
||||
("leather", 6, 2),
|
||||
("feather", 2, 1),
|
||||
// Building.
|
||||
("cobblestone", 1, 0),
|
||||
("stone", 2, 0),
|
||||
("dirt", 1, 0),
|
||||
("sand", 2, 0),
|
||||
("gravel", 2, 0),
|
||||
("glass", 3, 0),
|
||||
("bricks", 4, 0),
|
||||
("stone_bricks", 3, 0),
|
||||
// Food.
|
||||
("bread", 5, 0),
|
||||
("cooked_beef", 6, 0),
|
||||
("cooked_porkchop", 6, 0),
|
||||
("cooked_chicken", 5, 0),
|
||||
("baked_potato", 4, 0),
|
||||
("apple", 4, 0),
|
||||
("golden_carrot", 40, 0),
|
||||
// Tools.
|
||||
("torch", 1, 0),
|
||||
("ladder", 2, 0),
|
||||
("crafting_table", 4, 0),
|
||||
("chest", 6, 0),
|
||||
("furnace", 6, 0),
|
||||
];
|
||||
|
||||
impl Default for Config {
|
||||
/// A small starting list the owner edits: farm and mine produce the
|
||||
/// server buys, a few things it sells, buying always dearer than selling.
|
||||
/// `PRICES`, buying always dearer than selling, and the daily limits.
|
||||
fn default() -> Self {
|
||||
let list: [(&str, i64, i64); 18] = [
|
||||
("minecraft:wheat", 0, 1),
|
||||
("minecraft:carrot", 0, 1),
|
||||
("minecraft:potato", 0, 1),
|
||||
("minecraft:sugar_cane", 0, 1),
|
||||
("minecraft:pumpkin", 0, 3),
|
||||
("minecraft:melon_slice", 0, 1),
|
||||
("minecraft:cactus", 0, 1),
|
||||
("minecraft:oak_log", 4, 1),
|
||||
("minecraft:coal", 8, 2),
|
||||
("minecraft:iron_ingot", 30, 8),
|
||||
("minecraft:gold_ingot", 40, 10),
|
||||
("minecraft:redstone", 4, 1),
|
||||
("minecraft:lapis_lazuli", 8, 2),
|
||||
("minecraft:emerald", 80, 20),
|
||||
("minecraft:diamond", 400, 100),
|
||||
("minecraft:bread", 5, 0),
|
||||
("minecraft:cooked_beef", 6, 0),
|
||||
("minecraft:torch", 1, 0),
|
||||
];
|
||||
Self {
|
||||
enabled: true,
|
||||
items: list
|
||||
items: PRICES
|
||||
.iter()
|
||||
.map(|(id, buy, sell)| {
|
||||
(
|
||||
(*id).to_owned(),
|
||||
format!("minecraft:{id}"),
|
||||
Price {
|
||||
buy: *buy,
|
||||
sell: *sell,
|
||||
@@ -67,10 +129,128 @@ impl Default for Config {
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
daily_sell_coins: 2000,
|
||||
daily_sell_items: 640,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The default prices of SMP 0.1 to 0.11: a config that still holds
|
||||
/// exactly these (never edited) gets today's defaults (`migrate`).
|
||||
const OLD_PRICES: [(&str, i64, i64); 18] = [
|
||||
("wheat", 0, 1),
|
||||
("carrot", 0, 1),
|
||||
("potato", 0, 1),
|
||||
("sugar_cane", 0, 1),
|
||||
("pumpkin", 0, 3),
|
||||
("melon_slice", 0, 1),
|
||||
("cactus", 0, 1),
|
||||
("oak_log", 4, 1),
|
||||
("coal", 8, 2),
|
||||
("iron_ingot", 30, 8),
|
||||
("gold_ingot", 40, 10),
|
||||
("redstone", 4, 1),
|
||||
("lapis_lazuli", 8, 2),
|
||||
("emerald", 80, 20),
|
||||
("diamond", 400, 100),
|
||||
("bread", 5, 0),
|
||||
("cooked_beef", 6, 0),
|
||||
("torch", 1, 0),
|
||||
];
|
||||
|
||||
/// A config whose prices are still the old defaults, untouched: today's
|
||||
/// defaults instead. Whether it changed (the file is written again).
|
||||
pub fn migrate(config: &mut Config) -> bool {
|
||||
let old: BTreeMap<String, Price> = OLD_PRICES
|
||||
.iter()
|
||||
.map(|(id, buy, sell)| {
|
||||
(
|
||||
format!("minecraft:{id}"),
|
||||
Price {
|
||||
buy: *buy,
|
||||
sell: *sell,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if config.items != old {
|
||||
return false;
|
||||
}
|
||||
config.items = Config::default().items;
|
||||
true
|
||||
}
|
||||
|
||||
/// What one player sold today.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Sold {
|
||||
/// The UTC day (days since 1970) the counts are for.
|
||||
pub day: i64,
|
||||
pub coins: i64,
|
||||
pub items: BTreeMap<String, u32>,
|
||||
}
|
||||
|
||||
/// `market.json`: what each player sold today, by UUID.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Ledger {
|
||||
pub sold: BTreeMap<String, Sold>,
|
||||
}
|
||||
|
||||
/// A config's prices made safe: an amount out of range, or a sale price not
|
||||
/// below the buying price, closes that item's sale; so does an item a chain
|
||||
/// of recipes makes for less than it sells for. What it closed, to log.
|
||||
pub fn validate(config: &mut Config) -> Vec<String> {
|
||||
let mut closed = Vec::new();
|
||||
config.daily_sell_coins = config.daily_sell_coins.clamp(0, MAX_AMOUNT);
|
||||
for (id, p) in config.items.iter_mut() {
|
||||
if !valid_id(id) {
|
||||
closed.push(format!("{id}: not an item id; left out"));
|
||||
*p = Price::default();
|
||||
continue;
|
||||
}
|
||||
p.buy = p.buy.clamp(0, MAX_AMOUNT);
|
||||
p.sell = p.sell.clamp(0, MAX_AMOUNT);
|
||||
if p.sell > 0 && p.buy > 0 && p.sell >= p.buy {
|
||||
closed.push(format!(
|
||||
"{id}: sells for {} but buys for {}; the market won't buy it",
|
||||
p.sell, p.buy
|
||||
));
|
||||
p.sell = 0;
|
||||
}
|
||||
}
|
||||
for l in super::smp_recipes::loops(&config.items, &super::smp_recipes::chains()) {
|
||||
closed.push(format!(
|
||||
"{}: crafted from the market's items for {:.2}, sells for {}; the market won't buy it",
|
||||
l.item, l.cost, l.sell
|
||||
));
|
||||
if let Some(p) = config.items.get_mut(&l.item) {
|
||||
p.sell = 0;
|
||||
}
|
||||
}
|
||||
config.items.retain(|_, p| p.buy > 0 || p.sell > 0);
|
||||
closed
|
||||
}
|
||||
|
||||
/// `minecraft:iron_ingot`: lowercase letters, digits, `_`, `.`, `-`, `/`
|
||||
/// and one `:`, as a command line may carry it.
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
let mut parts = id.split(':');
|
||||
let (Some(ns), Some(path), None) = (parts.next(), parts.next(), parts.next()) else {
|
||||
return false;
|
||||
};
|
||||
let ok = |s: &str, extra: &str| {
|
||||
!s.is_empty()
|
||||
&& s.chars().all(|c| {
|
||||
c.is_ascii_lowercase()
|
||||
|| c.is_ascii_digit()
|
||||
|| "_.-".contains(c)
|
||||
|| extra.contains(c)
|
||||
})
|
||||
};
|
||||
ok(ns, "") && ok(path, "/")
|
||||
}
|
||||
|
||||
/// The acting player's items, lent by the game for one command.
|
||||
pub trait Bag {
|
||||
/// The main hand: item id, count, plain.
|
||||
@@ -105,13 +285,126 @@ pub fn item_name(id: &str) -> String {
|
||||
|
||||
pub struct Market {
|
||||
pub config: Config,
|
||||
ledger: Ledger,
|
||||
/// `market.json`; None in tests that keep nothing.
|
||||
path: Option<PathBuf>,
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
impl Market {
|
||||
/// The market of `dir` (`world/horizon-smp/`), its config made safe
|
||||
/// (`validate`), with what to log. A `market.json` that can't be read
|
||||
/// starts the day's counts from nothing (they only limit, never hold
|
||||
/// money).
|
||||
pub fn open(dir: &std::path::Path, mut config: Config) -> (Self, Vec<String>) {
|
||||
let mut problems: Vec<String> = validate(&mut config)
|
||||
.into_iter()
|
||||
.map(|l| format!("market: {l}"))
|
||||
.collect();
|
||||
let path = dir.join("market.json");
|
||||
let ledger = read_json(&path).unwrap_or_else(|e| {
|
||||
problems.push(format!("{e}: today's sale limits start again"));
|
||||
Ledger::default()
|
||||
});
|
||||
let market = Self {
|
||||
config,
|
||||
ledger,
|
||||
path: Some(path),
|
||||
dirty: false,
|
||||
};
|
||||
(market, problems)
|
||||
}
|
||||
|
||||
/// A market that keeps nothing on disk, its config as given (tests).
|
||||
#[cfg(test)]
|
||||
pub fn new(config: Config) -> Self {
|
||||
Self {
|
||||
config,
|
||||
ledger: Ledger::default(),
|
||||
path: None,
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&mut self) -> Result<(), String> {
|
||||
if !self.dirty {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(path) = &self.path {
|
||||
write_json(path, &self.ledger)?;
|
||||
}
|
||||
self.dirty = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What `uuid` sold today (`now`: milliseconds since 1970).
|
||||
fn sold(&self, uuid: u128, now: i64) -> Sold {
|
||||
match self.ledger.sold.get(&uuid_string(uuid)) {
|
||||
Some(s) if s.day == now.div_euclid(DAY_MS) => s.clone(),
|
||||
_ => Sold::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The coins `uuid` may still earn selling today (None: no limit).
|
||||
pub fn coins_left(&self, uuid: u128, now: i64) -> Option<i64> {
|
||||
let cap = self.config.daily_sell_coins;
|
||||
(cap > 0).then(|| (cap - self.sold(uuid, now).coins).max(0))
|
||||
}
|
||||
|
||||
/// How many of `id` `uuid` may still sell today (None: no limit).
|
||||
pub fn items_left(&self, uuid: u128, now: i64, id: &str) -> Option<u32> {
|
||||
let cap = self.config.daily_sell_items;
|
||||
(cap > 0)
|
||||
.then(|| cap.saturating_sub(self.sold(uuid, now).items.get(id).copied().unwrap_or(0)))
|
||||
}
|
||||
|
||||
/// Counts a sale against today's limits.
|
||||
fn count_sale(&mut self, uuid: u128, now: i64, id: &str, n: u32, coins: i64) {
|
||||
let day = now.div_euclid(DAY_MS);
|
||||
let s = self.ledger.sold.entry(uuid_string(uuid)).or_default();
|
||||
if s.day != day {
|
||||
*s = Sold {
|
||||
day,
|
||||
..Sold::default()
|
||||
};
|
||||
}
|
||||
s.coins = s.coins.saturating_add(coins);
|
||||
let c = s.items.entry(id.to_owned()).or_default();
|
||||
*c = c.saturating_add(n);
|
||||
// Yesterday's counts go.
|
||||
self.ledger.sold.retain(|_, s| s.day == day);
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// The price of `id` (nothing on either side when it isn't traded).
|
||||
pub fn price(&self, id: &str) -> Price {
|
||||
self.config.items.get(id).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Today's limits for `uuid`, as a line (None when there are none).
|
||||
pub fn limits_line(&self, economy: &Economy, uuid: u128) -> Option<String> {
|
||||
let now = economy.now;
|
||||
let coins = self.coins_left(uuid, now).map(|left| {
|
||||
format!(
|
||||
"{} left of your {} a day",
|
||||
economy.money(left),
|
||||
economy.money(self.config.daily_sell_coins)
|
||||
)
|
||||
});
|
||||
let items = (self.config.daily_sell_items > 0).then(|| {
|
||||
format!(
|
||||
"at most {} of each item a day",
|
||||
self.config.daily_sell_items
|
||||
)
|
||||
});
|
||||
let parts: Vec<String> = coins.into_iter().chain(items).collect();
|
||||
(!parts.is_empty()).then(|| format!("Selling today: {}.", parts.join(", ")))
|
||||
}
|
||||
|
||||
/// Runs `/worth`, `/sell` or `/buy`; None for another command. `trades`
|
||||
/// is false for a player in creative or spectator mode.
|
||||
pub fn command(
|
||||
&self,
|
||||
&mut self,
|
||||
economy: &mut Economy,
|
||||
me: &Player,
|
||||
trades: bool,
|
||||
@@ -127,7 +420,7 @@ impl Market {
|
||||
return Some(vec![Out::Error("The market is closed.".into())]);
|
||||
}
|
||||
if name == "worth" {
|
||||
return Some(self.worth(economy, bag, args));
|
||||
return Some(self.worth(economy, me, bag, args));
|
||||
}
|
||||
if !trades {
|
||||
return Some(vec![Out::Error(
|
||||
@@ -140,11 +433,7 @@ impl Market {
|
||||
})
|
||||
}
|
||||
|
||||
fn price(&self, id: &str) -> Price {
|
||||
self.config.items.get(id).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn worth(&self, economy: &Economy, bag: &mut dyn Bag, args: &[&str]) -> Vec<Out> {
|
||||
fn worth(&self, economy: &Economy, me: &Player, bag: &mut dyn Bag, args: &[&str]) -> Vec<Out> {
|
||||
let id = match args {
|
||||
[] => match bag.held() {
|
||||
Some((id, ..)) => id,
|
||||
@@ -169,11 +458,22 @@ impl Market {
|
||||
if parts.is_empty() {
|
||||
return vec![Out::Reply(format!("The market doesn't trade {name}."))];
|
||||
}
|
||||
vec![Out::Reply(format!("{name}: {}.", parts.join(", ")))]
|
||||
let mut out = vec![Out::Reply(format!("{name}: {}.", parts.join(", ")))];
|
||||
if p.sell > 0 {
|
||||
if let Some(left) = self.items_left(me.uuid, economy.now, &id) {
|
||||
out.push(Out::Reply(format!(
|
||||
"You may sell {left} more {name} today."
|
||||
)));
|
||||
}
|
||||
out.extend(self.limits_line(economy, me.uuid).map(Out::Reply));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn sell(
|
||||
&self,
|
||||
/// Sells what `args` names (`hand [n]`, `all`, `<item> [n]`), within
|
||||
/// today's limits.
|
||||
pub fn sell(
|
||||
&mut self,
|
||||
economy: &mut Economy,
|
||||
me: &Player,
|
||||
bag: &mut dyn Bag,
|
||||
@@ -224,23 +524,51 @@ impl Market {
|
||||
out.push(Out::Error(format!("The market doesn't buy {name}.")));
|
||||
continue;
|
||||
}
|
||||
// Never more than the account can be paid for at once.
|
||||
let cap = (MAX_AMOUNT / price).min(u32::MAX as i64) as u32;
|
||||
let have = bag.count(&id).min(at_most).min(cap);
|
||||
if have == 0 {
|
||||
// Never more than the account can be paid for at once, nor
|
||||
// than today's limits leave.
|
||||
let now = economy.now;
|
||||
let mut cap = (MAX_AMOUNT / price).min(i64::from(u32::MAX)) as u32;
|
||||
if let Some(left) = self.items_left(me.uuid, now, &id) {
|
||||
cap = cap.min(left);
|
||||
}
|
||||
if let Some(left) = self.coins_left(me.uuid, now) {
|
||||
cap = cap.min((left / price).min(i64::from(u32::MAX)) as u32);
|
||||
}
|
||||
let carried = bag.count(&id).min(at_most);
|
||||
if carried == 0 {
|
||||
out.push(Out::Error(format!("You have no plain {name}.")));
|
||||
continue;
|
||||
}
|
||||
let have = carried.min(cap);
|
||||
if have == 0 {
|
||||
out.push(Out::Error(format!(
|
||||
"You reached today's selling limit for {name}; it starts again at midnight UTC."
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
let taken = bag.take(&id, have);
|
||||
let pay = price * i64::from(taken);
|
||||
if taken == 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(pay) = price.checked_mul(i64::from(taken)) else {
|
||||
bag.give(&id, taken);
|
||||
continue;
|
||||
};
|
||||
let kind = format!("sell {taken} {id}");
|
||||
match economy.adjust(&kind, me.uuid, pay, "") {
|
||||
Ok(_) => {
|
||||
total += pay;
|
||||
self.count_sale(me.uuid, now, &id, taken, pay);
|
||||
total = total.saturating_add(pay);
|
||||
out.push(Out::Reply(format!(
|
||||
"Sold {taken} {name} for {}.",
|
||||
economy.money(pay)
|
||||
)));
|
||||
if taken < carried {
|
||||
out.push(Out::Reply(format!(
|
||||
"{} {name} kept: today's selling limit.",
|
||||
carried - taken
|
||||
)));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let back = bag.give(&id, taken);
|
||||
@@ -259,7 +587,8 @@ impl Market {
|
||||
out
|
||||
}
|
||||
|
||||
fn buy(
|
||||
/// Buys `args` (`<item> [count]`).
|
||||
pub fn buy(
|
||||
&self,
|
||||
economy: &mut Economy,
|
||||
me: &Player,
|
||||
@@ -283,7 +612,15 @@ impl Market {
|
||||
if count > 64 * 36 {
|
||||
return vec![Out::Error("At most a full inventory at once.".into())];
|
||||
}
|
||||
let cost = price * i64::from(count);
|
||||
if count == 0 {
|
||||
return vec![Out::Error("Usage: /buy <item> [count]".into())];
|
||||
}
|
||||
let Some(cost) = price
|
||||
.checked_mul(i64::from(count))
|
||||
.filter(|c| *c <= MAX_AMOUNT)
|
||||
else {
|
||||
return vec![Out::Error("That costs too much at once.".into())];
|
||||
};
|
||||
if economy.balance(me.uuid) < cost {
|
||||
return vec![Out::Error(format!(
|
||||
"{count} {name} cost {}; you have {}.",
|
||||
@@ -307,7 +644,7 @@ impl Market {
|
||||
))];
|
||||
if given < count {
|
||||
// Checked above; kept so money never goes for nothing.
|
||||
let refund = price * i64::from(count - given);
|
||||
let refund = price.saturating_mul(i64::from(count - given));
|
||||
if economy
|
||||
.adjust(
|
||||
&format!("refund {} {id}", count - given),
|
||||
@@ -378,6 +715,10 @@ mod tests {
|
||||
const ALEX: u128 = 1;
|
||||
|
||||
fn setup(name: &str) -> (Economy, Market, Player, std::path::PathBuf) {
|
||||
setup_with(name, Config::default())
|
||||
}
|
||||
|
||||
fn setup_with(name: &str, config: Config) -> (Economy, Market, Player, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!("smp-market-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
let mut e = Economy::open(&dir, EconomyConfig::default()).unwrap();
|
||||
@@ -387,14 +728,7 @@ mod tests {
|
||||
name: "Alex".into(),
|
||||
level: 0,
|
||||
};
|
||||
(
|
||||
e,
|
||||
Market {
|
||||
config: Config::default(),
|
||||
},
|
||||
me,
|
||||
dir,
|
||||
)
|
||||
(e, Market::new(config), me, dir)
|
||||
}
|
||||
|
||||
fn bag(items: &[(&str, u32)], room: u32) -> TestBag {
|
||||
@@ -407,7 +741,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn selling_takes_the_items_then_pays_and_logs_both() {
|
||||
let (mut e, m, me, dir) = setup("sell");
|
||||
let (mut e, mut m, me, dir) = setup("sell");
|
||||
let mut b = bag(
|
||||
&[
|
||||
("minecraft:diamond", 3),
|
||||
@@ -439,7 +773,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn selling_the_hand_takes_only_plain_items() {
|
||||
let (mut e, m, me, dir) = setup("hand");
|
||||
let (mut e, mut m, me, dir) = setup("hand");
|
||||
let mut b = bag(&[("minecraft:iron_ingot", 10)], 0);
|
||||
b.held = Some(("minecraft:iron_ingot".into(), 10, true));
|
||||
let out = m.command(&mut e, &me, true, &mut b, "sell hand 4").unwrap();
|
||||
@@ -460,7 +794,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn buying_checks_money_and_room_before_anything_moves() {
|
||||
let (mut e, m, me, dir) = setup("buy");
|
||||
let (mut e, mut m, me, dir) = setup("buy");
|
||||
e.adjust("test", ALEX, 1000, "").unwrap();
|
||||
let mut b = bag(&[], 3);
|
||||
let out = m
|
||||
@@ -483,8 +817,13 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(out[0], Out::Reply("Bought 2 diamond for 800 coins.".into()));
|
||||
assert_eq!((e.balance(ALEX), b.items["minecraft:diamond"]), (200, 2));
|
||||
let out = m.command(&mut e, &me, true, &mut b, "buy wheat").unwrap();
|
||||
assert_eq!(out, [Out::Error("The market doesn't sell wheat.".into())]);
|
||||
let out = m
|
||||
.command(&mut e, &me, true, &mut b, "buy rotten_flesh")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
[Out::Error("The market doesn't sell rotten flesh.".into())]
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -503,10 +842,10 @@ mod tests {
|
||||
.command(&mut e, &me, false, &mut b, "worth diamond")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
worth,
|
||||
[Out::Reply(
|
||||
worth[0],
|
||||
Out::Reply(
|
||||
"diamond: the market pays 100 coins each, sells it for 400 coins each.".into()
|
||||
)]
|
||||
)
|
||||
);
|
||||
m.config.enabled = false;
|
||||
let out = m.command(&mut e, &me, true, &mut b, "sell all").unwrap();
|
||||
@@ -515,4 +854,181 @@ mod tests {
|
||||
assert!(m.command(&mut e, &me, true, &mut b, "balance").is_none());
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sales_stop_at_the_daily_limits_and_start_again_the_next_day() {
|
||||
let config = Config {
|
||||
daily_sell_coins: 250,
|
||||
daily_sell_items: 30,
|
||||
..Config::default()
|
||||
};
|
||||
let (mut e, mut m, me, dir) = setup_with("limits", config);
|
||||
e.now = 5 * DAY_MS + 1000;
|
||||
let mut b = bag(&[("minecraft:diamond", 5), ("minecraft:wheat", 64)], 0);
|
||||
// 250 coins a day: two diamonds (200), then 50 wheat would pass the
|
||||
// coins but not the 30 items of one kind.
|
||||
let out = m
|
||||
.command(&mut e, &me, true, &mut b, "sell diamond")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out[0],
|
||||
Out::Reply("Sold 2 diamond for 200 coins.".into()),
|
||||
"{out:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
out[1],
|
||||
Out::Reply("3 diamond kept: today's selling limit.".into())
|
||||
);
|
||||
let out = m.command(&mut e, &me, true, &mut b, "sell wheat").unwrap();
|
||||
assert_eq!(
|
||||
out[0],
|
||||
Out::Reply("Sold 30 wheat for 30 coins.".into()),
|
||||
"{out:?}"
|
||||
);
|
||||
let out = m.command(&mut e, &me, true, &mut b, "sell wheat").unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
[Out::Error(
|
||||
"You reached today's selling limit for wheat; it starts again at midnight UTC."
|
||||
.into()
|
||||
)]
|
||||
);
|
||||
let worth = m
|
||||
.command(&mut e, &me, true, &mut b, "worth diamond")
|
||||
.unwrap();
|
||||
assert!(
|
||||
worth.contains(&Out::Reply("You may sell 28 more diamond today.".into())),
|
||||
"{worth:?}"
|
||||
);
|
||||
assert!(
|
||||
worth.contains(&Out::Reply(
|
||||
"Selling today: 20 coins left of your 250 coins a day, at most 30 of each item a day.".into()
|
||||
)),
|
||||
"{worth:?}"
|
||||
);
|
||||
assert_eq!((e.balance(ALEX), b.items["minecraft:diamond"]), (230, 3));
|
||||
// Kept across a restart, in market.json.
|
||||
m.path = Some(dir.join("market.json"));
|
||||
m.dirty = true;
|
||||
m.save().unwrap();
|
||||
let (mut m, _) = Market::open(&dir, m.config.clone());
|
||||
let out = m
|
||||
.command(&mut e, &me, true, &mut b, "sell diamond")
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Error(s) if s.contains("limit")),
|
||||
"{out:?}"
|
||||
);
|
||||
// A new UTC day.
|
||||
e.now = 6 * DAY_MS;
|
||||
let out = m
|
||||
.command(&mut e, &me, true, &mut b, "sell diamond")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out[0],
|
||||
Out::Reply("Sold 2 diamond for 200 coins.".into()),
|
||||
"{out:?}"
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prices_that_would_make_money_are_closed() {
|
||||
let mut config = Config::default();
|
||||
let set = |c: &mut Config, id: &str, buy, sell| {
|
||||
c.items
|
||||
.insert(format!("minecraft:{id}"), Price { buy, sell });
|
||||
};
|
||||
set(&mut config, "emerald", 20, 20);
|
||||
set(&mut config, "iron_block", 0, 300);
|
||||
set(&mut config, "rotten_flesh", -5, i64::MAX);
|
||||
config.items.insert(
|
||||
"minecraft:diamond 1\nop me".into(),
|
||||
Price { buy: 1, sell: 0 },
|
||||
);
|
||||
let closed = validate(&mut config);
|
||||
assert_eq!(closed.len(), 3, "{closed:?}");
|
||||
assert_eq!(
|
||||
config.items["minecraft:emerald"],
|
||||
Price { buy: 20, sell: 0 }
|
||||
);
|
||||
assert!(!config.items.contains_key("minecraft:iron_block"));
|
||||
assert_eq!(
|
||||
config.items["minecraft:rotten_flesh"],
|
||||
Price {
|
||||
buy: 0,
|
||||
sell: MAX_AMOUNT
|
||||
}
|
||||
);
|
||||
assert!(config.items.keys().all(|k| valid_id(k)));
|
||||
// At the largest price: one at a time, without overflow.
|
||||
let (mut e, mut m, me, dir) = setup_with(
|
||||
"validate",
|
||||
Config {
|
||||
daily_sell_coins: 0,
|
||||
..config
|
||||
},
|
||||
);
|
||||
let mut b = bag(&[("minecraft:rotten_flesh", 64)], 0);
|
||||
let out = m
|
||||
.command(&mut e, &me, true, &mut b, "sell rotten_flesh")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out[0],
|
||||
Out::Reply(format!("Sold 1 rotten flesh for {}.", e.money(MAX_AMOUNT))),
|
||||
"{out:?}"
|
||||
);
|
||||
assert_eq!(b.items["minecraft:rotten_flesh"], 63);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_purchase_too_dear_to_count_is_refused() {
|
||||
let mut config = Config::default();
|
||||
config.items.insert(
|
||||
"minecraft:diamond".into(),
|
||||
Price {
|
||||
buy: MAX_AMOUNT,
|
||||
sell: 0,
|
||||
},
|
||||
);
|
||||
let (mut e, mut m, me, dir) = setup_with("dear", config);
|
||||
let mut b = bag(&[], 64 * 36);
|
||||
let out = m
|
||||
.command(&mut e, &me, true, &mut b, "buy diamond 2")
|
||||
.unwrap();
|
||||
assert_eq!(out, [Out::Error("That costs too much at once.".into())]);
|
||||
let out = m
|
||||
.command(&mut e, &me, true, &mut b, "buy diamond 0")
|
||||
.unwrap();
|
||||
assert!(matches!(&out[0], Out::Error(_)), "{out:?}");
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_old_default_prices_become_the_new_ones_and_edited_ones_stay() {
|
||||
let mut config = Config {
|
||||
items: OLD_PRICES
|
||||
.iter()
|
||||
.map(|(id, buy, sell)| {
|
||||
(
|
||||
format!("minecraft:{id}"),
|
||||
Price {
|
||||
buy: *buy,
|
||||
sell: *sell,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
..Config::default()
|
||||
};
|
||||
let mut edited = config.clone();
|
||||
assert!(migrate(&mut config));
|
||||
assert_eq!(config.items, Config::default().items);
|
||||
assert!(!migrate(&mut config), "once");
|
||||
edited.items.get_mut("minecraft:diamond").unwrap().sell = 90;
|
||||
let before = edited.clone();
|
||||
assert!(!migrate(&mut edited));
|
||||
assert_eq!(edited, before);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
// Nicknames: `/nick <name>` shows another name for you in chat and the Tab
|
||||
// list, always as `~name` so it never passes for a real name (hovering it
|
||||
// gives your real one); `/nick off` your name again; operators of level 3
|
||||
// `/nick <player> <name|off>`; anyone `/realname <nick>`. A nickname is 3 to
|
||||
// 16 letters, digits or `_`, and once read without case, `_`, digits and
|
||||
// look-alikes (0 as o, 1 and i as l...) it may not be or contain a name the
|
||||
// server knows (anyone who played here, an operator, the Team in
|
||||
// `protected`, another player's nickname) nor a word kept for the server
|
||||
// (`reserved`: server, admin, notch...); it must pass the server's chat
|
||||
// filter; one change every 10 minutes, and only after `min_playtime_minutes`
|
||||
// of play. Nicknames that break these rules are taken off when the server
|
||||
// starts. `/pay`, `/tpa`, `/trade`... take a nickname as well as a name. The
|
||||
// real name stays everywhere else (the log, the anticheat, the Discord
|
||||
// bridge). Kept in `nicks.json`. A change is decided
|
||||
// here and made by the glue at the next second, outside the player's
|
||||
// command, where the chat filter and the Tab list are in reach. No game
|
||||
// types. Added to the game as `game::smp_nick`; also a module of this crate,
|
||||
// for its tests.
|
||||
|
||||
use super::smp_economy::{Out, Player};
|
||||
use super::smp_store::{parse_uuid, read_json, uuid_string, write_json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
|
||||
pub const COMMANDS: [&str; 2] = ["nick", "realname"];
|
||||
|
||||
/// `config.json`'s `nicks`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub enabled: bool,
|
||||
/// Between two changes of one's own nickname.
|
||||
pub cooldown_seconds: i64,
|
||||
/// The play a player needs here before their first nickname.
|
||||
pub min_playtime_minutes: i64,
|
||||
/// Names no nickname may look like, besides the players' and the
|
||||
/// operators': the Team's.
|
||||
pub protected: Vec<String>,
|
||||
/// Words no nickname may look like (contain, from 4 letters on; be, or
|
||||
/// have as a `_` part, when shorter).
|
||||
pub reserved: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
let list = |l: &[&str]| l.iter().map(|s| (*s).to_owned()).collect();
|
||||
Self {
|
||||
enabled: true,
|
||||
cooldown_seconds: 600,
|
||||
min_playtime_minutes: 60,
|
||||
protected: list(&[
|
||||
"rdbt",
|
||||
"rdbtclient",
|
||||
"luzW",
|
||||
"bunchy7s",
|
||||
"lu4ovic",
|
||||
"ludovic",
|
||||
"dezer-x",
|
||||
"dezerx",
|
||||
"levzzz",
|
||||
]),
|
||||
reserved: list(&[
|
||||
"server",
|
||||
"console",
|
||||
"admin",
|
||||
"mod",
|
||||
"moderator",
|
||||
"owner",
|
||||
"staff",
|
||||
"op",
|
||||
"horizon",
|
||||
"kira",
|
||||
"system",
|
||||
"error",
|
||||
"notch",
|
||||
"jeb",
|
||||
"dinnerbone",
|
||||
"mojang",
|
||||
"microsoft",
|
||||
"herobrine",
|
||||
]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A name as it reads: lowercase, look-alikes folded (`0` o, `1` `i` `|` l,
|
||||
/// `3` e, `4` `@` a, `5` `$` s, `7` t, `8` b, `9` g), anything else that
|
||||
/// isn't a letter (`_`, `-`, other digits) dropped.
|
||||
pub fn skeleton(name: &str) -> String {
|
||||
name.chars()
|
||||
.filter_map(|c| match c.to_ascii_lowercase() {
|
||||
'0' => Some('o'),
|
||||
'1' | 'i' | '|' => Some('l'),
|
||||
'3' => Some('e'),
|
||||
'4' | '@' => Some('a'),
|
||||
'5' | '$' => Some('s'),
|
||||
'7' => Some('t'),
|
||||
'8' => Some('b'),
|
||||
'9' => Some('g'),
|
||||
c if c.is_ascii_lowercase() => Some(c),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether a nickname reading as `nick` (skeleton) copies `name`'s: the
|
||||
/// same, or containing it when it has 4 letters or more.
|
||||
fn looks_like(nick: &str, name: &str) -> bool {
|
||||
!name.is_empty() && (nick == name || (name.len() >= 4 && nick.contains(name)))
|
||||
}
|
||||
|
||||
/// One player's nickname, and when they last changed it (ms).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Nick {
|
||||
pub nick: Option<String>,
|
||||
pub changed: i64,
|
||||
}
|
||||
|
||||
/// `nicks.json`, by UUID.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct File {
|
||||
pub nicks: BTreeMap<String, Nick>,
|
||||
}
|
||||
|
||||
/// A change to make: whose nickname, the new one (None: off), who asked
|
||||
/// (told the outcome), and whether it starts the cooldown.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Change {
|
||||
pub uuid: u128,
|
||||
pub nick: Option<String>,
|
||||
pub by: u128,
|
||||
pub own: bool,
|
||||
}
|
||||
|
||||
/// The names a nickname may not copy: real names of anyone who has played
|
||||
/// here and of the operators.
|
||||
pub trait Names {
|
||||
/// Every real name the server knows, with its player's UUID when known
|
||||
/// (an operator who never played has none).
|
||||
fn known(&self) -> Vec<(Option<u128>, String)>;
|
||||
/// A player who has played here by name (any case): UUID and name.
|
||||
fn find(&self, name: &str) -> Option<(u128, String)>;
|
||||
/// A player's real name.
|
||||
fn name_of(&self, uuid: u128) -> Option<String>;
|
||||
}
|
||||
|
||||
pub struct Nicks {
|
||||
pub config: Config,
|
||||
file: File,
|
||||
path: PathBuf,
|
||||
dirty: bool,
|
||||
/// Changes waiting for the next second.
|
||||
pub pending: Vec<Change>,
|
||||
}
|
||||
|
||||
/// 3 to 16 of `A-Z a-z 0-9 _`.
|
||||
pub fn valid(nick: &str) -> bool {
|
||||
(3..=16).contains(&nick.len()) && nick.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
|
||||
}
|
||||
|
||||
impl Nicks {
|
||||
/// The nicknames kept in `dir` (`world/horizon-smp/`); an error when
|
||||
/// `nicks.json` can't be read (nicknames are off then).
|
||||
pub fn open(dir: &std::path::Path, config: Config) -> Result<Self, String> {
|
||||
let path = dir.join("nicks.json");
|
||||
Ok(Self {
|
||||
config,
|
||||
file: read_json(&path)?,
|
||||
path,
|
||||
dirty: false,
|
||||
pending: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save(&mut self) -> Result<(), String> {
|
||||
if self.dirty {
|
||||
write_json(&self.path, &self.file)?;
|
||||
self.dirty = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every nickname set, by UUID.
|
||||
pub fn all(&self) -> Vec<(u128, String)> {
|
||||
self.file
|
||||
.nicks
|
||||
.iter()
|
||||
.filter_map(|(k, n)| Some((parse_uuid(k)?, n.nick.clone()?)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn nick_of(&self, uuid: u128) -> Option<&str> {
|
||||
self.file.nicks.get(&uuid_string(uuid))?.nick.as_deref()
|
||||
}
|
||||
|
||||
/// Whose nickname `nick` is (any case, with or without its `~`).
|
||||
pub fn owner(&self, nick: &str) -> Option<u128> {
|
||||
let nick = nick.strip_prefix('~').unwrap_or(nick);
|
||||
self.all()
|
||||
.into_iter()
|
||||
.find(|(_, n)| n.eq_ignore_ascii_case(nick))
|
||||
.map(|(u, _)| u)
|
||||
}
|
||||
|
||||
/// Why `nick` can't be the nickname of `uuid` (real name `real`), or
|
||||
/// None: its form, then the server's words, then every name the server
|
||||
/// knows and the other nicknames. One's own name never counts.
|
||||
pub fn refused(&self, uuid: u128, real: &str, nick: &str, names: &dyn Names) -> Option<String> {
|
||||
if !valid(nick) {
|
||||
return Some("A nickname is 3 to 16 letters, digits or _.".into());
|
||||
}
|
||||
let s = skeleton(nick);
|
||||
if s.len() < 3 {
|
||||
return Some("A nickname needs at least 3 letters.".into());
|
||||
}
|
||||
let parts: Vec<String> = nick.split('_').map(skeleton).collect();
|
||||
for word in &self.config.reserved {
|
||||
let w = skeleton(word);
|
||||
if looks_like(&s, &w) || parts.contains(&w) {
|
||||
return Some(format!(
|
||||
"{nick} reads as \"{word}\": that word is kept for the server."
|
||||
));
|
||||
}
|
||||
}
|
||||
let own = skeleton(real);
|
||||
let others = names
|
||||
.known()
|
||||
.into_iter()
|
||||
.filter(|(u, _)| *u != Some(uuid))
|
||||
.map(|(_, n)| n)
|
||||
.chain(self.config.protected.iter().cloned())
|
||||
.chain(
|
||||
self.all()
|
||||
.into_iter()
|
||||
.filter(|(u, _)| *u != uuid)
|
||||
.map(|(_, n)| n),
|
||||
);
|
||||
for name in others {
|
||||
let k = skeleton(&name);
|
||||
if k != own && looks_like(&s, &k) {
|
||||
return Some(format!("{nick} looks like the name of another player."));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Takes off the nicknames that break today's rules (from an older
|
||||
/// version): each one's UUID, nickname and reason, for the log.
|
||||
pub fn purge(&mut self, names: &dyn Names) -> Vec<(u128, String, String)> {
|
||||
let mut gone = Vec::new();
|
||||
for (uuid, nick) in self.all() {
|
||||
let real = names.name_of(uuid).unwrap_or_default();
|
||||
if let Some(why) = self.refused(uuid, &real, &nick, names) {
|
||||
if let Some(n) = self.file.nicks.get_mut(&uuid_string(uuid)) {
|
||||
n.nick = None;
|
||||
}
|
||||
self.dirty = true;
|
||||
gone.push((uuid, nick, why));
|
||||
}
|
||||
}
|
||||
gone
|
||||
}
|
||||
|
||||
/// `line` with a nickname in a player argument of `/pay`, `/tpa`,
|
||||
/// `/trade`... replaced by its player's name; None when nothing to
|
||||
/// change. `is_name`: whether a word is a real name (it wins);
|
||||
/// `name_of`: a player's real name.
|
||||
pub fn resolve(
|
||||
&self,
|
||||
line: &str,
|
||||
is_name: &dyn Fn(&str) -> bool,
|
||||
name_of: &dyn Fn(u128) -> Option<String>,
|
||||
) -> Option<String> {
|
||||
let mut words: Vec<&str> = line.split_whitespace().collect();
|
||||
let at = match words.as_slice() {
|
||||
["trade", "accept" | "deny", _] => 2,
|
||||
[
|
||||
"pay" | "tpa" | "trade" | "playtime" | "tpaccept" | "tpdeny",
|
||||
_,
|
||||
..,
|
||||
] => 1,
|
||||
["eco" | "money", "give" | "take" | "set", _, _] => 2,
|
||||
_ => return None,
|
||||
};
|
||||
let word = words[at];
|
||||
if word == "*"
|
||||
|| (at == 1 && ["accept", "deny", "cancel", "money"].contains(&word))
|
||||
|| is_name(word)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let real = name_of(self.owner(word)?)?;
|
||||
words[at] = ℜ
|
||||
Some(words.join(" "))
|
||||
}
|
||||
|
||||
/// Runs `/nick` or `/realname`; None for another command.
|
||||
pub fn command(
|
||||
&mut self,
|
||||
me: &Player,
|
||||
names: &dyn Names,
|
||||
played_seconds: i64,
|
||||
now: i64,
|
||||
line: &str,
|
||||
) -> Option<Vec<Out>> {
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
let (&name, args) = words.split_first()?;
|
||||
if !COMMANDS.contains(&name) {
|
||||
return None;
|
||||
}
|
||||
if !self.config.enabled {
|
||||
return Some(vec![Out::Error("Nicknames are off on this server.".into())]);
|
||||
}
|
||||
Some(match (name, args) {
|
||||
("nick", []) => vec![Out::Reply(match self.nick_of(me.uuid) {
|
||||
Some(n) => {
|
||||
format!("You go by ~{n}. /nick off takes it off; /nick <name> picks another.")
|
||||
}
|
||||
None => {
|
||||
"/nick <name> picks a nickname (shown as ~name); /nick off takes it off.".into()
|
||||
}
|
||||
})],
|
||||
("realname", [nick]) => match self
|
||||
.owner(nick)
|
||||
.and_then(|u| Some((u, self.nick_of(u)?.to_owned())))
|
||||
{
|
||||
Some((u, n)) => {
|
||||
let real = names.name_of(u).unwrap_or_else(|| uuid_string(u));
|
||||
vec![Out::Reply(format!("~{n} is {real}."))]
|
||||
}
|
||||
None => vec![Out::Error(format!("Nobody goes by {nick}."))],
|
||||
},
|
||||
("realname", _) => vec![Out::Error("Usage: /realname <nickname>".into())],
|
||||
("nick", [new]) => {
|
||||
let need = self.config.min_playtime_minutes;
|
||||
if me.level < 3 && played_seconds < need * 60 && !new.eq_ignore_ascii_case("off") {
|
||||
vec![Out::Error(format!(
|
||||
"Nicknames come after {need} minutes of play here; you have played {} so far.",
|
||||
played_seconds / 60
|
||||
))]
|
||||
} else {
|
||||
self.ask(me, me.uuid, &me.name, new, names, now, true)
|
||||
}
|
||||
}
|
||||
("nick", [who, new]) if me.level >= 3 => match names.find(who) {
|
||||
Some((uuid, real)) => self.ask(me, uuid, &real, new, names, now, false),
|
||||
None => vec![Out::Error(format!(
|
||||
"No player named {who} has played here."
|
||||
))],
|
||||
},
|
||||
("nick", [_, _]) => vec![Out::Error(
|
||||
"Only operators of level 3 may set another player's nickname.".into(),
|
||||
)],
|
||||
_ => vec![Out::Error("Usage: /nick <name>, /nick off".into())],
|
||||
})
|
||||
}
|
||||
|
||||
/// A change for `uuid` (real name `real`), checked; queued when right.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn ask(
|
||||
&mut self,
|
||||
me: &Player,
|
||||
uuid: u128,
|
||||
real: &str,
|
||||
new: &str,
|
||||
names: &dyn Names,
|
||||
now: i64,
|
||||
own: bool,
|
||||
) -> Vec<Out> {
|
||||
let current = self.nick_of(uuid);
|
||||
let new = new.strip_prefix('~').unwrap_or(new);
|
||||
let off = new.eq_ignore_ascii_case("off") || new == real;
|
||||
if own && let Some(n) = self.file.nicks.get(&uuid_string(uuid)) {
|
||||
let wait = n.changed + self.config.cooldown_seconds * 1000 - now;
|
||||
if wait > 0 && me.level < 3 {
|
||||
return vec![Out::Error(format!(
|
||||
"You changed your nickname recently: wait {} more minute(s).",
|
||||
(wait + 59_999) / 60_000
|
||||
))];
|
||||
}
|
||||
}
|
||||
if off {
|
||||
if current.is_none() {
|
||||
return vec![Out::Error("No nickname to take off.".into())];
|
||||
}
|
||||
} else {
|
||||
if let Some(why) = self.refused(uuid, real, new, names) {
|
||||
return vec![Out::Error(why)];
|
||||
}
|
||||
if current.is_some_and(|c| c == new) {
|
||||
return vec![Out::Error(format!(
|
||||
"That is already the nickname of {real}."
|
||||
))];
|
||||
}
|
||||
}
|
||||
self.pending.retain(|c| c.uuid != uuid);
|
||||
self.pending.push(Change {
|
||||
uuid,
|
||||
nick: (!off).then(|| new.to_owned()),
|
||||
by: me.uuid,
|
||||
own,
|
||||
});
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// A pending change the chat filter let through: kept. Lines for the
|
||||
/// one who asked and the player.
|
||||
pub fn apply(&mut self, change: &Change, real: &str, now: i64) -> Vec<Out> {
|
||||
let key = uuid_string(change.uuid);
|
||||
let entry = self.file.nicks.entry(key.clone()).or_default();
|
||||
entry.nick = change.nick.clone();
|
||||
if change.own {
|
||||
entry.changed = now;
|
||||
}
|
||||
if entry.nick.is_none() && entry.changed == 0 {
|
||||
self.file.nicks.remove(&key);
|
||||
}
|
||||
self.dirty = true;
|
||||
let line = match &change.nick {
|
||||
Some(n) => format!("{real} now goes by ~{n}. /nick off takes it off."),
|
||||
None => format!("{real} goes by their name again."),
|
||||
};
|
||||
let mut out = vec![Out::Tell(change.by, line.clone())];
|
||||
if change.by != change.uuid {
|
||||
out.push(Out::Tell(change.uuid, line));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
const ALEX: u128 = 1;
|
||||
const STEVE: u128 = 2;
|
||||
const HOUR: i64 = 3600;
|
||||
|
||||
struct Known(Vec<(u128, &'static str)>);
|
||||
impl Names for Known {
|
||||
fn known(&self) -> Vec<(Option<u128>, String)> {
|
||||
self.0
|
||||
.iter()
|
||||
.map(|(u, n)| (Some(*u), (*n).to_owned()))
|
||||
.chain([(None, "SomeOp".to_owned())])
|
||||
.collect()
|
||||
}
|
||||
fn find(&self, name: &str) -> Option<(u128, String)> {
|
||||
self.0
|
||||
.iter()
|
||||
.find(|(_, n)| n.eq_ignore_ascii_case(name))
|
||||
.map(|(u, n)| (*u, (*n).to_owned()))
|
||||
}
|
||||
fn name_of(&self, uuid: u128) -> Option<String> {
|
||||
self.0
|
||||
.iter()
|
||||
.find(|(u, _)| *u == uuid)
|
||||
.map(|(_, n)| (*n).to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn setup(name: &str) -> (Nicks, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!("smp-nick-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
(Nicks::open(&dir, Config::default()).unwrap(), dir)
|
||||
}
|
||||
|
||||
fn player(uuid: u128, name: &str, level: u8) -> Player {
|
||||
Player {
|
||||
uuid,
|
||||
name: name.into(),
|
||||
level,
|
||||
}
|
||||
}
|
||||
|
||||
fn known() -> Known {
|
||||
Known(vec![(ALEX, "Alex"), (STEVE, "Steve"), (3, "Bob")])
|
||||
}
|
||||
|
||||
/// What `/nick <nick>` answers Alex (played an hour): "" when queued.
|
||||
fn try_nick(n: &mut Nicks, nick: &str) -> String {
|
||||
n.pending.clear();
|
||||
match n
|
||||
.command(
|
||||
&player(ALEX, "Alex", 0),
|
||||
&known(),
|
||||
HOUR,
|
||||
0,
|
||||
&format!("nick {nick}"),
|
||||
)
|
||||
.unwrap()
|
||||
.as_slice()
|
||||
{
|
||||
[] => String::new(),
|
||||
[Out::Error(e)] => e.clone(),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_read_without_case_underscores_digits_and_look_alikes() {
|
||||
assert_eq!(skeleton("N0tch"), "notch");
|
||||
assert_eq!(skeleton("D1nnerb0ne_"), "dlnnerbone");
|
||||
assert_eq!(skeleton("dinnerbone"), "dlnnerbone");
|
||||
assert_eq!(skeleton("lu4ovic"), skeleton("LU4OVIC"));
|
||||
assert_eq!(skeleton("$3rv3r"), "server");
|
||||
assert_eq!(skeleton("rdbt_2"), "rdbt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_form_and_the_letters() {
|
||||
let (mut n, dir) = setup("form");
|
||||
for bad in ["ab", "way_too_long_nickname", "bad-name", "no.dots"] {
|
||||
assert_eq!(
|
||||
try_nick(&mut n, bad),
|
||||
"A nickname is 3 to 16 letters, digits or _.",
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
try_nick(&mut n, "1_2_6"),
|
||||
"A nickname needs at least 3 letters."
|
||||
);
|
||||
assert_eq!(try_nick(&mut n, "Sunny"), "");
|
||||
fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_kept_for_the_server_are_refused_in_any_spelling() {
|
||||
let (mut n, dir) = setup("reserved");
|
||||
for bad in [
|
||||
"Server",
|
||||
"SERVER_",
|
||||
"s3rv3r",
|
||||
"Notch",
|
||||
"N0tch",
|
||||
"Jeb_",
|
||||
"jeb",
|
||||
"Dinnerbone",
|
||||
"D1nnerb0ne",
|
||||
"ERROR",
|
||||
"Err0r",
|
||||
"TheAdmin",
|
||||
"Mod",
|
||||
"Mod_Steve",
|
||||
"Big_Op",
|
||||
"Herobrine2",
|
||||
"Mojang",
|
||||
"xXConsoleXx",
|
||||
"Staff",
|
||||
"Kira",
|
||||
] {
|
||||
assert!(
|
||||
try_nick(&mut n, bad).contains("is kept for the server"),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
// Short words count only alone or as a `_` part.
|
||||
for good in ["Opal", "Modest", "Jebbo", "Hopper"] {
|
||||
assert_eq!(try_nick(&mut n, good), "", "{good}");
|
||||
}
|
||||
fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_of_players_operators_the_team_and_other_nicknames_are_refused() {
|
||||
let (mut n, dir) = setup("names");
|
||||
for bad in [
|
||||
"steve",
|
||||
"St3ve",
|
||||
"Steve_",
|
||||
"TheSteve",
|
||||
"SomeOp",
|
||||
"S0me0p",
|
||||
"rdbt",
|
||||
"RDBT_",
|
||||
"rdbtclient",
|
||||
"luzW",
|
||||
"Lu4ovic",
|
||||
"ludovic1",
|
||||
"Bunchy7s",
|
||||
"levzzz",
|
||||
"dezerx",
|
||||
] {
|
||||
assert_eq!(
|
||||
try_nick(&mut n, bad),
|
||||
format!("{bad} looks like the name of another player."),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
// A name of 3 letters only when it is the whole nickname.
|
||||
assert!(try_nick(&mut n, "bob").contains("another player"));
|
||||
assert_eq!(try_nick(&mut n, "Bobcat"), "");
|
||||
// One's own name is fine.
|
||||
assert_eq!(try_nick(&mut n, "Alex_the_Great"), "");
|
||||
// Another player's nickname, in any spelling.
|
||||
let rocky = Change {
|
||||
uuid: STEVE,
|
||||
nick: Some("Rocky".into()),
|
||||
by: STEVE,
|
||||
own: true,
|
||||
};
|
||||
n.apply(&rocky, "Steve", 0);
|
||||
for bad in ["rocky", "R0cky", "Rocky_2", "RockyRoad"] {
|
||||
assert!(try_nick(&mut n, bad).contains("another player"), "{bad}");
|
||||
}
|
||||
fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nickname_shows_with_a_tilde_and_waits_for_playtime_and_the_cooldown() {
|
||||
let (mut n, dir) = setup("set");
|
||||
let k = known();
|
||||
let alex = player(ALEX, "Alex", 0);
|
||||
assert_eq!(
|
||||
n.command(&alex, &k, 59 * 60, 0, "nick Sunny").unwrap(),
|
||||
[Out::Error(
|
||||
"Nicknames come after 60 minutes of play here; you have played 59 so far.".into()
|
||||
)]
|
||||
);
|
||||
// `/nick` alone says how it works; a `~` typed is not part of it.
|
||||
assert!(
|
||||
matches!(&n.command(&alex, &k, 0, 0, "nick").unwrap()[0], Out::Reply(r) if r.contains("/nick off"))
|
||||
);
|
||||
assert_eq!(n.command(&alex, &k, HOUR, 0, "nick ~Sunny").unwrap(), []);
|
||||
let c = n.pending.remove(0);
|
||||
assert_eq!(c.nick.as_deref(), Some("Sunny"));
|
||||
assert_eq!(
|
||||
n.apply(&c, "Alex", 1000),
|
||||
[Out::Tell(
|
||||
ALEX,
|
||||
"Alex now goes by ~Sunny. /nick off takes it off.".into()
|
||||
)]
|
||||
);
|
||||
assert!(
|
||||
matches!(&n.command(&alex, &k, HOUR, 2000, "nick").unwrap()[0], Out::Reply(r) if r.starts_with("You go by ~Sunny."))
|
||||
);
|
||||
assert_eq!(
|
||||
n.command(&alex, &k, HOUR, 60_000, "nick off").unwrap(),
|
||||
[Out::Error(
|
||||
"You changed your nickname recently: wait 10 more minute(s).".into()
|
||||
)]
|
||||
);
|
||||
assert_eq!(n.command(&alex, &k, HOUR, 601_000, "nick off").unwrap(), []);
|
||||
n.save().unwrap();
|
||||
let mut again = Nicks::open(&dir, Config::default()).unwrap();
|
||||
assert_eq!(
|
||||
again.nick_of(ALEX),
|
||||
Some("Sunny"),
|
||||
"kept until the change is applied"
|
||||
);
|
||||
let steve = player(STEVE, "Steve", 0);
|
||||
for asked in ["realname Sunny", "realname ~sunny"] {
|
||||
assert_eq!(
|
||||
again.command(&steve, &k, 0, 0, asked).unwrap(),
|
||||
[Out::Reply("~Sunny is Alex.".into())]
|
||||
);
|
||||
}
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nicknames_that_break_the_rules_are_taken_off_on_load() {
|
||||
let (mut n, dir) = setup("purge");
|
||||
for (uuid, nick) in [(ALEX, "Notch"), (STEVE, "Rocky"), (3, "rdbt"), (4, "ERROR")] {
|
||||
n.file.nicks.insert(
|
||||
uuid_string(uuid),
|
||||
Nick {
|
||||
nick: Some(nick.into()),
|
||||
changed: 5,
|
||||
},
|
||||
);
|
||||
}
|
||||
let gone = n.purge(&known());
|
||||
let nicks: Vec<&str> = gone.iter().map(|g| g.1.as_str()).collect();
|
||||
assert_eq!(nicks, ["Notch", "rdbt", "ERROR"]);
|
||||
assert!(gone[0].2.contains("kept for the server"));
|
||||
assert_eq!(n.all(), [(STEVE, "Rocky".to_owned())]);
|
||||
// The cooldown stays: no new nickname at once.
|
||||
assert_eq!(n.file.nicks[&uuid_string(ALEX)].changed, 5);
|
||||
fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_that_name_a_player_take_a_nickname() {
|
||||
let (mut n, dir) = setup("resolve");
|
||||
n.apply(
|
||||
&Change {
|
||||
uuid: STEVE,
|
||||
nick: Some("Rocky".into()),
|
||||
by: STEVE,
|
||||
own: true,
|
||||
},
|
||||
"Steve",
|
||||
0,
|
||||
);
|
||||
let is_name = |w: &str| ["alex", "steve"].contains(&w.to_ascii_lowercase().as_str());
|
||||
let name_of = |u: u128| known().name_of(u);
|
||||
let r = |line: &str| n.resolve(line, &is_name, &name_of);
|
||||
assert_eq!(r("pay Rocky 50").as_deref(), Some("pay Steve 50"));
|
||||
assert_eq!(r("pay ~rocky 50").as_deref(), Some("pay Steve 50"));
|
||||
assert_eq!(r("tpa Rocky").as_deref(), Some("tpa Steve"));
|
||||
assert_eq!(r("trade Rocky").as_deref(), Some("trade Steve"));
|
||||
assert_eq!(
|
||||
r("trade accept Rocky").as_deref(),
|
||||
Some("trade accept Steve")
|
||||
);
|
||||
assert_eq!(r("eco give Rocky 5").as_deref(), Some("eco give Steve 5"));
|
||||
assert_eq!(r("pay Steve 50"), None, "a real name stays");
|
||||
assert_eq!(r("trade accept"), None);
|
||||
assert_eq!(r("eco give * 5"), None);
|
||||
assert_eq!(r("pay Nobody 5"), None);
|
||||
assert_eq!(r("balance"), None);
|
||||
fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operators_set_others_nicknames_without_a_cooldown_or_playtime() {
|
||||
let (mut n, dir) = setup("op");
|
||||
let k = known();
|
||||
let op = player(ALEX, "Alex", 3);
|
||||
assert_eq!(
|
||||
n.command(&player(STEVE, "Steve", 0), &k, HOUR, 0, "nick Alex Bobby")
|
||||
.unwrap(),
|
||||
[Out::Error(
|
||||
"Only operators of level 3 may set another player's nickname.".into()
|
||||
)]
|
||||
);
|
||||
assert_eq!(n.command(&op, &k, 0, 0, "nick Steve Rocky").unwrap(), []);
|
||||
let c = n.pending.remove(0);
|
||||
assert_eq!(
|
||||
n.apply(&c, "Steve", 5),
|
||||
[
|
||||
Out::Tell(
|
||||
ALEX,
|
||||
"Steve now goes by ~Rocky. /nick off takes it off.".into()
|
||||
),
|
||||
Out::Tell(
|
||||
STEVE,
|
||||
"Steve now goes by ~Rocky. /nick off takes it off.".into()
|
||||
)
|
||||
]
|
||||
);
|
||||
assert_eq!(n.command(&op, &k, 0, 10, "nick Steve off").unwrap(), []);
|
||||
assert_eq!(
|
||||
n.command(&op, &k, 0, 10, "nick Nobody x").unwrap(),
|
||||
[Out::Error("No player named Nobody has played here.".into())]
|
||||
);
|
||||
assert_eq!(n.command(&op, &k, 0, 10, "balance"), None);
|
||||
fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
// Places: the server's spawn (`/spawn`, `/setspawn`), each player's homes
|
||||
// (`/home`, `/sethome`, `/delhome`, `/homes`), warps (`/warp`, `/warps`,
|
||||
// `/setwarp`, `/delwarp`), teleport requests between players (`/tpa`,
|
||||
// `/tpaccept`, `/tpdeny`), `/back` (where the player died, or was before
|
||||
// their last teleport), `/playtime` and `/rules`. A teleport waits `warmup_seconds`,
|
||||
// and a player who moves or is hurt meanwhile stays where they are; the glue
|
||||
// moves them with a vanilla `tp` run as the server. The number of homes grows
|
||||
// with the time a player has played here, counted by the mod. Everything is
|
||||
// in `places.json`, written atomically. No game types. Added to the game as
|
||||
// `game::smp_places`; also a module of this crate, for its tests.
|
||||
|
||||
use super::smp_economy::{Out, Player};
|
||||
use super::smp_store;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// `config.json`'s `places` section.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
/// How long a player stands still before a teleport (0: at once).
|
||||
pub warmup_seconds: i64,
|
||||
/// How long a `/tpa` request waits for its answer.
|
||||
pub request_seconds: i64,
|
||||
/// Between two teleports of one player.
|
||||
pub cooldown_seconds: i64,
|
||||
/// Homes a player may set, by hours played here: `[hours, homes]`, the
|
||||
/// last line reached counts.
|
||||
pub homes: Vec<[i64; 2]>,
|
||||
/// `/rules`, a line each.
|
||||
pub rules: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
warmup_seconds: 3,
|
||||
request_seconds: 60,
|
||||
cooldown_seconds: 10,
|
||||
homes: vec![[0, 1], [5, 2], [20, 3], [100, 5]],
|
||||
rules: vec![
|
||||
"Be respectful to everyone.".into(),
|
||||
"No griefing, no stealing, no cheats.".into(),
|
||||
"The currency is earned in game only: never bought or sold for real money.".into(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A place in a level.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Place {
|
||||
/// `minecraft:overworld`, `minecraft:the_nether`, `minecraft:the_end`.
|
||||
pub dimension: String,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub z: f64,
|
||||
}
|
||||
|
||||
/// A player on the server, as the places see them.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Body {
|
||||
pub uuid: u128,
|
||||
pub name: String,
|
||||
pub at: Place,
|
||||
pub health: f32,
|
||||
}
|
||||
|
||||
/// `places.json`.
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct Data {
|
||||
spawn: Option<Place>,
|
||||
warps: BTreeMap<String, Place>,
|
||||
/// Homes by player UUID, then by name.
|
||||
homes: BTreeMap<String, BTreeMap<String, Place>>,
|
||||
/// Seconds played here, by player UUID.
|
||||
played: BTreeMap<String, i64>,
|
||||
}
|
||||
|
||||
/// Where a teleport goes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum To {
|
||||
/// A place, and what it is (`home "base"`).
|
||||
Place(Place, String),
|
||||
/// A player, wherever they are when the warmup ends.
|
||||
Player(u128, String),
|
||||
}
|
||||
|
||||
/// A teleport waiting for its warmup.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Pending {
|
||||
pub uuid: u128,
|
||||
to: To,
|
||||
from: Place,
|
||||
health: f32,
|
||||
due: i64,
|
||||
}
|
||||
|
||||
/// A `/tpa` request: `from` asks to go to `to`.
|
||||
struct Request {
|
||||
from: u128,
|
||||
from_name: String,
|
||||
to: u128,
|
||||
at: i64,
|
||||
}
|
||||
|
||||
/// A teleport to make now: the player's name and where.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Teleport {
|
||||
pub uuid: u128,
|
||||
pub name: String,
|
||||
pub to: Place,
|
||||
/// What the player is told once there.
|
||||
pub done: String,
|
||||
}
|
||||
|
||||
/// The places' commands.
|
||||
pub const COMMANDS: [&str; 16] = [
|
||||
"spawn", "setspawn", "home", "sethome", "delhome", "homes", "warp", "warps", "setwarp",
|
||||
"delwarp", "tpa", "tpaccept", "tpdeny", "rules", "back", "playtime",
|
||||
];
|
||||
|
||||
pub struct Places {
|
||||
pub config: Config,
|
||||
path: PathBuf,
|
||||
data: Data,
|
||||
/// Homes, warps or the spawn changed: written at the next save.
|
||||
changed: bool,
|
||||
/// Time played changed: written once a minute.
|
||||
played_at: Option<i64>,
|
||||
pub pending: Vec<Pending>,
|
||||
requests: Vec<Request>,
|
||||
last: HashMap<u128, i64>,
|
||||
/// Where `/back` goes: the place and whether they died there. Not kept
|
||||
/// over a restart.
|
||||
back: HashMap<u128, (Place, bool)>,
|
||||
/// The players seen dead (health 0) and not respawned yet.
|
||||
dead: std::collections::HashSet<u128>,
|
||||
}
|
||||
|
||||
/// `2 h 05 min`, `12 min`.
|
||||
fn duration(seconds: i64) -> String {
|
||||
let (h, m) = (seconds / 3600, seconds % 3600 / 60);
|
||||
if h > 0 {
|
||||
format!("{h} h {m:02} min")
|
||||
} else {
|
||||
format!("{m} min")
|
||||
}
|
||||
}
|
||||
|
||||
/// A home's or a warp's name: 1 to 16 of `a-z 0-9 _ -`.
|
||||
fn name_ok(name: &str) -> bool {
|
||||
(1..=16).contains(&name.len())
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "_-".contains(c))
|
||||
}
|
||||
|
||||
fn moved(a: &Place, b: &Place) -> bool {
|
||||
let d = (a.x - b.x).powi(2) + (a.y - b.y).powi(2) + (a.z - b.z).powi(2);
|
||||
a.dimension != b.dimension || d > 0.25
|
||||
}
|
||||
|
||||
impl Places {
|
||||
/// `places.json` in `dir`; an error when it exists but can't be read
|
||||
/// (never replaced by an empty one).
|
||||
pub fn open(dir: &Path, config: Config) -> Result<Self, String> {
|
||||
let path = dir.join("places.json");
|
||||
let data = smp_store::read_json(&path)?;
|
||||
Ok(Self {
|
||||
config,
|
||||
path,
|
||||
data,
|
||||
changed: false,
|
||||
played_at: None,
|
||||
pending: Vec::new(),
|
||||
requests: Vec::new(),
|
||||
last: HashMap::new(),
|
||||
back: HashMap::new(),
|
||||
dead: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes `places.json` when homes, warps or the spawn changed, or once
|
||||
/// a minute when only the time played did.
|
||||
pub fn save(&mut self, now: i64) -> Result<(), String> {
|
||||
let played = self.played_at.is_some_and(|t| now - t >= 60_000);
|
||||
if !self.changed && !played {
|
||||
return Ok(());
|
||||
}
|
||||
smp_store::write_json(&self.path, &self.data)?;
|
||||
self.changed = false;
|
||||
self.played_at = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Counts a second played for each of these players.
|
||||
pub fn played(&mut self, uuids: &[u128], now: i64) {
|
||||
for &u in uuids {
|
||||
*self
|
||||
.data
|
||||
.played
|
||||
.entry(smp_store::uuid_string(u))
|
||||
.or_default() += 1;
|
||||
}
|
||||
if !uuids.is_empty() {
|
||||
self.played_at.get_or_insert(now);
|
||||
}
|
||||
}
|
||||
|
||||
/// The seconds a player has played here.
|
||||
pub fn played_seconds(&self, uuid: u128) -> i64 {
|
||||
self.data
|
||||
.played
|
||||
.get(&smp_store::uuid_string(uuid))
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// How many homes a player may have now.
|
||||
pub fn home_limit(&self, uuid: u128) -> usize {
|
||||
let hours = self
|
||||
.data
|
||||
.played
|
||||
.get(&smp_store::uuid_string(uuid))
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
/ 3600;
|
||||
self.config
|
||||
.homes
|
||||
.iter()
|
||||
.filter(|[h, _]| hours >= *h)
|
||||
.map(|[_, n]| (*n).max(0) as usize)
|
||||
.next_back()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Starts a teleport's warmup, or says why not.
|
||||
fn start(&mut self, me: &Body, to: To, now: i64) -> Vec<Out> {
|
||||
if let Some(&t) = self.last.get(&me.uuid) {
|
||||
let wait = t + self.config.cooldown_seconds * 1000 - now;
|
||||
if wait > 0 {
|
||||
return vec![Out::Error(format!(
|
||||
"Wait {} s before your next teleport.",
|
||||
(wait + 999) / 1000
|
||||
))];
|
||||
}
|
||||
}
|
||||
self.pending.retain(|p| p.uuid != me.uuid);
|
||||
self.pending.push(Pending {
|
||||
uuid: me.uuid,
|
||||
to,
|
||||
from: me.at.clone(),
|
||||
health: me.health,
|
||||
due: now + self.config.warmup_seconds.max(0) * 1000,
|
||||
});
|
||||
if self.config.warmup_seconds > 0 {
|
||||
vec![Out::Reply(format!(
|
||||
"Teleporting in {} s: don't move.",
|
||||
self.config.warmup_seconds
|
||||
))]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// The places' commands; None for another command. `bodies` are the
|
||||
/// players on, `me` among them.
|
||||
pub fn command(
|
||||
&mut self,
|
||||
me: &Player,
|
||||
bodies: &[Body],
|
||||
now: i64,
|
||||
line: &str,
|
||||
) -> Option<Vec<Out>> {
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
let command = *words.first()?;
|
||||
if !COMMANDS.contains(&command) {
|
||||
return None;
|
||||
}
|
||||
let Some(body) = bodies.iter().find(|b| b.uuid == me.uuid) else {
|
||||
return Some(vec![Out::Error("You are not in the world.".into())]);
|
||||
};
|
||||
let key = smp_store::uuid_string(me.uuid);
|
||||
let named = |n: &str| bodies.iter().find(|b| b.name.eq_ignore_ascii_case(n));
|
||||
Some(match (command, &words[1..]) {
|
||||
("back", []) => match self.back.get(&me.uuid).cloned() {
|
||||
Some((p, died)) => {
|
||||
let what = if died {
|
||||
"where you died"
|
||||
} else {
|
||||
"where you were"
|
||||
};
|
||||
self.start(body, To::Place(p, what.into()), now)
|
||||
}
|
||||
None => vec![Out::Error(
|
||||
"Nowhere to go back to yet: /back follows a death or a teleport.".into(),
|
||||
)],
|
||||
},
|
||||
("playtime", []) => vec![Out::Reply(format!(
|
||||
"You have played here for {}.",
|
||||
duration(self.played_seconds(me.uuid))
|
||||
))],
|
||||
("playtime", [name]) => match named(name) {
|
||||
Some(b) => vec![Out::Reply(format!(
|
||||
"{} has played here for {}.",
|
||||
b.name,
|
||||
duration(self.played_seconds(b.uuid))
|
||||
))],
|
||||
None => vec![Out::Error(format!("{name} isn't on."))],
|
||||
},
|
||||
("rules", []) => {
|
||||
let mut out = vec![Out::Reply("Rules of this server:".into())];
|
||||
for (i, r) in self.config.rules.iter().enumerate() {
|
||||
out.push(Out::Reply(format!("{}. {r}", i + 1)));
|
||||
}
|
||||
out
|
||||
}
|
||||
("spawn", []) => match self.data.spawn.clone() {
|
||||
Some(p) => self.start(body, To::Place(p, "the spawn".into()), now),
|
||||
None => vec![Out::Error(
|
||||
"No spawn yet: an operator sets it with /setspawn.".into(),
|
||||
)],
|
||||
},
|
||||
("setspawn", []) if me.level >= 3 => {
|
||||
self.data.spawn = Some(body.at.clone());
|
||||
self.changed = true;
|
||||
vec![Out::Reply("The spawn is here now.".into())]
|
||||
}
|
||||
("homes", []) => {
|
||||
let homes = self.data.homes.get(&key);
|
||||
let names: Vec<&str> = homes
|
||||
.into_iter()
|
||||
.flat_map(|h| h.keys())
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
vec![Out::Reply(format!(
|
||||
"Your homes ({} of {}): {}",
|
||||
names.len(),
|
||||
self.home_limit(me.uuid),
|
||||
if names.is_empty() {
|
||||
"none yet, /sethome [name]".into()
|
||||
} else {
|
||||
names.join(", ")
|
||||
}
|
||||
))]
|
||||
}
|
||||
("home", rest) | ("sethome", rest) | ("delhome", rest) if rest.len() <= 1 => {
|
||||
let name = rest
|
||||
.first()
|
||||
.map_or("home".to_owned(), |n| n.to_ascii_lowercase());
|
||||
if !name_ok(&name) {
|
||||
return Some(vec![Out::Error(
|
||||
"A home's name: up to 16 of a-z, 0-9, _ and -.".into(),
|
||||
)]);
|
||||
}
|
||||
let homes = self.data.homes.get(&key);
|
||||
let has = homes.is_some_and(|h| h.contains_key(&name));
|
||||
match command {
|
||||
"home" => match homes.and_then(|h| h.get(&name)).cloned() {
|
||||
Some(p) => self.start(body, To::Place(p, format!("home \"{name}\"")), now),
|
||||
None => vec![Out::Error(format!(
|
||||
"No home \"{name}\": /homes lists yours."
|
||||
))],
|
||||
},
|
||||
"sethome" => {
|
||||
let count = homes.map_or(0, |h| h.len());
|
||||
let limit = self.home_limit(me.uuid);
|
||||
if !has && count >= limit {
|
||||
vec![Out::Error(format!(
|
||||
"You have {count} of {limit} homes: play longer for more, or move one (/sethome <name> over it)."
|
||||
))]
|
||||
} else {
|
||||
self.data
|
||||
.homes
|
||||
.entry(key)
|
||||
.or_default()
|
||||
.insert(name.clone(), body.at.clone());
|
||||
self.changed = true;
|
||||
vec![Out::Reply(format!("Home \"{name}\" set here."))]
|
||||
}
|
||||
}
|
||||
_ if has => {
|
||||
if let Some(h) = self.data.homes.get_mut(&key) {
|
||||
h.remove(&name);
|
||||
if h.is_empty() {
|
||||
self.data.homes.remove(&key);
|
||||
}
|
||||
}
|
||||
self.changed = true;
|
||||
vec![Out::Reply(format!("Home \"{name}\" deleted."))]
|
||||
}
|
||||
_ => vec![Out::Error(format!("No home \"{name}\"."))],
|
||||
}
|
||||
}
|
||||
("warps", []) => {
|
||||
let names: Vec<&str> = self.data.warps.keys().map(String::as_str).collect();
|
||||
vec![Out::Reply(if names.is_empty() {
|
||||
"No warps yet.".into()
|
||||
} else {
|
||||
format!("Warps: {}", names.join(", "))
|
||||
})]
|
||||
}
|
||||
("warp", [name]) => match self.data.warps.get(&name.to_ascii_lowercase()).cloned() {
|
||||
Some(p) => self.start(
|
||||
body,
|
||||
To::Place(p, format!("warp \"{}\"", name.to_ascii_lowercase())),
|
||||
now,
|
||||
),
|
||||
None => vec![Out::Error(format!(
|
||||
"No warp \"{name}\": /warps lists them."
|
||||
))],
|
||||
},
|
||||
("setwarp", [name]) if me.level >= 3 => {
|
||||
let name = name.to_ascii_lowercase();
|
||||
if !name_ok(&name) {
|
||||
return Some(vec![Out::Error(
|
||||
"A warp's name: up to 16 of a-z, 0-9, _ and -.".into(),
|
||||
)]);
|
||||
}
|
||||
self.data.warps.insert(name.clone(), body.at.clone());
|
||||
self.changed = true;
|
||||
vec![Out::Reply(format!("Warp \"{name}\" set here."))]
|
||||
}
|
||||
("delwarp", [name]) if me.level >= 3 => {
|
||||
if self.data.warps.remove(&name.to_ascii_lowercase()).is_some() {
|
||||
self.changed = true;
|
||||
vec![Out::Reply(format!("Warp \"{name}\" deleted."))]
|
||||
} else {
|
||||
vec![Out::Error(format!("No warp \"{name}\"."))]
|
||||
}
|
||||
}
|
||||
("tpa", [name]) => match named(name) {
|
||||
None => vec![Out::Error(format!("{name} is not on."))],
|
||||
Some(t) if t.uuid == me.uuid => vec![Out::Error("That's you.".into())],
|
||||
Some(t) => {
|
||||
self.requests.retain(|r| r.from != me.uuid);
|
||||
self.requests.push(Request {
|
||||
from: me.uuid,
|
||||
from_name: me.name.clone(),
|
||||
to: t.uuid,
|
||||
at: now,
|
||||
});
|
||||
vec![
|
||||
Out::Reply(format!(
|
||||
"Asked {} to teleport to them ({} s to answer).",
|
||||
t.name, self.config.request_seconds
|
||||
)),
|
||||
Out::Tell(
|
||||
t.uuid,
|
||||
format!(
|
||||
"{} asks to teleport to you: /tpaccept {0} or /tpdeny {0}.",
|
||||
me.name
|
||||
),
|
||||
),
|
||||
]
|
||||
}
|
||||
},
|
||||
("tpaccept", rest) | ("tpdeny", rest) if rest.len() <= 1 => {
|
||||
let found = self.requests.iter().rposition(|r| {
|
||||
r.to == me.uuid
|
||||
&& rest
|
||||
.first()
|
||||
.is_none_or(|n| r.from_name.eq_ignore_ascii_case(n))
|
||||
});
|
||||
let Some(i) = found else {
|
||||
return Some(vec![Out::Error("No teleport request to answer.".into())]);
|
||||
};
|
||||
let r = self.requests.remove(i);
|
||||
if command == "tpdeny" {
|
||||
return Some(vec![
|
||||
Out::Reply(format!("You turned down {}'s request.", r.from_name)),
|
||||
Out::Tell(r.from, format!("{} turned down your request.", me.name)),
|
||||
]);
|
||||
}
|
||||
let Some(from) = bodies.iter().find(|b| b.uuid == r.from) else {
|
||||
return Some(vec![Out::Error(format!("{} is gone.", r.from_name))]);
|
||||
};
|
||||
let mut out = vec![Out::Reply(format!("{} is on the way.", r.from_name))];
|
||||
let start = self.start(from, To::Player(me.uuid, me.name.clone()), now);
|
||||
// The warmup's lines are for the one who moves.
|
||||
out.extend(start.into_iter().map(|o| match o {
|
||||
Out::Reply(s) | Out::Error(s) => Out::Tell(r.from, s),
|
||||
o => o,
|
||||
}));
|
||||
out
|
||||
}
|
||||
_ => vec![Out::Error(
|
||||
match command {
|
||||
"home" | "sethome" | "delhome" => {
|
||||
"Usage: /home, /sethome, /delhome [name]; /homes"
|
||||
}
|
||||
"warp" | "setwarp" | "delwarp" => "Usage: /warp <name>; /warps",
|
||||
"tpa" => "Usage: /tpa <player>",
|
||||
"tpaccept" | "tpdeny" => "Usage: /tpaccept [player], /tpdeny [player]",
|
||||
_ => "You may not use this command.",
|
||||
}
|
||||
.into(),
|
||||
)],
|
||||
})
|
||||
}
|
||||
|
||||
/// Each tick: warmups that ended become teleports, those of players who
|
||||
/// moved, were hurt or left are called off; requests nobody answered
|
||||
/// expire.
|
||||
pub fn tick(&mut self, bodies: &[Body], now: i64) -> (Vec<Out>, Vec<Teleport>) {
|
||||
let mut out = Vec::new();
|
||||
let mut go = Vec::new();
|
||||
let wait = self.config.request_seconds * 1000;
|
||||
self.requests.retain(|r| {
|
||||
let keep = now - r.at < wait;
|
||||
if !keep {
|
||||
out.push(Out::Tell(r.from, "Your teleport request expired.".into()));
|
||||
}
|
||||
keep
|
||||
});
|
||||
// A death, seen once a second: /back goes there.
|
||||
for b in bodies {
|
||||
if b.health <= 0.0 {
|
||||
if self.dead.insert(b.uuid) {
|
||||
self.back.insert(b.uuid, (b.at.clone(), true));
|
||||
}
|
||||
} else {
|
||||
self.dead.remove(&b.uuid);
|
||||
}
|
||||
}
|
||||
for p in std::mem::take(&mut self.pending) {
|
||||
let Some(me) = bodies.iter().find(|b| b.uuid == p.uuid) else {
|
||||
continue;
|
||||
};
|
||||
if moved(&p.from, &me.at) {
|
||||
out.push(Out::Tell(p.uuid, "Teleport called off: you moved.".into()));
|
||||
} else if me.health < p.health || me.health <= 0.0 {
|
||||
out.push(Out::Tell(
|
||||
p.uuid,
|
||||
"Teleport called off: you were hurt.".into(),
|
||||
));
|
||||
} else if now < p.due {
|
||||
self.pending.push(p);
|
||||
} else {
|
||||
let (to, what) = match &p.to {
|
||||
To::Place(place, what) => (Some(place.clone()), what.clone()),
|
||||
To::Player(u, name) => (
|
||||
bodies.iter().find(|b| b.uuid == *u).map(|b| b.at.clone()),
|
||||
name.clone(),
|
||||
),
|
||||
};
|
||||
match to {
|
||||
Some(to) => {
|
||||
self.last.insert(p.uuid, now);
|
||||
self.back.insert(p.uuid, (p.from.clone(), false));
|
||||
go.push(Teleport {
|
||||
uuid: p.uuid,
|
||||
name: me.name.clone(),
|
||||
to,
|
||||
done: format!("Teleported to {what}."),
|
||||
});
|
||||
}
|
||||
None => out.push(Out::Tell(
|
||||
p.uuid,
|
||||
format!("Teleport called off: {what} is gone."),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
(out, go)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
const A: u128 = 1;
|
||||
const B: u128 = 2;
|
||||
|
||||
fn dir() -> PathBuf {
|
||||
static N: AtomicU32 = AtomicU32::new(0);
|
||||
let n = N.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!("smp-places-{}-{n}", std::process::id()))
|
||||
}
|
||||
fn place(x: f64) -> Place {
|
||||
Place {
|
||||
dimension: "minecraft:overworld".into(),
|
||||
x,
|
||||
y: 64.0,
|
||||
z: 0.0,
|
||||
}
|
||||
}
|
||||
fn body(uuid: u128, x: f64) -> Body {
|
||||
Body {
|
||||
uuid,
|
||||
name: if uuid == A { "Alex" } else { "Steve" }.into(),
|
||||
at: place(x),
|
||||
health: 20.0,
|
||||
}
|
||||
}
|
||||
fn player(uuid: u128, level: u8) -> Player {
|
||||
Player {
|
||||
uuid,
|
||||
name: if uuid == A { "Alex" } else { "Steve" }.into(),
|
||||
level,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_home_is_reached_after_the_warmup_unless_the_player_moves() {
|
||||
let d = dir();
|
||||
let mut p = Places::open(&d, Config::default()).unwrap();
|
||||
let here = [body(A, 10.0)];
|
||||
let out = p.command(&player(A, 0), &here, 0, "sethome base").unwrap();
|
||||
assert_eq!(out, vec![Out::Reply("Home \"base\" set here.".into())]);
|
||||
let there = [body(A, 500.0)];
|
||||
p.command(&player(A, 0), &there, 0, "home base").unwrap();
|
||||
assert!(p.tick(&there, 2_999).1.is_empty(), "the warmup");
|
||||
let (_, go) = p.tick(&there, 3_000);
|
||||
assert_eq!(go[0].to, place(10.0));
|
||||
assert_eq!(go[0].name, "Alex");
|
||||
// The cooldown, then a move calls the next one off.
|
||||
let out = p
|
||||
.command(&player(A, 0), &there, 5_000, "home base")
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Error(s) if s.contains("Wait")),
|
||||
"{out:?}"
|
||||
);
|
||||
p.command(&player(A, 0), &there, 13_000, "home base")
|
||||
.unwrap();
|
||||
let (out, go) = p.tick(&[body(A, 501.0)], 14_000);
|
||||
assert!(go.is_empty());
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![Out::Tell(A, "Teleport called off: you moved.".into())]
|
||||
);
|
||||
// Saved and read back.
|
||||
p.save(0).unwrap();
|
||||
let again = Places::open(&d, Config::default()).unwrap();
|
||||
assert_eq!(
|
||||
again.data.homes[&smp_store::uuid_string(A)]["base"],
|
||||
place(10.0)
|
||||
);
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn back_goes_where_the_player_died_or_was_before_a_teleport() {
|
||||
let d = dir();
|
||||
let mut p = Places::open(&d, Config::default()).unwrap();
|
||||
let out = p
|
||||
.command(&player(A, 0), &[body(A, 0.0)], 0, "back")
|
||||
.unwrap();
|
||||
assert!(matches!(&out[0], Out::Error(s) if s.contains("Nowhere")));
|
||||
// Dead at x 40: seen by the next tick, once.
|
||||
let mut dead = body(A, 40.0);
|
||||
dead.health = 0.0;
|
||||
p.tick(&[dead.clone()], 1_000);
|
||||
dead.at = place(41.0);
|
||||
p.tick(&[dead], 2_000);
|
||||
let alive = [body(A, 0.0)];
|
||||
p.tick(&alive, 3_000);
|
||||
p.command(&player(A, 0), &alive, 3_000, "back").unwrap();
|
||||
let (_, go) = p.tick(&alive, 6_000);
|
||||
assert_eq!(go[0].to, place(40.0));
|
||||
assert_eq!(go[0].done, "Teleported to where you died.");
|
||||
// Then /back goes to where that teleport started.
|
||||
let there = [body(A, 40.0)];
|
||||
p.command(&player(A, 0), &there, 20_000, "back").unwrap();
|
||||
let (_, go) = p.tick(&there, 23_000);
|
||||
assert_eq!(go[0].to, place(0.0));
|
||||
assert_eq!(go[0].done, "Teleported to where you were.");
|
||||
p.played(&[A], 0);
|
||||
let out = p.command(&player(A, 0), &there, 0, "playtime").unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![Out::Reply("You have played here for 0 min.".into())]
|
||||
);
|
||||
assert_eq!(duration(7_500), "2 h 05 min");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn homes_grow_with_time_played() {
|
||||
let d = dir();
|
||||
let mut p = Places::open(&d, Config::default()).unwrap();
|
||||
let here = [body(A, 0.0)];
|
||||
p.command(&player(A, 0), &here, 0, "sethome").unwrap();
|
||||
let out = p.command(&player(A, 0), &here, 0, "sethome two").unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Error(s) if s.contains("1 of 1")),
|
||||
"{out:?}"
|
||||
);
|
||||
// Moving a home is always allowed.
|
||||
let out = p.command(&player(A, 0), &here, 0, "sethome home").unwrap();
|
||||
assert!(matches!(&out[0], Out::Reply(_)));
|
||||
p.save(0).unwrap();
|
||||
for _ in 0..5 * 3600 {
|
||||
p.played(&[A], 0);
|
||||
}
|
||||
assert_eq!(p.home_limit(A), 2);
|
||||
// Time played is written once a minute.
|
||||
p.save(59_999).unwrap();
|
||||
assert_eq!(
|
||||
Places::open(&d, Config::default()).unwrap().home_limit(A),
|
||||
1
|
||||
);
|
||||
p.save(60_000).unwrap();
|
||||
let out = p.command(&player(A, 0), &here, 0, "sethome two").unwrap();
|
||||
assert!(matches!(&out[0], Out::Reply(_)), "{out:?}");
|
||||
let again = Places::open(&d, Config::default()).unwrap();
|
||||
assert_eq!(again.home_limit(A), 2);
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_moves_the_asker_to_the_one_who_accepts() {
|
||||
let d = dir();
|
||||
let mut p = Places::open(&d, Config::default()).unwrap();
|
||||
let both = [body(A, 0.0), body(B, 100.0)];
|
||||
let out = p.command(&player(A, 0), &both, 0, "tpa steve").unwrap();
|
||||
assert!(
|
||||
matches!(&out[1], Out::Tell(B, s) if s.contains("/tpaccept Alex")),
|
||||
"{out:?}"
|
||||
);
|
||||
let out = p.command(&player(B, 0), &both, 1_000, "tpaccept").unwrap();
|
||||
assert_eq!(
|
||||
out[1],
|
||||
Out::Tell(A, "Teleporting in 3 s: don't move.".into())
|
||||
);
|
||||
// Steve walked on meanwhile: Alex lands where Steve is then.
|
||||
let later = [body(A, 0.0), body(B, 120.0)];
|
||||
let (_, go) = p.tick(&later, 4_000);
|
||||
assert_eq!((go[0].uuid, go[0].to.x), (A, 120.0));
|
||||
// A hurt player stays; an unanswered request expires.
|
||||
p.command(&player(A, 0), &later, 20_000, "tpa steve")
|
||||
.unwrap();
|
||||
p.command(&player(B, 0), &later, 20_000, "tpaccept alex")
|
||||
.unwrap();
|
||||
let mut hurt = later.clone();
|
||||
hurt[0].health = 15.0;
|
||||
let (out, go) = p.tick(&hurt, 21_000);
|
||||
assert!(go.is_empty());
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![Out::Tell(A, "Teleport called off: you were hurt.".into())]
|
||||
);
|
||||
p.command(&player(A, 0), &later, 30_000, "tpa steve")
|
||||
.unwrap();
|
||||
let (out, _) = p.tick(&later, 90_000);
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![Out::Tell(A, "Your teleport request expired.".into())]
|
||||
);
|
||||
let out = p.command(&player(B, 0), &later, 90_000, "tpdeny").unwrap();
|
||||
assert!(matches!(&out[0], Out::Error(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_and_warps_are_set_by_operators() {
|
||||
let d = dir();
|
||||
let mut p = Places::open(&d, Config::default()).unwrap();
|
||||
let here = [body(A, 7.0)];
|
||||
let out = p.command(&player(A, 0), &here, 0, "spawn").unwrap();
|
||||
assert!(matches!(&out[0], Out::Error(s) if s.contains("/setspawn")));
|
||||
let out = p.command(&player(A, 0), &here, 0, "setspawn").unwrap();
|
||||
assert!(matches!(&out[0], Out::Error(_)), "level 0");
|
||||
p.command(&player(A, 3), &here, 0, "setspawn").unwrap();
|
||||
p.command(&player(A, 3), &here, 0, "setwarp Shop").unwrap();
|
||||
let out = p.command(&player(A, 0), &here, 0, "warps").unwrap();
|
||||
assert_eq!(out, vec![Out::Reply("Warps: shop".into())]);
|
||||
p.command(&player(A, 0), &[body(A, 50.0)], 0, "spawn")
|
||||
.unwrap();
|
||||
let (_, go) = p.tick(&[body(A, 50.0)], 3_000);
|
||||
assert_eq!(
|
||||
(go[0].to.x, go[0].done.as_str()),
|
||||
(7.0, "Teleported to the spawn.")
|
||||
);
|
||||
let out = p.command(&player(A, 0), &here, 0, "rules").unwrap();
|
||||
assert_eq!(out.len(), 4);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
// Who is on, and how: a welcome for a player's first join (a line to
|
||||
// everyone, the welcome and the rules to the newcomer), a reward once a day
|
||||
// for being on, AFK players (no move, no turn and no command for
|
||||
// `afk_minutes`, or `/afk`) left out of the sleep count, and a limit on how
|
||||
// fast one player runs the SMP's commands. Looked at once a second, never
|
||||
// per tick. No game types. Added to the game as `game::smp_presence`; also
|
||||
// a module of this crate, for its tests.
|
||||
|
||||
use super::smp_economy::{Out, Player};
|
||||
use super::smp_store;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const COMMANDS: [&str; 1] = ["afk"];
|
||||
|
||||
/// `config.json`'s `presence` section.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
/// The line everyone sees when a player joins for the first time
|
||||
/// (`{player}` filled in); empty: none.
|
||||
pub first_join: String,
|
||||
/// What the newcomer reads, before the rules.
|
||||
pub welcome: Vec<String>,
|
||||
/// Paid once a day (UTC) to each player who is on; 0: none.
|
||||
pub daily_reward: i64,
|
||||
/// Minutes without a move, a turn or a command before a player is AFK;
|
||||
/// 0: only `/afk`.
|
||||
pub afk_minutes: i64,
|
||||
/// Whether everyone hears that a player is AFK (else only them).
|
||||
pub afk_announce: bool,
|
||||
/// At most this many SMP commands in `spam_seconds` a player (operators
|
||||
/// of level 3 aside); 0: no limit.
|
||||
pub spam_commands: usize,
|
||||
pub spam_seconds: i64,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
first_join: "{player} joined for the first time: welcome!".into(),
|
||||
welcome: vec![
|
||||
"Welcome to the server, {player}!".into(),
|
||||
"Set a home with /sethome, claim your land with /claim, see the richest with /baltop.".into(),
|
||||
],
|
||||
daily_reward: 50,
|
||||
afk_minutes: 5,
|
||||
afk_announce: false,
|
||||
spam_commands: 8,
|
||||
spam_seconds: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `presence.json`.
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct Data {
|
||||
/// The players who have joined, by UUID.
|
||||
known: BTreeSet<String>,
|
||||
/// The last day (UTC, `yyyy-mm-dd`) each player got the daily reward.
|
||||
rewarded: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// A player on, as the presence sees them: where they are and look.
|
||||
pub struct Seen {
|
||||
pub uuid: u128,
|
||||
pub name: String,
|
||||
/// x, y, z, yaw, pitch.
|
||||
pub pose: [f64; 5],
|
||||
}
|
||||
|
||||
/// What the game does for the presence.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Act {
|
||||
/// Pays the daily reward (logged as `daily_reward`).
|
||||
Reward(u128),
|
||||
/// Leaves the player out of the sleep count, or counts them again.
|
||||
Sleep(u128, bool),
|
||||
}
|
||||
|
||||
pub struct Presence {
|
||||
pub config: Config,
|
||||
path: PathBuf,
|
||||
data: Data,
|
||||
dirty: bool,
|
||||
/// Each player on: their pose a second ago, and when they last moved,
|
||||
/// turned or ran a command (ms).
|
||||
on: HashMap<u128, ([f64; 5], i64)>,
|
||||
afk: HashSet<u128>,
|
||||
/// Each player's last commands (ms), for the limit.
|
||||
recent: HashMap<u128, VecDeque<i64>>,
|
||||
}
|
||||
|
||||
impl Presence {
|
||||
/// Opens `presence.json`. Without it, the players `known` already (the
|
||||
/// economy's accounts) aren't welcomed as newcomers.
|
||||
pub fn open(
|
||||
dir: &Path,
|
||||
config: Config,
|
||||
known: impl IntoIterator<Item = u128>,
|
||||
) -> Result<Self, String> {
|
||||
let path = dir.join("presence.json");
|
||||
let fresh = !path.exists();
|
||||
let mut data: Data = smp_store::read_json(&path)?;
|
||||
if fresh {
|
||||
data.known = known.into_iter().map(smp_store::uuid_string).collect();
|
||||
}
|
||||
Ok(Self {
|
||||
config,
|
||||
path,
|
||||
data,
|
||||
dirty: fresh,
|
||||
on: HashMap::new(),
|
||||
afk: HashSet::new(),
|
||||
recent: HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save(&mut self) -> Result<(), String> {
|
||||
if self.dirty {
|
||||
smp_store::write_json(&self.path, &self.data)?;
|
||||
self.dirty = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Once a second: who joined, who left, who moved; `today` is the UTC
|
||||
/// date, `rules` `/rules`' lines.
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
players: &[Seen],
|
||||
today: &str,
|
||||
now: i64,
|
||||
rules: &[String],
|
||||
) -> (Vec<Out>, Vec<Act>) {
|
||||
let (mut out, mut acts) = (Vec::new(), Vec::new());
|
||||
let gone: Vec<u128> = self
|
||||
.on
|
||||
.keys()
|
||||
.filter(|u| !players.iter().any(|p| p.uuid == **u))
|
||||
.copied()
|
||||
.collect();
|
||||
for uuid in gone {
|
||||
self.on.remove(&uuid);
|
||||
self.recent.remove(&uuid);
|
||||
if self.afk.remove(&uuid) {
|
||||
acts.push(Act::Sleep(uuid, false));
|
||||
}
|
||||
}
|
||||
for p in players {
|
||||
let key = smp_store::uuid_string(p.uuid);
|
||||
let Some((pose, active)) = self.on.get_mut(&p.uuid) else {
|
||||
// Joined.
|
||||
self.on.insert(p.uuid, (p.pose, now));
|
||||
if self.data.known.insert(key.clone()) {
|
||||
self.dirty = true;
|
||||
let fill = |l: &str| l.replace("{player}", &p.name);
|
||||
if !self.config.first_join.is_empty() {
|
||||
out.push(Out::All(fill(&self.config.first_join)));
|
||||
}
|
||||
out.extend(
|
||||
self.config
|
||||
.welcome
|
||||
.iter()
|
||||
.map(|l| Out::Tell(p.uuid, fill(l))),
|
||||
);
|
||||
if !rules.is_empty() {
|
||||
out.push(Out::Tell(p.uuid, "The rules:".into()));
|
||||
out.extend(
|
||||
rules
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| Out::Tell(p.uuid, format!("{}. {r}", i + 1))),
|
||||
);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let moved = pose.iter().zip(&p.pose).enumerate().any(|(i, (a, b))| {
|
||||
// A hundredth of a block, half a degree.
|
||||
(a - b).abs() > if i < 3 { 0.01 } else { 0.5 }
|
||||
});
|
||||
*pose = p.pose;
|
||||
if moved {
|
||||
*active = now;
|
||||
if self.afk.remove(&p.uuid) {
|
||||
out.extend(self.back(p.uuid, &p.name, &mut acts));
|
||||
}
|
||||
} else if self.config.afk_minutes > 0
|
||||
&& now - *active >= self.config.afk_minutes * 60_000
|
||||
&& self.afk.insert(p.uuid)
|
||||
{
|
||||
out.push(self.away(p.uuid, &p.name, &mut acts));
|
||||
}
|
||||
}
|
||||
// Each player's first second on today, or the day turning while on.
|
||||
if self.config.daily_reward > 0 {
|
||||
for p in players {
|
||||
let key = smp_store::uuid_string(p.uuid);
|
||||
if self.data.rewarded.get(&key).map(String::as_str) != Some(today) {
|
||||
self.data.rewarded.insert(key, today.to_owned());
|
||||
self.dirty = true;
|
||||
acts.push(Act::Reward(p.uuid));
|
||||
}
|
||||
}
|
||||
}
|
||||
(out, acts)
|
||||
}
|
||||
|
||||
fn away(&mut self, uuid: u128, name: &str, acts: &mut Vec<Act>) -> Out {
|
||||
acts.push(Act::Sleep(uuid, true));
|
||||
if self.config.afk_announce {
|
||||
Out::All(format!("{name} is AFK."))
|
||||
} else {
|
||||
Out::Tell(
|
||||
uuid,
|
||||
"You are AFK: you don't count for sleeping until you move.".into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn back(&mut self, uuid: u128, name: &str, acts: &mut Vec<Act>) -> Vec<Out> {
|
||||
acts.push(Act::Sleep(uuid, false));
|
||||
vec![if self.config.afk_announce {
|
||||
Out::All(format!("{name} is back."))
|
||||
} else {
|
||||
Out::Tell(uuid, "You are back.".into())
|
||||
}]
|
||||
}
|
||||
|
||||
/// Before each of a player's SMP commands: counts it as activity, and
|
||||
/// refuses it past the limit.
|
||||
pub fn allow(&mut self, me: &Player, now: i64) -> Result<(Vec<Out>, Vec<Act>), Out> {
|
||||
let (limit, window) = (self.config.spam_commands, self.config.spam_seconds * 1000);
|
||||
if limit > 0 && me.level < 3 {
|
||||
let recent = self.recent.entry(me.uuid).or_default();
|
||||
while recent.front().is_some_and(|t| now - t >= window) {
|
||||
recent.pop_front();
|
||||
}
|
||||
if recent.len() >= limit {
|
||||
return Err(Out::Error(format!(
|
||||
"Slow down: at most {limit} commands every {} s.",
|
||||
self.config.spam_seconds
|
||||
)));
|
||||
}
|
||||
recent.push_back(now);
|
||||
}
|
||||
if let Some((_, active)) = self.on.get_mut(&me.uuid) {
|
||||
*active = now;
|
||||
}
|
||||
let mut acts = Vec::new();
|
||||
let out = if self.afk.remove(&me.uuid) {
|
||||
self.back(me.uuid, &me.name, &mut acts)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok((out, acts))
|
||||
}
|
||||
|
||||
/// `/afk`: AFK until the player moves, turns or runs a command.
|
||||
pub fn command(&mut self, me: &Player, line: &str) -> Option<(Vec<Out>, Vec<Act>)> {
|
||||
if line.trim() != "afk" {
|
||||
return None;
|
||||
}
|
||||
let mut acts = Vec::new();
|
||||
if !self.afk.insert(me.uuid) {
|
||||
return Some((vec![Out::Reply("You are already AFK.".into())], acts));
|
||||
}
|
||||
let out = self.away(me.uuid, &me.name, &mut acts);
|
||||
Some((vec![out], acts))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const A: u128 = 1;
|
||||
const B: u128 = 2;
|
||||
|
||||
fn seen(uuid: u128, x: f64) -> Seen {
|
||||
Seen {
|
||||
uuid,
|
||||
name: if uuid == A { "Alex" } else { "Steve" }.into(),
|
||||
pose: [x, 64.0, 0.0, 0.0, 0.0],
|
||||
}
|
||||
}
|
||||
|
||||
fn me(uuid: u128) -> Player {
|
||||
Player {
|
||||
uuid,
|
||||
name: "Alex".into(),
|
||||
level: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn open(known: Vec<u128>) -> (Presence, PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"smp-presence-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
(Presence::open(&dir, Config::default(), known).unwrap(), dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_first_join_is_welcomed_once_and_known_players_are_not() {
|
||||
let (mut p, dir) = open(vec![B]);
|
||||
let rules = vec!["Be kind.".to_owned()];
|
||||
let (out, _) = p.tick(&[seen(A, 0.0), seen(B, 0.0)], "2026-10-31", 0, &rules);
|
||||
let all: Vec<_> = out.iter().filter(|o| matches!(o, Out::All(_))).collect();
|
||||
assert_eq!(all.len(), 1, "{out:?}");
|
||||
assert!(matches!(all[0], Out::All(l) if l.starts_with("Alex joined for the first time")));
|
||||
assert!(
|
||||
out.iter()
|
||||
.any(|o| matches!(o, Out::Tell(A, l) if l == "1. Be kind."))
|
||||
);
|
||||
assert!(!out.iter().any(|o| matches!(o, Out::Tell(B, _))));
|
||||
p.save().unwrap();
|
||||
// Back after leaving, or after a restart: no welcome.
|
||||
p.tick(&[], "2026-10-31", 1_000, &rules);
|
||||
let (out, _) = p.tick(&[seen(A, 0.0)], "2026-10-31", 2_000, &rules);
|
||||
assert!(out.is_empty(), "{out:?}");
|
||||
let mut again = Presence::open(&dir, Config::default(), []).unwrap();
|
||||
let (out, _) = again.tick(&[seen(A, 0.0)], "2026-10-31", 0, &rules);
|
||||
assert!(out.is_empty(), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_daily_reward_comes_once_a_day() {
|
||||
let (mut p, _) = open(vec![]);
|
||||
let (_, acts) = p.tick(&[seen(A, 0.0)], "2026-10-31", 0, &[]);
|
||||
assert_eq!(acts, vec![Act::Reward(A)]);
|
||||
p.tick(&[], "2026-10-31", 1_000, &[]);
|
||||
let (_, acts) = p.tick(&[seen(A, 0.0)], "2026-10-31", 2_000, &[]);
|
||||
assert!(acts.is_empty(), "{acts:?}");
|
||||
// Midnight with the player on.
|
||||
let (_, acts) = p.tick(&[seen(A, 0.0)], "2026-11-01", 3_000, &[]);
|
||||
assert_eq!(acts, vec![Act::Reward(A)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_still_player_goes_afk_and_comes_back_by_moving_or_a_command() {
|
||||
let (mut p, _) = open(vec![A]);
|
||||
p.config.daily_reward = 0;
|
||||
p.tick(&[seen(A, 0.0)], "d", 0, &[]);
|
||||
let (_, acts) = p.tick(&[seen(A, 0.0)], "d", 299_000, &[]);
|
||||
assert!(acts.is_empty());
|
||||
let (out, acts) = p.tick(&[seen(A, 0.0)], "d", 300_000, &[]);
|
||||
assert_eq!(acts, vec![Act::Sleep(A, true)]);
|
||||
assert!(matches!(&out[..], [Out::Tell(A, _)]));
|
||||
let (_, acts) = p.tick(&[seen(A, 0.0)], "d", 301_000, &[]);
|
||||
assert!(acts.is_empty(), "once");
|
||||
let (_, acts) = p.tick(&[seen(A, 1.0)], "d", 302_000, &[]);
|
||||
assert_eq!(acts, vec![Act::Sleep(A, false)]);
|
||||
// /afk, then a command.
|
||||
let (_, acts) = p.command(&me(A), "afk").unwrap();
|
||||
assert_eq!(acts, vec![Act::Sleep(A, true)]);
|
||||
let (_, acts) = p.allow(&me(A), 303_000).unwrap();
|
||||
assert_eq!(acts, vec![Act::Sleep(A, false)]);
|
||||
// Leaving while AFK counts them again.
|
||||
p.command(&me(A), "afk");
|
||||
let (_, acts) = p.tick(&[], "d", 304_000, &[]);
|
||||
assert_eq!(acts, vec![Act::Sleep(A, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_past_the_limit_wait() {
|
||||
let (mut p, _) = open(vec![A]);
|
||||
for i in 0..8 {
|
||||
assert!(p.allow(&me(A), i * 100).is_ok());
|
||||
}
|
||||
assert!(p.allow(&me(A), 900).is_err());
|
||||
assert!(p.allow(&me(A), 5_000).is_ok(), "the first left the window");
|
||||
let op = Player { level: 3, ..me(A) };
|
||||
assert!(p.allow(&op, 5_001).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
// Whether the market's prices let anyone get rich by crafting: buying items,
|
||||
// crafting or smelting them into others, and selling those for more than
|
||||
// they cost. The cheapest way to get each item from the market is found over
|
||||
// the recipes (Bellman-Ford: a recipe lowers its result's cost to what its
|
||||
// inputs cost, until nothing changes); an item the market buys for more than
|
||||
// that cost is a loop. Fuel is left out, so a loop is found even where the
|
||||
// fuel would eat the profit. The vanilla recipes that matter for the default
|
||||
// prices are written here as facts (`CHAINS`); the tests also walk every
|
||||
// recipe of the game's own data when it is there. No game types. Added to
|
||||
// the game as `game::smp_recipes`; also a module of this crate, for its
|
||||
// tests.
|
||||
|
||||
use super::smp_market::Price;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A recipe: `count` of `result` from the ingredients, each a choice of
|
||||
/// items (any one of them) and how many of it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Recipe {
|
||||
pub result: String,
|
||||
pub count: u32,
|
||||
pub inputs: Vec<(Vec<String>, u32)>,
|
||||
}
|
||||
|
||||
/// An item the market buys for more than it costs to make from what the
|
||||
/// market sells.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Loop {
|
||||
pub item: String,
|
||||
/// What the market pays for one.
|
||||
pub sell: i64,
|
||||
/// The least one costs, bought or crafted.
|
||||
pub cost: f64,
|
||||
}
|
||||
|
||||
/// A written recipe: result, count, [(ingredient, how many)].
|
||||
type Chain = (&'static str, u32, &'static [(&'static str, u32)]);
|
||||
|
||||
/// The compression and food chains around the default prices.
|
||||
const CHAINS: &[Chain] = &[
|
||||
("iron_block", 1, &[("iron_ingot", 9)]),
|
||||
("iron_ingot", 9, &[("iron_block", 1)]),
|
||||
("iron_nugget", 9, &[("iron_ingot", 1)]),
|
||||
("iron_ingot", 1, &[("iron_nugget", 9)]),
|
||||
("iron_ingot", 1, &[("raw_iron", 1)]),
|
||||
("raw_iron_block", 1, &[("raw_iron", 9)]),
|
||||
("raw_iron", 9, &[("raw_iron_block", 1)]),
|
||||
("gold_block", 1, &[("gold_ingot", 9)]),
|
||||
("gold_ingot", 9, &[("gold_block", 1)]),
|
||||
("gold_nugget", 9, &[("gold_ingot", 1)]),
|
||||
("gold_ingot", 1, &[("gold_nugget", 9)]),
|
||||
("gold_ingot", 1, &[("raw_gold", 1)]),
|
||||
("copper_block", 1, &[("copper_ingot", 9)]),
|
||||
("copper_ingot", 9, &[("copper_block", 1)]),
|
||||
("copper_ingot", 1, &[("raw_copper", 1)]),
|
||||
("diamond_block", 1, &[("diamond", 9)]),
|
||||
("diamond", 9, &[("diamond_block", 1)]),
|
||||
("emerald_block", 1, &[("emerald", 9)]),
|
||||
("emerald", 9, &[("emerald_block", 1)]),
|
||||
("coal_block", 1, &[("coal", 9)]),
|
||||
("coal", 9, &[("coal_block", 1)]),
|
||||
("redstone_block", 1, &[("redstone", 9)]),
|
||||
("redstone", 9, &[("redstone_block", 1)]),
|
||||
("lapis_block", 1, &[("lapis_lazuli", 9)]),
|
||||
("lapis_lazuli", 9, &[("lapis_block", 1)]),
|
||||
("quartz_block", 1, &[("quartz", 4)]),
|
||||
("hay_block", 1, &[("wheat", 9)]),
|
||||
("wheat", 9, &[("hay_block", 1)]),
|
||||
("bread", 1, &[("wheat", 3)]),
|
||||
("melon", 1, &[("melon_slice", 9)]),
|
||||
("melon_seeds", 1, &[("melon_slice", 1)]),
|
||||
("pumpkin_seeds", 4, &[("pumpkin", 1)]),
|
||||
("sugar", 1, &[("sugar_cane", 1)]),
|
||||
("paper", 3, &[("sugar_cane", 3)]),
|
||||
("bone_meal", 3, &[("bone", 1)]),
|
||||
("bone_block", 1, &[("bone_meal", 9)]),
|
||||
("bone_meal", 9, &[("bone_block", 1)]),
|
||||
("slime_block", 1, &[("slime_ball", 9)]),
|
||||
("slime_ball", 9, &[("slime_block", 1)]),
|
||||
("white_wool", 1, &[("string", 4)]),
|
||||
("dried_kelp_block", 1, &[("dried_kelp", 9)]),
|
||||
("oak_planks", 4, &[("oak_log", 1)]),
|
||||
("spruce_planks", 4, &[("spruce_log", 1)]),
|
||||
("birch_planks", 4, &[("birch_log", 1)]),
|
||||
("stick", 4, &[("oak_planks", 2)]),
|
||||
("torch", 4, &[("coal", 1), ("stick", 1)]),
|
||||
("charcoal", 1, &[("oak_log", 1)]),
|
||||
("stone", 1, &[("cobblestone", 1)]),
|
||||
("stone_bricks", 4, &[("stone", 4)]),
|
||||
("glass", 1, &[("sand", 1)]),
|
||||
("cooked_beef", 1, &[("beef", 1)]),
|
||||
("cooked_porkchop", 1, &[("porkchop", 1)]),
|
||||
("cooked_chicken", 1, &[("chicken", 1)]),
|
||||
("baked_potato", 1, &[("potato", 1)]),
|
||||
("golden_carrot", 1, &[("carrot", 1), ("gold_nugget", 8)]),
|
||||
("blaze_powder", 2, &[("blaze_rod", 1)]),
|
||||
("leather", 1, &[("rabbit_hide", 4)]),
|
||||
("tnt", 1, &[("gunpowder", 5), ("sand", 4)]),
|
||||
];
|
||||
|
||||
/// `CHAINS` as recipes.
|
||||
pub fn chains() -> Vec<Recipe> {
|
||||
CHAINS
|
||||
.iter()
|
||||
.map(|(result, count, inputs)| Recipe {
|
||||
result: format!("minecraft:{result}"),
|
||||
count: *count,
|
||||
inputs: inputs
|
||||
.iter()
|
||||
.map(|(i, n)| (vec![format!("minecraft:{i}")], *n))
|
||||
.collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The least each item costs from the market: bought, or crafted from what
|
||||
/// the market sells (absent: no way at all).
|
||||
pub fn costs(prices: &BTreeMap<String, Price>, recipes: &[Recipe]) -> BTreeMap<String, f64> {
|
||||
let mut cost: BTreeMap<String, f64> = prices
|
||||
.iter()
|
||||
.filter(|(_, p)| p.buy > 0)
|
||||
.map(|(id, p)| (id.clone(), p.buy as f64))
|
||||
.collect();
|
||||
// Each round can only lower a cost; as many rounds as recipes is
|
||||
// Bellman-Ford's bound, and the loop stops as soon as nothing changes.
|
||||
for _ in 0..=recipes.len() {
|
||||
let mut changed = false;
|
||||
for r in recipes {
|
||||
if r.count == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut total = 0.0;
|
||||
let mut makeable = true;
|
||||
for (choices, n) in &r.inputs {
|
||||
let best = choices
|
||||
.iter()
|
||||
.filter_map(|c| cost.get(c))
|
||||
.fold(f64::INFINITY, |a, &b| a.min(b));
|
||||
if !best.is_finite() {
|
||||
makeable = false;
|
||||
break;
|
||||
}
|
||||
total += best * f64::from(*n);
|
||||
}
|
||||
if !makeable {
|
||||
continue;
|
||||
}
|
||||
let each = total / f64::from(r.count);
|
||||
let old = cost.get(&r.result).copied().unwrap_or(f64::INFINITY);
|
||||
if each < old - 1e-9 {
|
||||
cost.insert(r.result.clone(), each);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
cost
|
||||
}
|
||||
|
||||
/// The items the market buys for more than they cost to get from it.
|
||||
pub fn loops(prices: &BTreeMap<String, Price>, recipes: &[Recipe]) -> Vec<Loop> {
|
||||
let cost = costs(prices, recipes);
|
||||
prices
|
||||
.iter()
|
||||
.filter(|(_, p)| p.sell > 0)
|
||||
.filter_map(|(id, p)| {
|
||||
let c = *cost.get(id)?;
|
||||
(p.sell as f64 > c + 1e-9).then(|| Loop {
|
||||
item: id.clone(),
|
||||
sell: p.sell,
|
||||
cost: c,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// An ingredient of a recipe file: an item, a `#tag`, or a list of either,
|
||||
/// as the items it allows (`tags` maps a tag to its items, tags in tags
|
||||
/// resolved).
|
||||
fn choices(v: &serde_json::Value, tags: &BTreeMap<String, Vec<String>>) -> Vec<String> {
|
||||
match v {
|
||||
serde_json::Value::String(s) => match s.strip_prefix('#') {
|
||||
Some(tag) => tags.get(tag).cloned().unwrap_or_default(),
|
||||
None => vec![s.clone()],
|
||||
},
|
||||
serde_json::Value::Array(a) => a.iter().flat_map(|x| choices(x, tags)).collect(),
|
||||
serde_json::Value::Object(o) => o
|
||||
.get("item")
|
||||
.or_else(|| o.get("tag"))
|
||||
.map(|x| choices(x, tags))
|
||||
.unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// A vanilla recipe file (`data/minecraft/recipe/*.json`) as a recipe, for
|
||||
/// the kinds that turn items into items: shaped and shapeless crafting,
|
||||
/// transmuting, the furnaces and the campfire, the stonecutter and the
|
||||
/// smithing table. None for the others (dyeing, fireworks...).
|
||||
pub fn parse(json: &serde_json::Value, tags: &BTreeMap<String, Vec<String>>) -> Option<Recipe> {
|
||||
let kind = json.get("type")?.as_str()?;
|
||||
let result = json.get("result")?;
|
||||
let (result, count) = match result {
|
||||
serde_json::Value::String(s) => (s.clone(), 1),
|
||||
r => (
|
||||
r.get("id")?.as_str()?.to_owned(),
|
||||
r.get("count").and_then(|c| c.as_u64()).unwrap_or(1) as u32,
|
||||
),
|
||||
};
|
||||
let one = |key: &str| json.get(key).map(|v| (choices(v, tags), 1));
|
||||
let inputs: Vec<(Vec<String>, u32)> = match kind.strip_prefix("minecraft:")? {
|
||||
"crafting_shaped" => {
|
||||
let key = json.get("key")?.as_object()?;
|
||||
let mut counts: BTreeMap<char, u32> = BTreeMap::new();
|
||||
for row in json.get("pattern")?.as_array()? {
|
||||
for c in row.as_str()?.chars().filter(|c| *c != ' ') {
|
||||
*counts.entry(c).or_default() += 1;
|
||||
}
|
||||
}
|
||||
counts
|
||||
.into_iter()
|
||||
.map(|(c, n)| Some((choices(key.get(&c.to_string())?, tags), n)))
|
||||
.collect::<Option<_>>()?
|
||||
}
|
||||
"crafting_shapeless" => json
|
||||
.get("ingredients")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(|i| (choices(i, tags), 1))
|
||||
.collect(),
|
||||
"crafting_transmute" => vec![one("input")?, one("material")?],
|
||||
"smelting" | "blasting" | "smoking" | "campfire_cooking" | "stonecutting" => {
|
||||
vec![one("ingredient")?]
|
||||
}
|
||||
"smithing_transform" => vec![one("template")?, one("base")?, one("addition")?],
|
||||
_ => return None,
|
||||
};
|
||||
// An ingredient nothing can fill makes the recipe useless here.
|
||||
if inputs.iter().any(|(c, _)| c.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
Some(Recipe {
|
||||
result,
|
||||
count,
|
||||
inputs,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Item tags (`data/minecraft/tags/item/*.json`, by name: `logs`) resolved
|
||||
/// into items, as `minecraft:logs` → items.
|
||||
pub fn resolve_tags(raw: &BTreeMap<String, Vec<String>>) -> BTreeMap<String, Vec<String>> {
|
||||
fn expand(name: &str, raw: &BTreeMap<String, Vec<String>>, depth: u32, out: &mut Vec<String>) {
|
||||
let Some(values) = raw.get(name) else {
|
||||
return;
|
||||
};
|
||||
for v in values {
|
||||
match v.strip_prefix('#') {
|
||||
Some(t) if depth < 16 => expand(t, raw, depth + 1, out),
|
||||
Some(_) => {}
|
||||
None => out.push(v.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
raw.keys()
|
||||
.map(|name| {
|
||||
let mut out = Vec::new();
|
||||
expand(name, raw, 0, &mut out);
|
||||
(name.clone(), out)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::smp_market::Config;
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn prices(list: &[(&str, i64, i64)]) -> BTreeMap<String, Price> {
|
||||
list.iter()
|
||||
.map(|(id, buy, sell)| {
|
||||
(
|
||||
format!("minecraft:{id}"),
|
||||
Price {
|
||||
buy: *buy,
|
||||
sell: *sell,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_sold_dearer_than_its_ingots_is_a_loop() {
|
||||
// 9 ingots at 30 make a block the market would buy for 300.
|
||||
let p = prices(&[("iron_ingot", 30, 8), ("iron_block", 0, 300)]);
|
||||
let found = loops(&p, &chains());
|
||||
assert_eq!(found.len(), 1, "{found:?}");
|
||||
assert_eq!(found[0].item, "minecraft:iron_block");
|
||||
assert!((found[0].cost - 270.0).abs() < 1e-9);
|
||||
// The other way: a cheap block uncrafted into ingots the market buys.
|
||||
let p = prices(&[("iron_ingot", 30, 8), ("iron_block", 60, 0)]);
|
||||
let found = loops(&p, &chains());
|
||||
assert_eq!(found[0].item, "minecraft:iron_ingot", "{found:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bread_from_cheap_wheat_and_planks_from_cheap_logs_are_loops() {
|
||||
let p = prices(&[("wheat", 1, 0), ("bread", 5, 4)]);
|
||||
assert_eq!(loops(&p, &chains())[0].item, "minecraft:bread");
|
||||
let p = prices(&[("oak_log", 4, 1), ("oak_planks", 0, 2)]);
|
||||
assert_eq!(loops(&p, &chains())[0].item, "minecraft:oak_planks");
|
||||
// Through two recipes: logs → planks → sticks.
|
||||
let p = prices(&[("oak_log", 4, 1), ("stick", 0, 1)]);
|
||||
assert_eq!(loops(&p, &chains())[0].item, "minecraft:stick");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_prices_have_no_loop() {
|
||||
let config = Config::default();
|
||||
assert_eq!(loops(&config.items, &chains()), []);
|
||||
for (id, p) in &config.items {
|
||||
assert!(
|
||||
p.buy == 0 || p.sell < p.buy,
|
||||
"{id} sells for {} and buys for {}",
|
||||
p.sell,
|
||||
p.buy
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipe_files_read_as_recipes() {
|
||||
let tags = resolve_tags(
|
||||
&[
|
||||
(
|
||||
"minecraft:planks",
|
||||
vec!["minecraft:oak_planks", "#minecraft:x"],
|
||||
),
|
||||
("minecraft:x", vec![]),
|
||||
(
|
||||
"minecraft:oak_logs",
|
||||
vec!["minecraft:oak_log", "minecraft:oak_wood"],
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_owned(), v.into_iter().map(str::to_owned).collect()))
|
||||
.collect(),
|
||||
);
|
||||
let shaped: serde_json::Value = serde_json::from_str(
|
||||
r##"{"type":"minecraft:crafting_shaped","key":{"#":"minecraft:iron_ingot","S":"#minecraft:planks"},
|
||||
"pattern":["# #","#S#"," # "],"result":{"id":"minecraft:bucket"}}"##,
|
||||
)
|
||||
.unwrap();
|
||||
let r = parse(&shaped, &tags).unwrap();
|
||||
assert_eq!(r.result, "minecraft:bucket");
|
||||
assert_eq!(
|
||||
r.inputs,
|
||||
[
|
||||
(vec!["minecraft:iron_ingot".to_owned()], 5),
|
||||
(vec!["minecraft:oak_planks".to_owned()], 1)
|
||||
]
|
||||
);
|
||||
let shapeless: serde_json::Value = serde_json::from_str(
|
||||
r##"{"type":"minecraft:crafting_shapeless","ingredients":["#minecraft:oak_logs"],"result":{"count":4,"id":"minecraft:oak_planks"}}"##,
|
||||
)
|
||||
.unwrap();
|
||||
let r = parse(&shapeless, &tags).unwrap();
|
||||
assert_eq!((r.count, r.inputs[0].0.len()), (4, 2));
|
||||
let smelt: serde_json::Value = serde_json::from_str(
|
||||
r#"{"type":"minecraft:smelting","ingredient":["minecraft:a","minecraft:b"],"result":{"id":"minecraft:c"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(parse(&smelt, &tags).unwrap().inputs[0].0.len(), 2);
|
||||
let special: serde_json::Value =
|
||||
serde_json::from_str(r#"{"type":"minecraft:crafting_special_repairitem"}"#).unwrap();
|
||||
assert_eq!(parse(&special, &tags), None);
|
||||
}
|
||||
|
||||
/// The game's own recipe and tag files: `SMP_VANILLA_DATA` (a
|
||||
/// `data/minecraft` folder), or a Horizon checkout's prepared resources
|
||||
/// beside this repository. None when neither is there (CI).
|
||||
fn vanilla_data() -> Option<PathBuf> {
|
||||
if let Some(p) = std::env::var_os("SMP_VANILLA_DATA") {
|
||||
return Some(PathBuf::from(p));
|
||||
}
|
||||
let home = PathBuf::from(std::env::var_os("HOME")?);
|
||||
let p = home.join("code/horizon/.local/horizon-client/26.3/data/minecraft");
|
||||
p.join("recipe").is_dir().then_some(p)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_prices_have_no_loop_through_any_vanilla_recipe() {
|
||||
let Some(data) = vanilla_data() else {
|
||||
eprintln!("no vanilla data (SMP_VANILLA_DATA): only the written chains are checked");
|
||||
return;
|
||||
};
|
||||
let read = |p: &std::path::Path| -> serde_json::Value {
|
||||
serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
|
||||
};
|
||||
// Tags in folders too: `tags/item/enchantable/sword.json` is
|
||||
// `minecraft:enchantable/sword`.
|
||||
fn walk(dir: &std::path::Path, out: &mut Vec<PathBuf>) {
|
||||
for entry in std::fs::read_dir(dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.is_dir() {
|
||||
walk(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "json") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let root = data.join("tags/item");
|
||||
let mut files = Vec::new();
|
||||
walk(&root, &mut files);
|
||||
let mut raw = BTreeMap::new();
|
||||
for path in files {
|
||||
let name = path.strip_prefix(&root).unwrap().with_extension("");
|
||||
let values = read(&path)["values"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|v| {
|
||||
v.as_str()
|
||||
.or_else(|| v.get("id")?.as_str())
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.collect();
|
||||
raw.insert(format!("minecraft:{}", name.to_string_lossy()), values);
|
||||
}
|
||||
let tags = resolve_tags(&raw);
|
||||
let mut recipes = Vec::new();
|
||||
for entry in std::fs::read_dir(data.join("recipe")).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.extension().is_some_and(|e| e == "json")
|
||||
&& let Some(r) = parse(&read(&path), &tags)
|
||||
{
|
||||
recipes.push(r);
|
||||
}
|
||||
}
|
||||
assert!(recipes.len() > 1000, "{} recipes", recipes.len());
|
||||
let config = Config::default();
|
||||
assert_eq!(loops(&config.items, &recipes), []);
|
||||
// The checker does find a loop through the real recipes.
|
||||
let mut cheap = config.items.clone();
|
||||
cheap.insert("minecraft:iron_block".into(), Price { buy: 0, sell: 300 });
|
||||
assert_eq!(loops(&cheap, &recipes)[0].item, "minecraft:iron_block");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
// `/shop`: the market (smp_market) as a chest menu. The first page shows the
|
||||
// categories; a category's page its items, with what the market asks and
|
||||
// pays (in its name). A click buys one, a shift-click a stack of 64; a click on a stack in
|
||||
// your own inventory sells it; "Sell all" sells everything the market buys.
|
||||
// Buying and selling go through the market's own rules (its prices, today's
|
||||
// limits, items first then money, logged). This file only lays the pages out
|
||||
// and says what a click means; the glue opens the menu and moves the items.
|
||||
// No game types. Added to the game as `game::smp_shop`; also a module of this
|
||||
// crate, for its tests.
|
||||
|
||||
use super::smp_market::{Config as MarketConfig, Price, item_name};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Rows of the menu, and its slots.
|
||||
pub const ROWS: u8 = 6;
|
||||
pub const SIZE: usize = 54;
|
||||
/// A page's item slots (the rows above the bottom one).
|
||||
pub const PER_PAGE: usize = 45;
|
||||
/// What a shift-click buys.
|
||||
pub const STACK: u32 = 64;
|
||||
|
||||
/// The bottom row's buttons.
|
||||
const BACK: usize = 45;
|
||||
const HELP: usize = 46;
|
||||
const PREV: usize = 47;
|
||||
const BALANCE: usize = 48;
|
||||
const SELL_ALL: usize = 49;
|
||||
const LIMITS: usize = 50;
|
||||
const NEXT: usize = 51;
|
||||
const CLOSE: usize = 53;
|
||||
/// Where the first page puts the categories: the middle of rows 2 to 4.
|
||||
const CATEGORY_SLOTS: [usize; 21] = [
|
||||
10, 11, 12, 13, 14, 15, 16, 19, 20, 21, 22, 23, 24, 25, 28, 29, 30, 31, 32, 33, 34,
|
||||
];
|
||||
|
||||
/// A category: its name, the item its button shows, its items.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Category {
|
||||
pub name: String,
|
||||
pub icon: String,
|
||||
pub items: Vec<String>,
|
||||
}
|
||||
|
||||
/// `config.json`'s `shop`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub enabled: bool,
|
||||
pub title: String,
|
||||
/// The market's items not in any category go to an "Other" one.
|
||||
pub categories: Vec<Category>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
let cat = |name: &str, icon: &str, items: &[&str]| Category {
|
||||
name: name.into(),
|
||||
icon: format!("minecraft:{icon}"),
|
||||
items: items.iter().map(|i| format!("minecraft:{i}")).collect(),
|
||||
};
|
||||
Self {
|
||||
enabled: true,
|
||||
title: "Shop".into(),
|
||||
categories: vec![
|
||||
cat(
|
||||
"Farming",
|
||||
"wheat",
|
||||
&[
|
||||
"wheat",
|
||||
"wheat_seeds",
|
||||
"carrot",
|
||||
"potato",
|
||||
"beetroot",
|
||||
"sugar_cane",
|
||||
"pumpkin",
|
||||
"melon_slice",
|
||||
"cactus",
|
||||
"bone_meal",
|
||||
],
|
||||
),
|
||||
cat(
|
||||
"Mining",
|
||||
"diamond",
|
||||
&[
|
||||
"coal",
|
||||
"copper_ingot",
|
||||
"iron_ingot",
|
||||
"gold_ingot",
|
||||
"redstone",
|
||||
"lapis_lazuli",
|
||||
"quartz",
|
||||
"emerald",
|
||||
"diamond",
|
||||
],
|
||||
),
|
||||
cat(
|
||||
"Wood",
|
||||
"oak_log",
|
||||
&[
|
||||
"oak_log",
|
||||
"spruce_log",
|
||||
"birch_log",
|
||||
"jungle_log",
|
||||
"acacia_log",
|
||||
"dark_oak_log",
|
||||
"mangrove_log",
|
||||
"cherry_log",
|
||||
],
|
||||
),
|
||||
cat(
|
||||
"Mob drops",
|
||||
"bone",
|
||||
&[
|
||||
"rotten_flesh",
|
||||
"bone",
|
||||
"string",
|
||||
"gunpowder",
|
||||
"spider_eye",
|
||||
"slime_ball",
|
||||
"blaze_rod",
|
||||
"leather",
|
||||
"feather",
|
||||
],
|
||||
),
|
||||
cat(
|
||||
"Building",
|
||||
"bricks",
|
||||
&[
|
||||
"cobblestone",
|
||||
"stone",
|
||||
"dirt",
|
||||
"sand",
|
||||
"gravel",
|
||||
"glass",
|
||||
"bricks",
|
||||
"stone_bricks",
|
||||
],
|
||||
),
|
||||
cat(
|
||||
"Food",
|
||||
"bread",
|
||||
&[
|
||||
"bread",
|
||||
"cooked_beef",
|
||||
"cooked_porkchop",
|
||||
"cooked_chicken",
|
||||
"baked_potato",
|
||||
"apple",
|
||||
"golden_carrot",
|
||||
],
|
||||
),
|
||||
cat(
|
||||
"Tools",
|
||||
"torch",
|
||||
&["torch", "ladder", "crafting_table", "chest", "furnace"],
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which page a player sees.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Page {
|
||||
Main,
|
||||
/// A category (by index in `categories`) and its page from 0.
|
||||
Category(usize, usize),
|
||||
}
|
||||
|
||||
/// One slot of the menu.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Cell {
|
||||
Empty,
|
||||
/// The bottom row's background.
|
||||
Filler,
|
||||
/// A category's button: its icon, name, and how many items it has.
|
||||
Category(String, String, usize),
|
||||
/// An item and its price.
|
||||
Item(String, Price),
|
||||
Back,
|
||||
Prev,
|
||||
Next,
|
||||
Balance,
|
||||
SellAll,
|
||||
Limits,
|
||||
Help,
|
||||
Close,
|
||||
}
|
||||
|
||||
/// What a click asks for.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Click {
|
||||
/// Nothing (an empty slot, a filler).
|
||||
Nothing,
|
||||
/// Show this page.
|
||||
Show(Page),
|
||||
/// Buy this many of the item.
|
||||
Buy(String, u32),
|
||||
/// Sell everything the market buys.
|
||||
SellAll,
|
||||
Close,
|
||||
}
|
||||
|
||||
/// The categories as shown: each with only the items the market trades,
|
||||
/// those it trades in none of them under "Other", the empty ones left out.
|
||||
pub fn categories(config: &Config, market: &MarketConfig) -> Vec<Category> {
|
||||
let traded = |id: &String| {
|
||||
market
|
||||
.items
|
||||
.get(id)
|
||||
.is_some_and(|p| p.buy > 0 || p.sell > 0)
|
||||
};
|
||||
let mut out: Vec<Category> = config
|
||||
.categories
|
||||
.iter()
|
||||
.map(|c| Category {
|
||||
items: c.items.iter().filter(|i| traded(i)).cloned().collect(),
|
||||
..c.clone()
|
||||
})
|
||||
.collect();
|
||||
let other: Vec<String> = market
|
||||
.items
|
||||
.keys()
|
||||
.filter(|id| traded(id) && !out.iter().any(|c| c.items.contains(id)))
|
||||
.cloned()
|
||||
.collect();
|
||||
if !other.is_empty() {
|
||||
out.push(Category {
|
||||
name: "Other".into(),
|
||||
icon: "minecraft:chest".into(),
|
||||
items: other,
|
||||
});
|
||||
}
|
||||
out.retain(|c| !c.items.is_empty());
|
||||
out
|
||||
}
|
||||
|
||||
/// The players' open shop menus: their window and page.
|
||||
#[derive(Default)]
|
||||
pub struct Shop {
|
||||
pub views: HashMap<u128, (Option<i32>, Page)>,
|
||||
}
|
||||
|
||||
impl Shop {
|
||||
/// The page `uuid` sees, as cells.
|
||||
pub fn layout(&self, config: &Config, market: &MarketConfig, uuid: u128) -> Vec<Cell> {
|
||||
let page = self.views.get(&uuid).map_or(Page::Main, |v| v.1);
|
||||
let cats = categories(config, market);
|
||||
let mut cells = vec![Cell::Empty; SIZE];
|
||||
for c in &mut cells[PER_PAGE..] {
|
||||
*c = Cell::Filler;
|
||||
}
|
||||
cells[HELP] = Cell::Help;
|
||||
cells[BALANCE] = Cell::Balance;
|
||||
cells[SELL_ALL] = Cell::SellAll;
|
||||
cells[LIMITS] = Cell::Limits;
|
||||
cells[CLOSE] = Cell::Close;
|
||||
match page {
|
||||
Page::Main => {
|
||||
for (slot, c) in CATEGORY_SLOTS.iter().zip(&cats) {
|
||||
cells[*slot] = Cell::Category(c.icon.clone(), c.name.clone(), c.items.len());
|
||||
}
|
||||
}
|
||||
Page::Category(i, p) => {
|
||||
cells[BACK] = Cell::Back;
|
||||
let items = cats.get(i).map(|c| c.items.as_slice()).unwrap_or_default();
|
||||
for (slot, id) in items.iter().skip(p * PER_PAGE).take(PER_PAGE).enumerate() {
|
||||
cells[slot] = Cell::Item(
|
||||
id.clone(),
|
||||
market.items.get(id).copied().unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
if p > 0 {
|
||||
cells[PREV] = Cell::Prev;
|
||||
}
|
||||
if (p + 1) * PER_PAGE < items.len() {
|
||||
cells[NEXT] = Cell::Next;
|
||||
}
|
||||
}
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
/// What a click on the menu's `slot` means (`shift`: a shift-click); a
|
||||
/// new page is kept as the player's.
|
||||
pub fn click(
|
||||
&mut self,
|
||||
config: &Config,
|
||||
market: &MarketConfig,
|
||||
uuid: u128,
|
||||
slot: usize,
|
||||
shift: bool,
|
||||
) -> Click {
|
||||
let cells = self.layout(config, market, uuid);
|
||||
let page = self.views.get(&uuid).map_or(Page::Main, |v| v.1);
|
||||
let click = match (cells.get(slot), page) {
|
||||
(Some(Cell::Category(_, name, _)), _) => {
|
||||
let i = categories(config, market)
|
||||
.iter()
|
||||
.position(|c| &c.name == name);
|
||||
i.map_or(Click::Nothing, |i| Click::Show(Page::Category(i, 0)))
|
||||
}
|
||||
(Some(Cell::Item(id, price)), _) if price.buy > 0 => {
|
||||
Click::Buy(id.clone(), if shift { STACK } else { 1 })
|
||||
}
|
||||
(Some(Cell::Back), _) => Click::Show(Page::Main),
|
||||
(Some(Cell::Prev), Page::Category(i, p)) => {
|
||||
Click::Show(Page::Category(i, p.saturating_sub(1)))
|
||||
}
|
||||
(Some(Cell::Next), Page::Category(i, p)) => Click::Show(Page::Category(i, p + 1)),
|
||||
(Some(Cell::SellAll), _) => Click::SellAll,
|
||||
(Some(Cell::Close), _) => Click::Close,
|
||||
_ => Click::Nothing,
|
||||
};
|
||||
if let (Click::Show(p), Some(v)) = (&click, self.views.get_mut(&uuid)) {
|
||||
v.1 = *p;
|
||||
}
|
||||
click
|
||||
}
|
||||
}
|
||||
|
||||
/// An item's button: its name and what the market asks and pays.
|
||||
pub fn item_label(id: &str, price: Price, money: &dyn Fn(i64) -> String) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if price.buy > 0 {
|
||||
parts.push(format!("buy {}", money(price.buy)));
|
||||
}
|
||||
if price.sell > 0 {
|
||||
parts.push(format!("sell {}", money(price.sell)));
|
||||
}
|
||||
format!("{}: {}", title(id), parts.join(", "))
|
||||
}
|
||||
|
||||
/// The help button's text.
|
||||
pub const HELP_TEXT: &str = "Click: buy 1 · Shift-click: buy 64 · Click a stack of yours: sell it";
|
||||
|
||||
/// `minecraft:iron_ingot` as `Iron Ingot`, for a button.
|
||||
pub fn title(id: &str) -> String {
|
||||
item_name(id)
|
||||
.split(' ')
|
||||
.map(|w| {
|
||||
let mut c = w.chars();
|
||||
c.next()
|
||||
.map(|f| f.to_uppercase().chain(c).collect())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ALEX: u128 = 1;
|
||||
|
||||
fn open(shop: &mut Shop) {
|
||||
shop.views.insert(ALEX, (Some(3), Page::Main));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_page_shows_the_categories_with_the_market_s_items() {
|
||||
let (config, mut market) = (Config::default(), MarketConfig::default());
|
||||
// An item in no category: under "Other". One the market doesn't
|
||||
// trade: nowhere.
|
||||
market
|
||||
.items
|
||||
.insert("minecraft:sponge".into(), Price { buy: 50, sell: 0 });
|
||||
market.items.remove("minecraft:torch");
|
||||
let cats = categories(&config, &market);
|
||||
assert_eq!(cats.last().unwrap().name, "Other");
|
||||
assert_eq!(cats.last().unwrap().items, ["minecraft:sponge"]);
|
||||
assert!(
|
||||
cats.iter()
|
||||
.all(|c| !c.items.contains(&"minecraft:torch".to_owned()))
|
||||
);
|
||||
// Every default item is in a category of its own.
|
||||
let all = categories(&config, &MarketConfig::default());
|
||||
assert!(all.iter().all(|c| c.name != "Other"), "{all:?}");
|
||||
let mut shop = Shop::default();
|
||||
open(&mut shop);
|
||||
let cells = shop.layout(&config, &market, ALEX);
|
||||
assert_eq!(
|
||||
cells[10],
|
||||
Cell::Category("minecraft:wheat".into(), "Farming".into(), 10)
|
||||
);
|
||||
assert_eq!(
|
||||
(cells[BALANCE].clone(), cells[CLOSE].clone()),
|
||||
(Cell::Balance, Cell::Close)
|
||||
);
|
||||
assert_eq!(cells[BACK], Cell::Filler);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_open_a_category_buy_one_or_a_stack_and_go_back() {
|
||||
let (config, market) = (Config::default(), MarketConfig::default());
|
||||
let mut shop = Shop::default();
|
||||
open(&mut shop);
|
||||
// Mining: the second category.
|
||||
assert_eq!(
|
||||
shop.click(&config, &market, ALEX, 11, false),
|
||||
Click::Show(Page::Category(1, 0))
|
||||
);
|
||||
let cells = shop.layout(&config, &market, ALEX);
|
||||
assert_eq!(
|
||||
cells[0],
|
||||
Cell::Item("minecraft:coal".into(), Price { buy: 8, sell: 2 })
|
||||
);
|
||||
assert_eq!(cells[BACK], Cell::Back);
|
||||
assert_eq!(
|
||||
shop.click(&config, &market, ALEX, 0, false),
|
||||
Click::Buy("minecraft:coal".into(), 1)
|
||||
);
|
||||
assert_eq!(
|
||||
shop.click(&config, &market, ALEX, 0, true),
|
||||
Click::Buy("minecraft:coal".into(), 64)
|
||||
);
|
||||
assert_eq!(
|
||||
shop.click(&config, &market, ALEX, 40, false),
|
||||
Click::Nothing
|
||||
);
|
||||
assert_eq!(
|
||||
shop.click(&config, &market, ALEX, SELL_ALL, false),
|
||||
Click::SellAll
|
||||
);
|
||||
assert_eq!(
|
||||
shop.click(&config, &market, ALEX, BACK, false),
|
||||
Click::Show(Page::Main)
|
||||
);
|
||||
assert_eq!(shop.views[&ALEX].1, Page::Main);
|
||||
assert_eq!(
|
||||
shop.click(&config, &market, ALEX, CLOSE, false),
|
||||
Click::Close
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_the_market_only_buys_is_not_sold_and_long_categories_have_pages() {
|
||||
let mut market = MarketConfig::default();
|
||||
let mut config = Config::default();
|
||||
// Rotten flesh: the market buys it, never sells it.
|
||||
let mut shop = Shop::default();
|
||||
open(&mut shop);
|
||||
shop.click(&config, &market, ALEX, 13, false);
|
||||
assert_eq!(shop.views[&ALEX].1, Page::Category(3, 0));
|
||||
assert_eq!(shop.click(&config, &market, ALEX, 0, false), Click::Nothing);
|
||||
// 50 items in one category: two pages.
|
||||
let many: Vec<String> = (0..50).map(|i| format!("minecraft:item_{i}")).collect();
|
||||
for id in &many {
|
||||
market.items.insert(id.clone(), Price { buy: 2, sell: 1 });
|
||||
}
|
||||
config.categories = vec![Category {
|
||||
name: "All".into(),
|
||||
icon: "minecraft:chest".into(),
|
||||
items: many,
|
||||
}];
|
||||
shop.views.insert(ALEX, (Some(3), Page::Category(0, 0)));
|
||||
let cells = shop.layout(&config, &market, ALEX);
|
||||
assert_eq!(
|
||||
(cells[PREV].clone(), cells[NEXT].clone()),
|
||||
(Cell::Filler, Cell::Next)
|
||||
);
|
||||
assert_eq!(
|
||||
shop.click(&config, &market, ALEX, NEXT, false),
|
||||
Click::Show(Page::Category(0, 1))
|
||||
);
|
||||
let cells = shop.layout(&config, &market, ALEX);
|
||||
assert_eq!(
|
||||
cells[0],
|
||||
Cell::Item("minecraft:item_45".into(), Price { buy: 2, sell: 1 })
|
||||
);
|
||||
assert_eq!(cells[5], Cell::Empty);
|
||||
assert_eq!(cells[PREV], Cell::Prev);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_read_well() {
|
||||
let money = |n: i64| format!("{n} coins");
|
||||
assert_eq!(
|
||||
item_label("minecraft:iron_ingot", Price { buy: 30, sell: 8 }, &money),
|
||||
"Iron Ingot: buy 30 coins, sell 8 coins"
|
||||
);
|
||||
assert_eq!(
|
||||
item_label("minecraft:bone", Price { buy: 0, sell: 1 }, &money),
|
||||
"Bone: sell 1 coins"
|
||||
);
|
||||
}
|
||||
}
|
||||
+454
-14
@@ -15,11 +15,17 @@ Steve, `horizon-ctl --offscreen`) join it. Checked from the clients' own chat an
|
||||
comes back; the money in the game stays the same throughout;
|
||||
- the lottery: two players' tickets held, one drawn after `lottery_seconds`, the winner announced to everyone and paid
|
||||
the pot;
|
||||
- after the server stops cleanly and starts again, the balances are the same, and audit.log holds every change.
|
||||
- after the server stops cleanly and starts again, the balances are the same, and audit.log holds every change;
|
||||
- /money and /eco give * for an operator, then the /shop menu: a category, one item bought by a click and a stack by
|
||||
a shift-click, a stack of the player's own sold by clicking it, "Sell all" stopped by the daily limit (the rest
|
||||
kept, the limit shown in the menu and in /worth, the day's sales in market.json), the close button;
|
||||
- nicknames: refused ones (a player's name, too short, a bad character), one in the other's chat and Tab list,
|
||||
/realname, someone else's taken, the cooldown, an operator taking it off.
|
||||
|
||||
Everything runs in fresh temporary folders (the server's, each client's home, launcher and XDG folders), no audio.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
@@ -98,6 +104,14 @@ class Server:
|
||||
return self.lines
|
||||
|
||||
|
||||
def new_lines(before, after):
|
||||
"""The lines `after` adds to `before`: the client keeps only its last 100 chat lines, so the oldest may be gone."""
|
||||
for n in range(min(len(before), len(after)), -1, -1):
|
||||
if before[len(before) - n:] == after[:n]:
|
||||
return after[n:]
|
||||
return after
|
||||
|
||||
|
||||
class Client:
|
||||
"""A real client under horizon-ctl, joined to the server as `name`."""
|
||||
|
||||
@@ -125,10 +139,16 @@ class Client:
|
||||
|
||||
def say(self, line):
|
||||
"""A command typed in chat; returns the chat lines it brought."""
|
||||
before = len(self.chat())
|
||||
before = self.chat()
|
||||
self.run("chat", line)
|
||||
time.sleep(1.5)
|
||||
return self.chat()[before:]
|
||||
# The answer, however busy the machine: up to 10 s for a first line,
|
||||
# then a moment for the rest of it.
|
||||
for _ in range(40):
|
||||
time.sleep(0.25)
|
||||
if self.chat() != before:
|
||||
break
|
||||
time.sleep(1)
|
||||
return new_lines(before, self.chat())
|
||||
|
||||
def chat(self):
|
||||
"""Every chat line so far, oldest first (`messages` answers newest first)."""
|
||||
@@ -214,11 +234,384 @@ def lottery(alex, steve, alex_wins):
|
||||
assert len(results["draw"]) == 1 and "won 50 coins" in results["draw"][0], results
|
||||
assert results["draw"][0] in steve.chat()[-3:], "everyone hears the draw"
|
||||
alex_lotto = results["draw"][0].startswith("Lottery: Alex won")
|
||||
assert any(f"Balance: {alex_had - 30 + (50 if alex_lotto else 0)} coins" in l for l in alex.say("/balance"))
|
||||
assert any(f"Balance: {steve_had - 20 + (0 if alex_lotto else 50)} coins" in l for l in steve.say("/balance"))
|
||||
results["alex_after"] = alex.say("/balance")
|
||||
assert any(f"Balance: {alex_had - 30 + (50 if alex_lotto else 0)} coins" in l for l in results["alex_after"]), (
|
||||
results, alex_had, alex.chat()[-12:])
|
||||
results["steve_after"] = steve.say("/balance")
|
||||
assert any(f"Balance: {steve_had - 20 + (0 if alex_lotto else 50)} coins" in l for l in results["steve_after"]), (
|
||||
results, steve_had, steve.chat()[-12:])
|
||||
return results, alex_lotto
|
||||
|
||||
|
||||
def where(c):
|
||||
"""The client's dimension, position and health."""
|
||||
s = c.run("state")
|
||||
return s.get("dimension"), s["player"]["position"], s["player"]["health"]
|
||||
|
||||
|
||||
def near(a, b):
|
||||
return sum((x - y) ** 2 for x, y in zip(a, b)) < 1.5 ** 2
|
||||
|
||||
|
||||
def places(server, alex, steve):
|
||||
"""Spawn, homes, warps, /tpa and /rules; teleports across levels, called off by a move or a hit."""
|
||||
r = {}
|
||||
r["setspawn"] = alex.say("/setspawn")
|
||||
assert any("The spawn is here now." in l for l in r["setspawn"]), r
|
||||
r["sethome"] = steve.say("/sethome base")
|
||||
assert any('Home "base" set here.' in l for l in r["sethome"]), r
|
||||
_, home, _ = where(steve)
|
||||
r["limit"] = steve.say("/sethome two")
|
||||
assert any("You have 1 of 1 homes" in l for l in r["limit"]), r
|
||||
# To the Nether's bedrock roof, then home, across levels.
|
||||
server.console("execute in minecraft:the_nether run tp Steve 0.5 128 0.5")
|
||||
time.sleep(5)
|
||||
assert where(steve)[0] == "minecraft:the_nether", where(steve)
|
||||
r["home"] = steve.say("/home base")
|
||||
assert any("Teleporting in 2 s: don't move." in l for l in r["home"]), r
|
||||
time.sleep(5)
|
||||
dim, at, _ = where(steve)
|
||||
r["home_reached"] = [dim, at]
|
||||
assert dim == "minecraft:overworld" and near(at, home), (r, home)
|
||||
assert any('Teleported to home "base".' in l for l in steve.chat()[-4:]), steve.chat()[-4:]
|
||||
# A move or a hit during the warmup calls it off.
|
||||
steve.say("/spawn")
|
||||
steve.run("move", "forward", 1)
|
||||
time.sleep(2)
|
||||
r["moved"] = steve.chat()[-3:]
|
||||
assert any("Teleport called off: you moved." in l for l in r["moved"]), r
|
||||
steve.say("/spawn")
|
||||
server.console("damage Steve 2")
|
||||
time.sleep(3)
|
||||
r["hurt"] = steve.chat()[-3:]
|
||||
assert any("Teleport called off: you were hurt." in l for l in r["hurt"]), r
|
||||
# A request: Alex goes to Steve once Steve accepts.
|
||||
alex.say("/tpa Steve")
|
||||
assert any("Alex asks to teleport to you" in l for l in steve.chat()[-3:]), steve.chat()[-3:]
|
||||
steve.say("/tpaccept")
|
||||
time.sleep(4)
|
||||
r["tpa"] = [where(alex)[1], where(steve)[1]]
|
||||
assert near(*r["tpa"]), r
|
||||
alex.say("/setwarp shop")
|
||||
r["warps"] = steve.say("/warps")
|
||||
assert any("Warps: shop" in l for l in r["warps"]), r
|
||||
r["warp_refused"] = steve.say("/setwarp mine")
|
||||
assert not any("set here" in l for l in r["warp_refused"]), r
|
||||
r["rules"] = steve.say("/rules")
|
||||
assert any("1. Be respectful to everyone." in l for l in r["rules"]), r
|
||||
return r
|
||||
|
||||
|
||||
def claims(server, alex, steve):
|
||||
"""Alex claims a chunk: Steve can't break, use or place there until trusted."""
|
||||
r = {}
|
||||
_, (ax, ay, az), _ = where(alex)
|
||||
cx, cz, y = math.floor(ax) >> 4, math.floor(az) >> 4, math.floor(ay)
|
||||
r["claim"] = alex.say("/claim")
|
||||
assert any("Claimed this chunk" in l for l in r["claim"]), r
|
||||
lever, dirt, free = (cx * 16 + 8, y, cz * 16 + 8), (cx * 16 + 10, y, cz * 16 + 8), (cx * 16 + 8, y, cz * 16 + 10)
|
||||
server.console("setblock %d %d %d minecraft:lever[face=floor]" % lever)
|
||||
server.console("setblock %d %d %d minecraft:dirt" % dirt)
|
||||
server.console(f"tp Steve {lever[0] + 0.5} {y} {lever[2] + 3.5}")
|
||||
server.console("give Steve minecraft:oak_planks 4")
|
||||
time.sleep(3)
|
||||
block = lambda pos: json.dumps(steve.run("block", *pos))
|
||||
steve.run("interact", *lever)
|
||||
time.sleep(0.5)
|
||||
# The action bar fades after 3 s: read right after the click (a busy machine may take that long to break).
|
||||
said = json.dumps(steve.run("messages", 5))
|
||||
steve.run("break", *dirt, "--timeout", 4, check=False)
|
||||
steve.run("place", *free, "--item", "minecraft:oak_planks", check=False)
|
||||
time.sleep(1.5)
|
||||
r["refused"] = [block(lever), block(dirt), block(free), said + json.dumps(steve.run("messages", 5))]
|
||||
# Not broken (grass may spread onto the dirt meanwhile).
|
||||
assert "powered=true" not in r["refused"][0] and "minecraft:air" not in r["refused"][1], r
|
||||
assert "oak_planks" not in r["refused"][2], r
|
||||
assert "This land is claimed by Alex." in r["refused"][3], r
|
||||
r["trust"] = alex.say("/trust Steve")
|
||||
assert any("Steve may now build" in l for l in r["trust"]), r
|
||||
steve.run("interact", *lever)
|
||||
steve.run("break", *dirt, "--timeout", 10, check=False)
|
||||
time.sleep(1.5)
|
||||
r["trusted"] = [block(lever), block(dirt)]
|
||||
assert "powered=true" in r["trusted"][0] and '"block": "minecraft:air"' in r["trusted"][1], r
|
||||
r["claims"] = alex.say("/claims")
|
||||
assert any("Your chunks (1 of 4)" in l for l in r["claims"]) and any("Trusted: Steve" in l for l in r["claims"]), r
|
||||
return r
|
||||
|
||||
|
||||
def world_guard(server, alex, steve):
|
||||
"""Nothing reaches Alex's chunk from outside: water, fire, a piston, TNT, Steve's bucket and fists."""
|
||||
r = {}
|
||||
_, (ax, ay, az), _ = where(alex)
|
||||
x0, z0, y = (math.floor(ax) >> 4) * 16, (math.floor(az) >> 4) * 16, math.floor(ay)
|
||||
r["untrust"] = alex.say("/untrust Steve")
|
||||
block = lambda x, yy, z: json.dumps(steve.run("block", x, yy, z))
|
||||
for line in ("weather clear", f"tp Alex {x0 + 8.5} {y} {z0 - 12.5}", f"tp Steve {x0 + 4.5} {y} {z0 - 1.5}",
|
||||
# Water two blocks east of the chunk; fire on netherrack against planks on both sides of its
|
||||
# west edge (more planks outside, so some burn soon).
|
||||
f"setblock {x0 + 17} {y} {z0 + 8} minecraft:water",
|
||||
f"setblock {x0 - 1} {y - 1} {z0 + 8} minecraft:netherrack",
|
||||
f"setblock {x0} {y} {z0 + 8} minecraft:oak_planks",
|
||||
f"setblock {x0 - 2} {y} {z0 + 8} minecraft:oak_planks",
|
||||
f"setblock {x0 - 1} {y} {z0 + 7} minecraft:oak_planks",
|
||||
f"setblock {x0 - 1} {y} {z0 + 9} minecraft:oak_planks",
|
||||
f"setblock {x0 - 1} {y + 1} {z0 + 8} minecraft:oak_planks",
|
||||
f"setblock {x0 - 1} {y} {z0 + 8} minecraft:fire",
|
||||
# North of the chunk: a piston pushing dirt into it, another pushing away from it.
|
||||
f"setblock {x0 + 8} {y} {z0 - 2} minecraft:piston[facing=south]",
|
||||
f"setblock {x0 + 8} {y} {z0 - 1} minecraft:dirt",
|
||||
f"setblock {x0 + 12} {y} {z0 - 2} minecraft:piston[facing=north]",
|
||||
f"setblock {x0 + 12} {y} {z0 - 3} minecraft:dirt",
|
||||
f"setblock {x0 + 8} {y} {z0 - 3} minecraft:redstone_block",
|
||||
f"setblock {x0 + 12} {y} {z0 - 1} minecraft:redstone_block",
|
||||
"give Steve minecraft:water_bucket",
|
||||
f"summon minecraft:cow {x0 + 5.5} {y} {z0 + 0.5} {{NoAI:1b}}"):
|
||||
server.console(line)
|
||||
time.sleep(4)
|
||||
r["water"] = [block(x0 + 16, y, z0 + 8), block(x0 + 15, y, z0 + 8)]
|
||||
assert "water" in r["water"][0] and "water" not in r["water"][1], r
|
||||
r["pistons"] = [block(x0 + 8, y, z0 - 2), block(x0 + 8, y, z0 - 1), block(x0 + 12, y, z0 - 2)]
|
||||
assert "extended=false" in r["pistons"][0] and ("dirt" in r["pistons"][1] or "grass_block" in r["pistons"][1]), r
|
||||
assert "extended=true" in r["pistons"][2], r
|
||||
# Steve's bucket on the chunk's ground: no water; then on the ground outside it.
|
||||
steve.run("look-at-block", x0 + 3, y - 1, z0 + 1, "up")
|
||||
steve.run("use-item")
|
||||
time.sleep(1.5)
|
||||
r["bucket"] = [block(x0 + 3, y, z0 + 1), json.dumps(steve.run("messages", 5))]
|
||||
assert "water" not in r["bucket"][0] and "This land is claimed by Alex." in r["bucket"][1], r
|
||||
# Steve's fist on Alex's cow: nothing, until Alex trusts him.
|
||||
cow = steve.run("entities", 8, "cow")["entities"][0]
|
||||
steve.run("attack", cow["id"])
|
||||
time.sleep(1.5)
|
||||
r["cow"] = [steve.run("entities", 8, "cow")["entities"][0]["health"]]
|
||||
r["trust"] = alex.say("/trust Steve")
|
||||
steve.run("attack", cow["id"])
|
||||
time.sleep(1.5)
|
||||
r["cow"].append(steve.run("entities", 8, "cow")["entities"][0]["health"])
|
||||
assert r["cow"][0] == 10 and r["cow"][1] < 10, r
|
||||
# Fire: the planks outside burn, those inside never.
|
||||
for _ in range(90):
|
||||
outside = [block(x0 - 2, y, z0 + 8), block(x0 - 1, y, z0 + 7), block(x0 - 1, y, z0 + 9),
|
||||
block(x0 - 1, y + 1, z0 + 8)]
|
||||
if sum("oak_planks" not in b for b in outside) >= 2:
|
||||
break
|
||||
time.sleep(1)
|
||||
r["fire"] = outside + [block(x0, y, z0 + 8)]
|
||||
assert sum("oak_planks" not in b for b in outside) >= 1 and "oak_planks" in r["fire"][-1], r
|
||||
# TNT two blocks south of the chunk: a crater outside, not one block of the chunk broken.
|
||||
inside = lambda: json.dumps(steve.run("blocks", x0 + 4, y - 4, z0 + 12, x0 + 12, y + 2, z0 + 15)["counts"])
|
||||
r["before_tnt"] = inside()
|
||||
server.console(f"summon minecraft:tnt {x0 + 8.5} {y} {z0 + 17.5} {{fuse:1}}")
|
||||
for _ in range(20):
|
||||
time.sleep(1)
|
||||
if "grass" not in block(x0 + 8, y - 1, z0 + 17):
|
||||
break
|
||||
time.sleep(1)
|
||||
r["after_tnt"] = [inside(), block(x0 + 8, y - 1, z0 + 17)]
|
||||
assert r["after_tnt"][0] == r["before_tnt"] and "grass" not in r["after_tnt"][1], r
|
||||
return r
|
||||
|
||||
|
||||
def board(alex, steve, out):
|
||||
"""The scoreboard sidebar (the player's own block, the richest, the address) and the Tab list; /sidebar off
|
||||
hides it, on shows it again."""
|
||||
r = {}
|
||||
side = lambda c: c.run("status").get("sidebar")
|
||||
for _ in range(20):
|
||||
r["sidebar"] = side(steve)
|
||||
if r["sidebar"]:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
assert r["sidebar"]["objective"] == "horizon:native_sidebar" and r["sidebar"]["title"] == "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
|
||||
# Holders starting with '#' would be hidden from the sidebar (only the title would show).
|
||||
assert not any(o.startswith("#") for o in r["sidebar"]["scores"]), r
|
||||
steve.run("key", "tab", "down")
|
||||
time.sleep(1)
|
||||
steve.run("screenshot", str(Path(out).resolve() / "sidebar-and-tab.png"))
|
||||
steve.run("key", "tab", "up")
|
||||
r["off"] = steve.say("/sidebar off")
|
||||
assert any("The sidebar is off" in l for l in r["off"]), r
|
||||
time.sleep(1)
|
||||
assert not side(steve), side(steve)
|
||||
assert side(alex), "only Steve's is gone"
|
||||
r["on"] = steve.say("/sidebar")
|
||||
assert any("The sidebar is on" in l for l in r["on"]), r
|
||||
for _ in range(10):
|
||||
if side(steve):
|
||||
break
|
||||
time.sleep(0.5)
|
||||
assert side(steve), "back within a second or two"
|
||||
steve.say("/sidebar off")
|
||||
return r
|
||||
|
||||
|
||||
def polish(server, alex, steve, out):
|
||||
"""/back after a death, /playtime, /claim show."""
|
||||
r = {}
|
||||
_, died_at, _ = where(steve)
|
||||
server.console("kill Steve")
|
||||
time.sleep(2.5)
|
||||
steve.run("respawn")
|
||||
time.sleep(2)
|
||||
r["died_at"], r["respawned_at"] = died_at, where(steve)[1]
|
||||
r["back"] = steve.say("/back")
|
||||
assert any("Teleporting in" in l for l in r["back"]), r
|
||||
time.sleep(4)
|
||||
r["back_reached"] = where(steve)[1]
|
||||
assert near(r["back_reached"], died_at), r
|
||||
assert any("where you died" in l for l in steve.chat()[-4:]), steve.chat()[-4:]
|
||||
r["playtime"] = steve.say("/playtime")
|
||||
assert any("You have played here for" in l for l in r["playtime"]), r
|
||||
r["playtime_alex"] = steve.say("/playtime Alex")
|
||||
assert any("Alex has played here for" in l for l in r["playtime_alex"]), r
|
||||
r["show"] = alex.say("/claim show")
|
||||
assert any("This chunk's border, for 10 s" in l for l in r["show"]), r
|
||||
time.sleep(2)
|
||||
alex.run("screenshot", str(Path(out).resolve() / "claim-show.png"))
|
||||
return r
|
||||
|
||||
|
||||
def presence(server, alex, steve):
|
||||
"""/afk keeps Steve out of the sleep count (Alex alone in bed passes the night); moving brings him back; too many
|
||||
commands at once wait."""
|
||||
r = {}
|
||||
r["afk"] = steve.say("/afk")
|
||||
assert any("You are AFK" in l for l in r["afk"]), r
|
||||
_, (ax, ay, az), _ = where(alex)
|
||||
x, y, z = math.floor(ax) + 2, math.floor(ay), math.floor(az)
|
||||
for line in (f"setblock {x} {y} {z} minecraft:red_bed[part=foot,facing=south]",
|
||||
f"setblock {x} {y} {z + 1} minecraft:red_bed[part=head,facing=south]", "time set 18000"):
|
||||
server.console(line)
|
||||
time.sleep(2)
|
||||
alex.run("interact", x, y, z)
|
||||
# Five seconds asleep, then the morning; Steve, AFK and awake, doesn't hold it back.
|
||||
for _ in range(40):
|
||||
r["time_of_day"] = alex.run("status")["day_time"] % 24000
|
||||
if r["time_of_day"] < 12000:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
assert r["time_of_day"] < 12000, r
|
||||
steve.run("move", "forward", 1)
|
||||
time.sleep(2.5)
|
||||
assert any("You are back." in l for l in steve.chat()[-4:]), steve.chat()[-4:]
|
||||
for _ in range(9):
|
||||
steve.run("chat", "/bal")
|
||||
time.sleep(2)
|
||||
r["spam"] = steve.chat()[-12:]
|
||||
assert any("Slow down: at most 8 commands every 5 s." in l for l in r["spam"]), r
|
||||
return r
|
||||
|
||||
|
||||
def nicks(server, alex, steve, out):
|
||||
"""Steve takes a nickname: refused ones first, then ~Rocky in Alex's chat and the Tab list, /realname, /pay by
|
||||
nickname, the cooldown, and Alex (an operator) takes it off. The one an older version let through is gone."""
|
||||
r = {}
|
||||
r["purged"] = [l for l in server.lines if "taken off" in l]
|
||||
assert any("nickname N0tch of" in l and "kept for the server" in l for l in r["purged"]), r
|
||||
time.sleep(6) # the spam guard's window from the step before
|
||||
r["usage"] = steve.say("/nick")
|
||||
assert any("/nick <name> picks a nickname" in l for l in r["usage"]), r
|
||||
for line, error in (("/nick Alex", "Alex looks like the name of another player."),
|
||||
("/nick A1ex_", "A1ex_ looks like the name of another player."),
|
||||
("/nick N0tch", "N0tch reads as \"notch\": that word is kept for the server."),
|
||||
("/nick ab", "A nickname is 3 to 16 letters, digits or _.")):
|
||||
r[line] = steve.say(line)
|
||||
assert any(error in l for l in r[line]), r
|
||||
time.sleep(6)
|
||||
steve.run("chat", "/nick Rocky")
|
||||
time.sleep(3) # made at the next second's tick
|
||||
r["set"] = steve.chat()[-3:]
|
||||
assert any("Steve now goes by ~Rocky." in l for l in r["set"]), r
|
||||
steve.run("chat", "hello from rocky")
|
||||
time.sleep(2)
|
||||
r["chat"] = alex.chat()[-3:]
|
||||
assert any("~Rocky" in l and "hello from rocky" in l and "Steve" not in l for l in r["chat"]), r
|
||||
if os.environ.get("SMP_PROOF_MSG_NICK"):
|
||||
# An engine whose player arguments take nicknames (horizon fix/commands-take-nicknames).
|
||||
alex.run("chat", "/msg Rocky psst")
|
||||
time.sleep(2)
|
||||
r["msg"] = steve.chat()[-3:]
|
||||
assert any("whispers to you: psst" in l for l in r["msg"]), r
|
||||
alex.run("key", "tab", "down")
|
||||
time.sleep(1)
|
||||
alex.run("screenshot", str(Path(out).resolve() / "nick-tab.png"))
|
||||
alex.run("key", "tab", "up")
|
||||
r["realname"] = alex.say("/realname ~rocky")
|
||||
assert any("~Rocky is Steve." in l for l in r["realname"]), r
|
||||
r["taken"] = alex.say("/nick R0cky")
|
||||
assert any("R0cky looks like the name of another player." in l for l in r["taken"]), r
|
||||
# A nickname where a command names a player; paid back so the sums stay.
|
||||
r["pay"] = alex.say("/pay Rocky 1")
|
||||
assert any("You paid 1 coins to Steve." in l for l in r["pay"]), r
|
||||
steve.say("/pay Alex 1")
|
||||
r["cooldown"] = steve.say("/nick Pebble")
|
||||
assert any("wait 10 more minute(s)" in l for l in r["cooldown"]), r
|
||||
time.sleep(6)
|
||||
alex.run("chat", "/nick Steve off")
|
||||
time.sleep(3)
|
||||
r["off"] = steve.chat()[-3:]
|
||||
assert any("Steve goes by their name again." in l for l in r["off"]), r
|
||||
steve.run("chat", "back to steve")
|
||||
time.sleep(2)
|
||||
assert any("Steve" in l and "back to steve" in l for l in alex.chat()[-3:]), alex.chat()[-3:]
|
||||
return r
|
||||
|
||||
|
||||
def shop(server, alex):
|
||||
"""Alex alone, op, in survival: /money and /eco give *, then the /shop menu: a category, buying one and a stack,
|
||||
selling a stack of hers, "Sell all" up to the daily limit, closing. Returns the coins it added, and the lines."""
|
||||
results = {}
|
||||
# Only what the step gives her: "Sell all" would sell the rest too.
|
||||
server.console("clear Alex")
|
||||
time.sleep(1)
|
||||
results["money"] = alex.say("/money give Alex 2000")
|
||||
assert any("Alex now has" in l for l in results["money"]), results
|
||||
results["everyone"] = alex.say("/eco give * 10")
|
||||
assert any("Gave 10 coins to 1 player(s) on." in l for l in results["everyone"]), results
|
||||
assert alex.count("minecraft:iron_ingot") == 0
|
||||
alex.say("/shop")
|
||||
menu = alex.menu()
|
||||
assert menu[10]["item"] == "minecraft:wheat" and menu[48]["item"] == "minecraft:gold_ingot", menu
|
||||
alex.click(300 + 11) # Mining
|
||||
menu = alex.menu()
|
||||
assert [menu[i]["item"] for i in range(3)] == ["minecraft:coal", "minecraft:copper_ingot",
|
||||
"minecraft:iron_ingot"], menu
|
||||
alex.click(300 + 2) # one iron ingot
|
||||
assert alex.count("minecraft:iron_ingot") == 1, alex.run("inventory")
|
||||
results["bought"] = alex.chat()[-2:]
|
||||
assert any("Bought 1 iron ingot for 30 coins." in l for l in results["bought"]), results
|
||||
alex.click(300 + 45) # back
|
||||
alex.click(300 + 14) # Building
|
||||
alex.run("click-slot", 300 + 0, "shift") # a stack of cobblestone
|
||||
time.sleep(1)
|
||||
assert alex.count("minecraft:cobblestone") == 64, alex.run("inventory")
|
||||
# Her own stack of iron, clicked: sold.
|
||||
alex.click(alex.slot_of("minecraft:iron_ingot"))
|
||||
results["sold"] = alex.chat()[-2:]
|
||||
assert any("Sold 1 iron ingot for 8 coins." in l for l in results["sold"]), results
|
||||
assert alex.count("minecraft:iron_ingot") == 0
|
||||
# Sell all, up to the 2,000 coins a day: 19 of 30 diamonds (8 coins earned already).
|
||||
server.console("give Alex minecraft:diamond 30")
|
||||
time.sleep(2)
|
||||
alex.click(300 + 49)
|
||||
results["sell_all"] = alex.chat()[-3:]
|
||||
assert any("Sold 19 diamond for 1,900 coins." in l for l in results["sell_all"]), results
|
||||
assert any("11 diamond kept: today's selling limit." in l for l in results["sell_all"]), results
|
||||
assert alex.count("minecraft:diamond") == 11, alex.run("inventory")
|
||||
limits = alex.menu()[50]
|
||||
results["limits"] = limits
|
||||
assert "92 coins left of your 2,000 coins a day" in json.dumps(limits), limits
|
||||
alex.click(300 + 53) # close
|
||||
assert not alex.menu()
|
||||
results["worth"] = alex.say("/worth diamond")
|
||||
assert any("You may sell 621 more diamond today." in l for l in results["worth"]), results
|
||||
return 2000 + 10 - 30 - 64 + 8 + 1900, results
|
||||
|
||||
|
||||
def trades(server, alex, steve, port, ctl, resources, root):
|
||||
"""Every way out of a trade, then one that goes through. Alex: 10 diamonds; Steve: 5 emeralds, 5 torches."""
|
||||
results = {}
|
||||
@@ -311,14 +704,30 @@ def run(ctl, resources, out):
|
||||
data = server.folder / "world" / "horizon-smp"
|
||||
data.mkdir(parents=True)
|
||||
(data / "config.json").write_text(json.dumps({"gambling": {"enabled": True, "cooldown_seconds": 20,
|
||||
"lottery_seconds": 30}}))
|
||||
"lottery_seconds": 30},
|
||||
"places": {"warmup_seconds": 2, "cooldown_seconds": 0},
|
||||
# Paid once, after the restart, not to change the sums.
|
||||
"presence": {"daily_reward": 0},
|
||||
# Steve plays a few minutes before his nickname.
|
||||
"nicks": {"min_playtime_minutes": 1}}))
|
||||
# A nickname an older version let through, taken off at the first start.
|
||||
(data / "nicks.json").write_text(json.dumps({"nicks": {"00000000-0000-0000-0000-00000000abcd": {
|
||||
"nick": "N0tch", "changed": 0}}}))
|
||||
server.start()
|
||||
# No monster may end a step early: a slime once killed Steve on the flat world.
|
||||
for line in ("difficulty peaceful", "gamerule spawn_monsters false", "kill @e[type=minecraft:slime]"):
|
||||
server.console(line)
|
||||
alex.join(port)
|
||||
steve.join(port)
|
||||
time.sleep(2)
|
||||
config = json.loads((data / "config.json").read_text())
|
||||
results["config_keys"] = sorted(config)
|
||||
assert results["config_keys"] == ["currency", "gambling", "market", "starting_balance"], config
|
||||
assert results["config_keys"] == ["board", "claims", "currency", "gambling", "market", "nicks", "places",
|
||||
"presence", "shop", "starting_balance"], config
|
||||
results["welcome"] = steve.chat()
|
||||
assert any("Welcome to the server, Steve!" in l for l in results["welcome"]), results["welcome"]
|
||||
assert any("1. Be respectful to everyone." in l for l in results["welcome"]), results["welcome"]
|
||||
assert any("Steve joined for the first time" in l for l in alex.chat()), alex.chat()
|
||||
assert config["gambling"] == {"enabled": True, "min_bet": 10, "max_bet": 1000, "cooldown_seconds": 20,
|
||||
"offer_seconds": 60, "fee_percent": 0, "ticket_price": 10,
|
||||
"max_tickets": 100, "lottery_seconds": 30}, config
|
||||
@@ -367,6 +776,14 @@ def run(ctl, resources, out):
|
||||
server.console("give Steve minecraft:emerald 5")
|
||||
time.sleep(2)
|
||||
results["trades"], steve = trades(server, alex, steve, port, ctl, resources, root)
|
||||
# Before the coinflips: their last offer must still wait when the server stops.
|
||||
results["places"] = places(server, alex, steve)
|
||||
results["claims"] = claims(server, alex, steve)
|
||||
results["world_guard"] = world_guard(server, alex, steve)
|
||||
results["board"] = board(alex, steve, out)
|
||||
results["polish"] = polish(server, alex, steve, out)
|
||||
results["presence"] = presence(server, alex, steve)
|
||||
results["nicks"] = nicks(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)
|
||||
@@ -374,7 +791,13 @@ def run(ctl, resources, out):
|
||||
lines = server.stop()
|
||||
steve.quit()
|
||||
alex.quit()
|
||||
assert not any("panicked" in l for l in lines), lines[-40:]
|
||||
assert not any("panicked" in l or "teleport failed" in l for l in lines), lines[-40:]
|
||||
saved = json.loads((data / "places.json").read_text())
|
||||
assert sorted(saved["warps"]) == ["shop"] and saved["spawn"] and len(saved["homes"]) == 1, saved
|
||||
saved = json.loads((data / "board.json").read_text())
|
||||
assert len(saved["hidden"]) == 1, saved
|
||||
saved = json.loads((data / "claims.json").read_text())
|
||||
assert len(saved["chunks"]) == 1 and len(saved["trust"]) == 1, saved
|
||||
economy = json.loads((data / "economy.json").read_text())
|
||||
balances = {a["name"]: a["balance"] for a in economy["accounts"].values()}
|
||||
results["economy.json"] = economy
|
||||
@@ -384,26 +807,43 @@ def run(ctl, resources, out):
|
||||
results["audit"] = [a["kind"] for a in audit]
|
||||
assert results["audit"] == ["open", "open", "eco_give", "pay", "sell 20 minecraft:wheat",
|
||||
"sell 3 minecraft:diamond", "buy 5 minecraft:torch", "trade", "trade_failed",
|
||||
"trade", "trade_money", "coinflip_stake", "coinflip_stake", "coinflip_win",
|
||||
"trade", "trade_money", "pay", "pay", "coinflip_stake", "coinflip_stake", "coinflip_win",
|
||||
"coinflip_stake", "lottery_ticket", "lottery_ticket", "lottery_win"] + (
|
||||
["coinflip_cancel"] * (held == 0)), results["audit"]
|
||||
swap = [a for a in audit if a["kind"] == "trade"][-1]
|
||||
assert (swap["a_items"], swap["b_items"], swap["b_money"]) == (
|
||||
[{"item": "minecraft:diamond", "count": 10, "plain": True}],
|
||||
[{"item": "minecraft:emerald", "count": 5, "plain": True}], 35), swap
|
||||
# The daily reward, on: Alex gets it when she joins again.
|
||||
config = json.loads((data / "config.json").read_text())
|
||||
config["presence"]["daily_reward"] = 7
|
||||
(data / "config.json").write_text(json.dumps(config))
|
||||
server.start()
|
||||
alex = Client(ctl, resources, "Alex2", root)
|
||||
alex.name = "Alex"
|
||||
alex.join(port)
|
||||
results["after_restart"] = alex.say("/balance")
|
||||
assert any(f"Balance: {alex_after} coins" in l for l in results["after_restart"]), results
|
||||
assert any(f"Balance: {alex_after + 7} coins" in l for l in results["after_restart"]), results
|
||||
assert any("Daily reward: 7 coins." in l for l in alex.chat()), alex.chat()
|
||||
assert not any("joined for the first time" in l for l in alex.chat()), alex.chat()
|
||||
added, results["shop"] = shop(server, alex)
|
||||
alex.quit()
|
||||
server.stop()
|
||||
economy = json.loads((data / "economy.json").read_text())
|
||||
assert "held" not in economy and sum(a["balance"] for a in economy["accounts"].values()) == 815, economy
|
||||
total = sum(a["balance"] for a in economy["accounts"].values())
|
||||
assert "held" not in economy and total == 815 + 7 + added, economy
|
||||
kinds = [json.loads(l)["kind"] for l in (data / "audit.log").read_text().splitlines()]
|
||||
assert kinds[-1] in ("held_returned", "coinflip_cancel"), kinds[-3:]
|
||||
results["stake_back"] = kinds[-1]
|
||||
shop_kinds = ["eco_give", "eco_give", "buy 1 minecraft:iron_ingot", "buy 64 minecraft:cobblestone",
|
||||
"sell 1 minecraft:iron_ingot", "sell 19 minecraft:diamond"]
|
||||
assert kinds[-len(shop_kinds):] == shop_kinds, kinds[-8:]
|
||||
kinds = kinds[:-len(shop_kinds)]
|
||||
assert kinds[-2] in ("held_returned", "coinflip_cancel") and kinds[-1] == "daily_reward", kinds[-3:]
|
||||
results["stake_back"] = kinds[-2]
|
||||
sold = json.loads((data / "market.json").read_text())["sold"]
|
||||
# Steve's sales of the first run's day, and Alex's.
|
||||
assert sorted((s["coins"], s["items"]) for s in sold.values()) == [
|
||||
(320, {"minecraft:diamond": 3, "minecraft:wheat": 20}),
|
||||
(1908, {"minecraft:diamond": 19, "minecraft:iron_ingot": 1})], sold
|
||||
print(json.dumps({"proof": "smp", "ok": True, **results}, indent=2))
|
||||
finally:
|
||||
(out / "server.log").write_text("\n".join(server.lines))
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! The SMP's rules, as the bundle adds them to the game: each file is a
|
||||
//! module of `game` there and of this crate here, under the same name, so
|
||||
//! `super::smp_store` reaches the same code in both.
|
||||
#[path = "../mixins/smp/smp_board.rs"]
|
||||
pub mod smp_board;
|
||||
#[path = "../mixins/smp/smp_claims.rs"]
|
||||
pub mod smp_claims;
|
||||
#[path = "../mixins/smp/smp_economy.rs"]
|
||||
pub mod smp_economy;
|
||||
#[path = "../mixins/smp/smp_games.rs"]
|
||||
@@ -9,6 +13,16 @@ pub mod smp_games;
|
||||
pub mod smp_lottery;
|
||||
#[path = "../mixins/smp/smp_market.rs"]
|
||||
pub mod smp_market;
|
||||
#[path = "../mixins/smp/smp_nick.rs"]
|
||||
pub mod smp_nick;
|
||||
#[path = "../mixins/smp/smp_places.rs"]
|
||||
pub mod smp_places;
|
||||
#[path = "../mixins/smp/smp_presence.rs"]
|
||||
pub mod smp_presence;
|
||||
#[path = "../mixins/smp/smp_recipes.rs"]
|
||||
pub mod smp_recipes;
|
||||
#[path = "../mixins/smp/smp_shop.rs"]
|
||||
pub mod smp_shop;
|
||||
#[path = "../mixins/smp/smp_store.rs"]
|
||||
pub mod smp_store;
|
||||
#[path = "../mixins/smp/smp_trade.rs"]
|
||||
|
||||
@@ -5,13 +5,14 @@ further in or out. Its settings page (Options > Mods > Zoom > Settings) has the
|
||||
starting zoom (2x to 10x), smooth zoom (eased like the spyglass) and slower turning while zoomed.
|
||||
|
||||
It is a [horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundle (`mixins/zoom/`) that the Horizon
|
||||
launcher builds into the game. It replaces the WebAssembly Zoom 1.x and keeps its settings: the same files
|
||||
launcher builds into the game. It keeps the settings of earlier versions: the same files
|
||||
(`horizon-mods/.horizon-mod-data/zoom/level`, `smooth`, `sensitivity`) and the same key line
|
||||
(`key.zoom.zoom:…` in `horizon-mods.txt`).
|
||||
|
||||
## Install
|
||||
|
||||
In the Horizon launcher: Mods → Zoom → Install, then Settings → Game → Native mods (experimental) → Build. The
|
||||
In the Horizon launcher: Mods → Zoom → Install, then **Build** on the same page (once built, switch it
|
||||
on or off in the game under Options → Mods). The
|
||||
launcher downloads its own Rust and builds the game with your native mods; see Horizon's
|
||||
[native mods](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md).
|
||||
|
||||
@@ -42,7 +43,7 @@ python3 native/tools/native.py proof zoom --resources /path/to/.local/horizon-cl
|
||||
```
|
||||
|
||||
The proof needs resources prepared from your own Minecraft 26.3 (`horizon-prepare`); none are in this repository.
|
||||
It seeds the WebAssembly Zoom's settings (8x, no smoothing, key C) in a temporary game folder, then checks that
|
||||
It seeds an earlier version's settings (8x, no smoothing, key C) in a temporary game folder, then checks that
|
||||
Options > Mods shows them, that Z does nothing and C zooms, that the wheel zooms instead of moving the hotbar
|
||||
while C is held, and that the page's buttons write the same files. CI runs `cargo test`, packages the `.hnmod`
|
||||
and checks the game on the tested commit, and every Monday on the newest Nightly.
|
||||
|
||||
Reference in new issue
Block a user