mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 11:05:58 -10:00
Compare commits
131
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6593df3a20 | ||
|
|
bc40e7bafe | ||
|
|
5628be3e6b | ||
|
|
58fbad4a85 | ||
|
|
7645804495 | ||
|
|
e5d840dabf | ||
|
|
b22a9c42a3 | ||
|
|
6ab145fce5 | ||
|
|
c240394cde | ||
|
|
d57056b937 | ||
|
|
245ab9b4f6 | ||
|
|
61e99d5df7 | ||
|
|
59f2388a0f | ||
|
|
5b0ae21c96 | ||
|
|
097ccd28b0 | ||
|
|
31d77513fe | ||
|
|
5ba617927f | ||
|
|
7257e32237 | ||
|
|
18102bf315 | ||
|
|
7ec0a0516d | ||
|
|
c3839da0ef | ||
|
|
2f677e9337 | ||
|
|
8295b4c1ef | ||
|
|
cacc3360e0 | ||
|
|
964325b819 | ||
|
|
9ce6a3f9a0 | ||
|
|
693205032b | ||
|
|
e8b243b60d | ||
|
|
409cac606e | ||
|
|
d4463c5a82 | ||
|
|
e001cceadd | ||
|
|
2b62bd35d9 | ||
|
|
219c9302d9 | ||
|
|
e9f25bb1d7 | ||
|
|
bb97027039 | ||
|
|
27bfc9da13 | ||
|
|
fa61a38111 | ||
|
|
169ac59750 | ||
|
|
7c850a3056 | ||
|
|
e3ee0a7c45 | ||
|
|
3f115b7e06 | ||
|
|
a864f877ec | ||
|
|
41874a0e0c | ||
|
|
48ed3ea061 | ||
|
|
8e5a70b52b | ||
|
|
d31c2d7992 | ||
|
|
9f691e7e83 | ||
|
|
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
@@ -78,7 +78,9 @@ jobs:
|
||||
names = zipfile.ZipFile(path).namelist()
|
||||
manifest = json.loads(zipfile.ZipFile(path).read("horizon-native.json"))
|
||||
assert manifest["hooks"] >= 1, manifest
|
||||
assert all(n == "horizon-native.json" or (n.startswith(f"mixins/{manifest['id']}/") and n.endswith((".rs", ".toml"))) for n in names), names
|
||||
bundle = f"mixins/{manifest['id']}/"
|
||||
assert all(n == "horizon-native.json" or n == bundle + "LICENSE" or (n.startswith(bundle) and n.endswith((".rs", ".toml"))) for n in names), names
|
||||
assert bundle + "LICENSE" in names, "the mod's LICENSE travels in its package"
|
||||
print(path, manifest)
|
||||
PY
|
||||
- uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -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.
|
||||
@@ -82,7 +83,8 @@ The index: `https://horizon-rust.github.io/horizon-mods/index.json`.
|
||||
[horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundles on Horizon's native hook points
|
||||
([docs/native-mods.md](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md)). Each folder keeps the
|
||||
history of the repository it came from. A mod builds on any Horizon whose hook points are at least the version its
|
||||
`compatibility.json` names; `horizon_revision` is the commit it was last tested on.
|
||||
`compatibility.json` names; `horizon_revision` is the commit it was last tested on. `smp` and `voicechat-server`
|
||||
are server mods, built into the official server's engine (see their READMEs).
|
||||
|
||||
```sh
|
||||
python3 native/tools/native.py check zoom # apply on the tested Horizon and cargo check the game
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 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,32 @@
|
||||
id = "astral_cruiser"
|
||||
kind = "native-mod"
|
||||
name = "Astral Cruiser"
|
||||
summary = "A huge pilotable starship with a walkable interior, lasers, dash and live portals between dimensions."
|
||||
description = """
|
||||
A huge starship you fly yourself, with a large walkable interior that moves with it.
|
||||
|
||||
- **Flight:** WASD with the ship following your view, and F5 views including the ship's own.
|
||||
- **Weapons:** turrets, nose guns and two beams.
|
||||
- **Travel:** coordinate dash and jump. Long dashes show only a small strip of far terrain around the ship.
|
||||
- **Portals:** a portal shows the real destination dimension live, and you cross it aboard or on foot.
|
||||
- **Start:** `/astral spawn`, then `/astral board`. N opens the control panel; keys are in Options > Mods > Settings.
|
||||
|
||||
**What it does as a native mod.** It is compiled into the game with no sandbox. It changes the game's rendering, ticking and chunk loading for the ship and portals, and registers a thread-exit callback in builds made with MinGW so the allocator frees finished threads' memory.
|
||||
|
||||
**What it does not do.** It adds no network access of its own beyond the game's own, and it reads no accounts or files outside the game's data. It keeps the ship in the world's own folder (`astral_cruiser/ship.json`). It holds no Minecraft code or assets.
|
||||
|
||||
Weapons hurt the mobs they hit. With blasting turned on in the control panel (off by default), the nose guns and beams also blow up the blocks they hit. Portals can hang in the air, so a player on foot falls if there is no ground at the exit, including into the End's void.
|
||||
|
||||
Built for this Horizon commit only: `68ac6f647e7022b2c696e81863d7cface28c9416`."""
|
||||
authors = ["repositorykreml1n"]
|
||||
owners = ["repositorykreml1n"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/repositorykreml1n/astral-cruiser"
|
||||
|
||||
[[versions]]
|
||||
version = "0.1.0"
|
||||
published = "2026-11-01"
|
||||
game = "26.3"
|
||||
file = "astral_cruiser-0.1.0.hnmod"
|
||||
sha256 = "69100219f92307f9190712da2854bfd43f15cf81773d89d89d6dd22411caa2f2"
|
||||
changelog = "First release, built for Horizon 68ac6f6."
|
||||
@@ -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."
|
||||
@@ -0,0 +1,31 @@
|
||||
id = "crossversion"
|
||||
kind = "native-mod"
|
||||
name = "Cross Version"
|
||||
summary = "Allows joining older version minecraft servers"
|
||||
description = """
|
||||
|
||||
|
||||
A native Horizon mod that lets the 26.3 client join servers that speak an older Minecraft protocol.
|
||||
|
||||
Horizon talks exactly one protocol (777, Minecraft 26.3). This mod watches a join, asks the server what protocol it speaks in its status reply, and — if it is not 26.3 — starts a ViaProxy instance on a loopback port and points the join at it. The proxy speaks 26.3 to Horizon and the server's own version to the server. Nothing else in the client changes, and a join to a 26.3 server never starts anything."""
|
||||
authors = ["JustKarkat"]
|
||||
owners = ["JustKarkat"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/JustKarkat/horizon-crossversion/"
|
||||
icon = "https://github.com/JustKarkat/horizon-crossversion/blob/master/icon.png?raw=true"
|
||||
key = "RWTcPJHJYjPW0pV3lPwr45crCu0wJxW5HbDLc3fr3Eom1TzxO++kHOo4"
|
||||
|
||||
[[versions]]
|
||||
version = "0.7.1"
|
||||
published = "2026-11-02"
|
||||
game = "26.3"
|
||||
file = "crossversion-0.7.1.hnmod"
|
||||
sha256 = "deb9cf8a84838e6c48999f70fbd3c81db90992c456d6e85a0e0746b088c5b8fa"
|
||||
signature = """
|
||||
untrusted comment: signature from minisign secret key
|
||||
RUTcPJHJYjPW0nmeON4u6rxOS7AxK+s/e3kPaLkJ3lm618iPPc8oP8nWdJQ8k9yYriSZSu091TZ5m7qX56nNovAjeZyTcOdrUAk=
|
||||
trusted comment: timestamp:1793657606\tfile:crossversion-0.7.1.hnmod\thashed
|
||||
q2Oy6yVv5T+GHc2iM+HxNDImfPcaKGV/XPToNCTQFHSTKJ8+rz5IV4LfJnOBwBp1rZQZoGkExopDrXfZyQybCQ=="""
|
||||
changelog = """
|
||||
Version: 0.7.1
|
||||
Changelog: The MIT notice now ships inside the .hnmod (it is the header of the bundled engine source). The settings page's 'Delete the downloaded ViaProxy' deletes the jars only, so the account added to ViaProxy and its refresh token in saves.json stay. Also: a version switch on Edit Server and Direct Connect: choose what version ViaProxy tells that server it is (auto, 1.8, 1.9, 1.12.2, 1.16.5, 1.20.1, 1.21.1, 1.21.4, 26.3), kept per address, for servers whose status reports the wrong version."""
|
||||
@@ -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."
|
||||
@@ -0,0 +1,22 @@
|
||||
id = "motion_blur"
|
||||
kind = "native-mod"
|
||||
name = "Motion Blur"
|
||||
summary = "Blurs what moves: mobs, players, items and vehicles along their own motion, with optional camera blur."
|
||||
description = """
|
||||
Moving mobs, players, items, minecarts and boats blur along their own motion. The camera's motion (turning, walking) can blur too, and is off by default.
|
||||
|
||||
Each frame the mod works out every pixel's motion since the last frame (the world's from the depth buffer and the camera, entities from last frame's vertices), finds the largest motion near each tile, and gathers along it with depth-aware weights (McGuire et al., "A Reconstruction Filter for Plausible Motion Blur", 2012).
|
||||
|
||||
Settings are in Options → Mods → Motion Blur: strength for moving things and the camera, shutter angle or a fixed exposure, the longest blur, quality, blurring yourself in third person, and a view that shows each pixel's motion."""
|
||||
authors = ["levzzz5154"]
|
||||
owners = ["levzzz5154"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/levzzz5154/horizon-motion-blur"
|
||||
|
||||
[[versions]]
|
||||
version = "0.1.0"
|
||||
published = "2026-11-02"
|
||||
game = "26.3"
|
||||
file = "motion_blur-0.1.0.hnmod"
|
||||
sha256 = "4cdaa6369fffea8116b013edfc618358ca989f48ed65c83306d2102f0e506c47"
|
||||
changelog = "First release, built against Nightly 2026-11-01: motion blur for moving mobs, players, items and vehicles, with optional camera blur."
|
||||
@@ -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,26 @@
|
||||
id = "speedrun_timer"
|
||||
kind = "native-mod"
|
||||
name = "Speedrun Timer"
|
||||
summary = "Speedrun Timer Is a mod made to count the time it took you to beat the Game"
|
||||
description = """
|
||||
Speedrun Timer Is a mod made to count the time it took you to beat the Game
|
||||
it features on screen timer and best score count it also save files of speedrun records locally """
|
||||
authors = ["Demerobit"]
|
||||
owners = ["Demerobit"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/Demerobit/Speedrun-Timer-Horizon-Rust-Minecraft"
|
||||
icon = "https://raw.githubusercontent.com/Demerobit/Speedrun-Timer-Horizon-Rust-Minecraft/main/icon.png"
|
||||
key = "RWSN8c+oZYIvyvgXXOpp3RmpTyTijX2OA0iUvsExiaCTn1BawtmLRT6A"
|
||||
|
||||
[[versions]]
|
||||
version = "1.0.0"
|
||||
published = "2026-11-03"
|
||||
game = "26.3"
|
||||
file = "speedrun_timer-1.0.0.hnmod"
|
||||
sha256 = "a628fef22b6e072029647970623f9e12e10929069caca9571ca4bb2bbf08a988"
|
||||
signature = """
|
||||
untrusted comment: speedrun_timer 1.0.0
|
||||
RUSN8c+oZYIvyoH9a97dmlDoTaXXaL5RI7SylSk47DKxhoYAcWOoPrsrUtS1VQqoN06ELQMKEfafNiR1qT1QyvmL/5K6ZF/j8Ak=
|
||||
trusted comment: timestamp:1793684713\tfile:speedrun_timer-1.0.0.hnmod\thashed
|
||||
ZfY1563ZBFhaeAmd+JqVmKRDNgNacSl4QYB+Crf0JrBtEXso3r18hvVI/OgXCdgBszvh/r3ptkakCDWK2HoFBQ=="""
|
||||
changelog = "This is the first release of the Mod "
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"horizon_repository": "https://github.com/horizon-rust/horizon",
|
||||
"horizon_revision": "4baa571566e39bfd6594988d3bb2bc85440c30e7",
|
||||
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
|
||||
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
|
||||
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -4,5 +4,5 @@
|
||||
"horizon_repository": "https://github.com/horizon-rust/horizon",
|
||||
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
|
||||
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
|
||||
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
|
||||
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -4,5 +4,5 @@
|
||||
"horizon_repository": "https://github.com/horizon-rust/horizon",
|
||||
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
|
||||
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
|
||||
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
|
||||
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -4,5 +4,5 @@
|
||||
"horizon_repository": "https://github.com/horizon-rust/horizon",
|
||||
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
|
||||
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
|
||||
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
|
||||
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"horizon_repository": "https://github.com/horizon-rust/horizon",
|
||||
"horizon_revision": "4baa571566e39bfd6594988d3bb2bc85440c30e7",
|
||||
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
|
||||
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
|
||||
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
|
||||
}
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-smp"
|
||||
version = "0.5.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "horizon-mod-smp"
|
||||
version = "0.5.0"
|
||||
version = "0.31.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
+273
-13
@@ -8,15 +8,26 @@ 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 auction house, the server market and its `/shop` menu, player trades, the coinflip, the lottery, places (spawn, homes, warps,
|
||||
teleport requests, the rules), land claims, and the scoreboard (a sidebar with your balance, playtime and the richest players, the Tab list's
|
||||
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 <player> <amount> <reason>`, `/money give ...` | operators of level 3: makes money for a player (an event prize, a bug refund); everyone reads it with the reason, and the audit log keeps it |
|
||||
| `/eco take <player> <amount> [reason]`, `/money take ...` | operators of level 3: takes a cheat's gains out of the game, logged with the reason |
|
||||
| `/eco stats` | the money supply and where it sits, what the server made and destroyed today, how evenly money is spread |
|
||||
| `/ah` | the auction house: a chest menu of listings by category, cheapest a unit first; click one to buy it or bid; your listings (click to take one back) and your mail |
|
||||
| `/ah sell <price> [count]` | lists the stack in your hand (or part of it), whole (enchantments, name, contents...), at a fixed price, for `listing_hours` (48); listing is free |
|
||||
| `/ah auction <start> [hours]` | auctions the stack in your hand from a starting bid, for 1 to 24 hours (12) |
|
||||
| `/ah bid <listing> <amount>`, `/ah buy <listing>` | bids (the money is held until someone bids more or it ends), buys a fixed-price listing |
|
||||
| `/ah cancel <listing>`, `/ah claim`, `/ah search <words>`, `/ah mine`, `/ah mail` | takes back a listing nobody bid on; gives you your mail; the menu on a search, your listings, your mail |
|
||||
| `/shop` | the market as a chest menu: categories, a click buys one, a shift-click 64, a click on a stack of yours sells it, "Sell all" |
|
||||
| `/market [item]` | the moving market as a chest menu: categories, each item's price, its move over 1 h and 24 h and its volume; an item's page buys or sells 1, 16 or 64, sells all you carry, and gives its price chart as a map |
|
||||
| `/worth [item]` | an item's price, the same to buy and to sell (the one in your hand without a name), and what you may still sell today |
|
||||
| `/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) |
|
||||
@@ -25,12 +36,131 @@ This version: the economy, the server market, player trades, the coinflip and th
|
||||
| `/trade cancel` | ends your trade, or withdraws your request |
|
||||
| `/coinflip <amount> [player]` | stakes an amount for anyone (or one player) to match; off unless `gambling.enabled` |
|
||||
| `/lottery`, `/lottery buy <tickets>` | the pot, your tickets and the time to the draw; buys tickets (off unless `gambling.enabled`) |
|
||||
| `/casino`, `/casino roulette\|slots\|crash\|blackjack` | the casino's lobby menu, or a game's table (needs `gambling.enabled` and `casino.enabled`) |
|
||||
| `/casino help`, `/casino top`, `/casino stats` | every game's odds and house edge and the limits; today's biggest wins; what you bet, won and may still lose today |
|
||||
| `/coinflip accept <player>`, `/coinflip cancel`, `/coinflip list` | takes an offer (a fair coin decides, the winner gets both stakes), withdraws yours, lists those waiting |
|
||||
| `/spawn` | to the server's spawn; `/setspawn` (operators of level 3) sets it where you stand |
|
||||
| `/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` | operators of level 3 (with `ops_only`, the default): another name for you in chat and the Tab list, shown as `~name` (hovering it gives your real one); `/nick off` (anyone with a nickname) takes it off; `/nick` alone says how |
|
||||
| `/nick <player> <name\|off>` | operators of level 3: sets or takes off a player's nickname |
|
||||
| `/realname <nick>` | who goes by that nickname |
|
||||
| `/invsee <player>`, `/endersee <player>` | operators of level 3: the player's inventory (main, hotbar, armour, off hand) or ender chest in a chest menu, on or off the server; click one of theirs to take it, one of yours to give it; every move in `audit.log`, a summary in `staff.log` |
|
||||
| `/sidebar off`, `/sidebar on`, `/sidebar` | hides the scoreboard sidebar for you (remembered), shows it again |
|
||||
|
||||
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:
|
||||
- One price per item, to buy and to sell (since 0.29): a config that still has two (`buy` 400, `sell` 100) gets
|
||||
their geometric mean (200) at load, logged ("one price now"); `buy` or `sell` 0 still closes that side. A trade
|
||||
of N items is priced along the curve it moves the price on (below), so buying N and selling them back gives back
|
||||
exactly what was paid (less whole-coin rounding): a round trip never earns (tested for 1, 64 and past the minute's band, and
|
||||
over thousands of random trades with the drift in between).
|
||||
- No chain of recipes turns what the market sells into something it buys for more: the cheapest way to get each item
|
||||
from the market (bought, or crafted, smelted, cut from bought items, through any number of recipes) is worked out,
|
||||
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.
|
||||
- Items a recipe links (an ingot and its block, wheat and hay...) share one moving price, the block moving it as
|
||||
its 9 ingots do, so crafting never beats the market however prices move; the vanilla-recipe test checks every item
|
||||
at the worst drift (what makes it at 0.6 times its price, it at 1.6) too.
|
||||
- Each player sells at most `daily_sell_coins` (2,000 coins) and `daily_sell_items` (640 of each item) a UTC day,
|
||||
`/worth` and the shop show what is left, and `market.json` keeps the day's count across restarts.
|
||||
- Money received with `/pay` counts toward the receiver's same `daily_sell_coins`: selling up to the limit on alts
|
||||
and paying it all to a main is refused once the main's limit is reached ("can only receive N more today").
|
||||
Trades (`/trade`) and auction house sales are not counted.
|
||||
- Amounts are checked: no price above 1,000,000,000,000, no sum that overflows.
|
||||
- The coinflip and the lottery give the winner all the stakes: they move money between players and never make any.
|
||||
|
||||
Default prices (coins; "buy only": the market sells it but doesn't buy it, "sell only" the other way):
|
||||
|
||||
| Category | Items |
|
||||
|---|---|
|
||||
| Farming | wheat 2, carrot 2, potato 2, beetroot 2, sugar cane 2, pumpkin 5, melon slice 1, cactus 2; buy only: wheat seeds 2, bone meal 3 |
|
||||
| Mining | coal 4, copper ingot 3, iron ingot 15, gold ingot 20, redstone 2, lapis 4, quartz 4, emerald 40, diamond 200 |
|
||||
| Wood | the eight logs 2 |
|
||||
| Mob drops | bone 2, string 2, gunpowder 4, spider eye 2, slime ball 4, blaze rod 15, leather 3, feather 1; sell only: rotten flesh 1 |
|
||||
| Building (buy only) | cobblestone 1, stone 2, dirt 1, sand 2, gravel 2, glass 3, bricks 4, stone bricks 3 |
|
||||
| Food (buy only) | bread 5, cooked beef 6, cooked porkchop 6, cooked chicken 5, baked potato 4, apple 4, golden carrot 40 |
|
||||
| Tools (buy only) | 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.
|
||||
|
||||
**/invsee and /endersee.** For operators of level 3 (luzW, bunchy7s...). The menu shows the main inventory in its
|
||||
first three rows, the hotbar in the fourth, then the armour (head to feet) and the off hand; the ender chest fills
|
||||
three rows. A click on one of the player's stacks moves it whole into the operator's inventory; a click on one of the
|
||||
operator's own moves it into the player's (main inventory or ender chest, where it fits whole). A stack moves only if
|
||||
it is still exactly what the menu showed: the game checks it and moves it, or nothing (no copy, no loss). A player who
|
||||
isn't on is read from and written to their saved file; while they log in or are dead nothing moves and the menu says
|
||||
why. The menu is read again once a second while open; nothing runs when none is open. Each move is in `audit.log`
|
||||
before it is made (`invsee_take`, `invsee_put`: operator, player, inventory, item, count; a refused one again with
|
||||
its reason); closing the menu writes one line to the server log (`[smp] staff: ...`), `staff.log`, and the chat of
|
||||
the other operators on. Needs horizon#6106 (`native_inventory`, `native_move_stack`).
|
||||
|
||||
**Nicknames.** With `ops_only` (the default since 0.22) only operators of level 3 set nicknames: their own, or a
|
||||
player's with `/nick <player> <name>`; a player may take off the one given to them (`/nick off`). At each start, a
|
||||
nickname a player who isn't an operator picked themselves is taken off and logged (`for operators only`). 3 to 16 letters, digits or `_`, shown with a `~` in front (`~Rocky`) so a nickname never passes for
|
||||
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`, `ops_only` (true), `cooldown_seconds` (600), `min_playtime_minutes` (60), `protected` (the Team's
|
||||
names), `reserved` (the words above).
|
||||
|
||||
**Moving prices** (`market.dynamic`, on by default since 0.26). Each item has one price: its base price (`items`)
|
||||
times a factor that supply and demand move as each trade happens. Every item bought raises the log of the factor by
|
||||
`1% / depth`, every item sold lowers it as much, where the depth follows the item's rarity (400 / its price, between
|
||||
2 and 400: a diamond moves 50 times faster per item than cobblestone; `depth` overrides it per item). A trade of N is
|
||||
worth the area under that curve (`price / step × (e^(N × step) - 1)`), rounded up to whole coins when buying and down
|
||||
when selling: one diamond at 200 costs 201 and sells for 199, 64 cobblestone at 1 cost 65, and a sale walks back the
|
||||
exact path of the purchase. All trades together move an item at most `max_minute_move_percent` (5%) a minute from
|
||||
where it started that minute: past that, a purchase is cut short and a sale keeps the rest ("sell them in a
|
||||
minute"). The factor goes back to 1 with a half-life of `half_life_hours` (6) and stays within `min_factor` (0.6)
|
||||
and `max_factor` (1.6), where the rest of a trade goes at that price. There is no tax on trades (an older config's
|
||||
`tax_percent` is ignored): the receipt is the price ("Sold 3 diamond at 198 coins each: 595 coins."). Items a player bought from the market in the last `round_trip_hours` (24) can't be
|
||||
sold back to it. (`max_trade_impact_percent` and `max_player_impact_percent` are no longer used: the curve and the
|
||||
minute's band do their work.) History: one point a minute for 24 hours and one an hour for 30 days (the mid price, the
|
||||
items traded and those sold), in `market_prices.json`, written by a thread every 5 minutes and when the world closes.
|
||||
At the first minute of each UTC day, the day's three biggest risers and fallers (at least 1%) and news from real
|
||||
activity ("Diamond supply up 40% today", sales against the day before's, at least `news_percent` and
|
||||
`news_min_volume`) go to everyone's chat and to `web/market.json` for Kira to post. `enabled: false` keeps the
|
||||
fixed prices of 0.23. For other mods: `Market::spot`, `volume(id, now, hours)`, `history(id, Span)`.
|
||||
|
||||
**`/market`.** A 6-row chest menu: the shop's categories and the day's movers; a category's items, each with its
|
||||
price, its change over 1 h and 24 h (green ▲, red ▼) and what was traded in 24 h; an item's page with its
|
||||
base price and what you may still sell today, buttons to buy or sell 1, 16 and 64 (what they come to
|
||||
along the curve), sell all of it you carry, and the price chart over 24 hours or 7 days, given as a filled map (128 × 128,
|
||||
drawn by the server: the curve green when it rose, red when it fell, the price, the change, the low and high). A
|
||||
chart map is drawn again each minute for whoever carries it, and once a minute the open menus follow the prices.
|
||||
`/market <item>` opens the item's page. `/worth` adds the item's moves and volume.
|
||||
|
||||
**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
|
||||
@@ -42,16 +172,129 @@ the trade, and swaps everything in one step, or nothing (and both must confirm a
|
||||
adventure players only.
|
||||
|
||||
**Coinflips.** Off by default: the owner turns them on in `config.json` (`gambling`: `enabled`, `min_bet` 10,
|
||||
`max_bet` 1000, `cooldown_seconds` 30, `offer_seconds` 60, `fee_percent` 0). A stake is held from the offer, in
|
||||
`max_bet` 1000, `cooldown_seconds` 30, `offer_seconds` 60). A stake is held from the offer, in
|
||||
`economy.json` with the balances, until the coin falls; an offer cancelled, not taken in time, or whose player
|
||||
left gives it back, and so does the next start for one the server stopped with. The coin is a fresh random key from
|
||||
the system, never the world's random. Only the in-game currency is staked and won.
|
||||
|
||||
**The lottery.** Part of `gambling` (`ticket_price` 10, `max_tickets` 100 a player a draw, `lottery_seconds` 3600
|
||||
from a round's first ticket to its draw). Tickets are held like coinflip stakes; at the draw one ticket is picked at
|
||||
random among all sold (more tickets, more chances), its owner gets the pot (less `fee_percent`), and everyone hears
|
||||
random among all sold (more tickets, more chances), its owner gets the whole pot, and everyone hears
|
||||
it. A round with a single player, or one the server stopped with, gives every ticket back.
|
||||
|
||||
**The casino.** On with `gambling.enabled` (and `casino.enabled`, true by default). Every game is a chest menu any
|
||||
stock client can play, animated by changing its items at most 5 times a second for each open menu (and only while a
|
||||
round moves), with note-block sounds, titles and the action bar for results, fireworks and a line for everyone on a
|
||||
jackpot:
|
||||
|
||||
- European roulette: one zero, red/black, odd/even, 1-18/19-36 pay 1 to 1, dozens and columns 2 to 1, a number 35 to
|
||||
1; house edge 2.70% on every bet. The wheel turns and slows down on the drawn pocket.
|
||||
- Slots: 3 reels of 32 stops with item symbols, one payline, a published paytable (three Nether Stars 500x down to
|
||||
three Berries 6x, two Berries 2x); 94.2% paid back (house edge 5.8%), one pull in 4.5 pays.
|
||||
- Crash: one shared round; bet in the 10 s before take-off, then cash out (or set an automatic cash-out) before it
|
||||
crashes. P(crash at x or more) = 96% / x, capped at 100x: 96% paid back whatever you do. Each player in the round
|
||||
sees the multiplier in their own boss bar and the action bar.
|
||||
- Blackjack: 6 decks shuffled each hand, the dealer stands on all 17s and peeks, blackjack pays 3 to 2, double on the
|
||||
first two cards, no split; about 1% house edge with basic strategy.
|
||||
|
||||
Every draw is made by the server (ChaCha20 keyed from the system) before the animation starts. Bets go from `min_bet`
|
||||
10 to `max_bet` 1000; a player may lose at most `daily_loss_cap` (10,000) a day (UTC, all games together) and win at most
|
||||
`daily_win_cap` (10,000) net (once reached, no new bet until 00:00 UTC; the round that crosses it is paid in full), and
|
||||
`cooldown_ms` (2000) passes between the end of a round and their next bet. A bet is held like a coinflip stake;
|
||||
closing the menu or leaving never loses a round: a spin or a pull ends and is paid, a blackjack hand stands, a crash
|
||||
bet is cashed out when its player leaves in flight (or given back before take-off). Wins are paid from the casino's
|
||||
pot (`economy.json`'s pool `casino`): a win larger than the pot is paid up to it. A new casino's pot gets `float`
|
||||
(20,000) once, made for it (`casino_float`; an older pot only what it lacks of it), then keeps every lost bet: the
|
||||
house edge stays in the pot, out of the players' hands. A win of `jackpot_multiple` (50) times the stake, or of `announce_profit` (5000) coins, is
|
||||
told to everyone. Settings in `config.json`'s `casino`, also `crash_edge_percent` (4) and `crash_max` (100).
|
||||
|
||||
**Places.** A teleport waits `warmup_seconds` (3) in `config.json`'s `places`; a player who moves or is hurt meanwhile
|
||||
stays where they are, and `cooldown_seconds` (10) passes between two of one player's teleports. Teleports are free (an older config's
|
||||
`places.fees` is ignored). `/tpa` takes the
|
||||
asker to where the other player is when the warmup ends. Homes grow with the time played on the server since the mod
|
||||
was on (`homes`: `[hours, homes]`, by default 1 home, 2 from 5 hours, 3 from 20, 5 from 100). `rules` holds `/rules`'
|
||||
lines. Teleports are vanilla's `tp`, run as the server (`execute in <level> run tp`), so they cross levels and land
|
||||
as any teleport does.
|
||||
|
||||
**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. `claims.free_radius` (0, none) keeps the land within that many blocks of 0 0, in every dimension, free
|
||||
for all: no one claims a chunk that touches it, and claims already there are dropped at start (each logged). The world stays out too: no water or lava flows, no fire spreads or burns, and no piston pushes into
|
||||
a claim from land that isn't its owner's (or a player's they trust), and explosions break no claimed block, whoever
|
||||
lit them. Buckets are guarded where they fill or empty, and hitting the claim's item frames, armour stands,
|
||||
paintings, boats, minecarts and animals does nothing for others (`claims.protected_entities`, entity ids; monsters
|
||||
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) after
|
||||
`presence.reward_after_minutes` (10) of play that day, logged as `daily_reward` (0 turns it off). Only seconds with a
|
||||
move, a turn or a command in the minute before count, so an alt that logs in and stands still gets nothing (on
|
||||
2026-11-01, 1,242 players got the reward; 323 of them had played 10 minutes in all). The count isn't kept over a
|
||||
restart. 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 auction house.** Players sell to players: a listing is a fixed price or a timed auction. The listed items
|
||||
leave the seller's inventory at once; what doesn't sell, what an auction's winner gets, and what didn't fit in a
|
||||
buyer's inventory wait in the player's mail (`/ah claim`, or the menu's mail page; as much as fits, the rest stays).
|
||||
A purchase takes the whole listing: the listing leaves first, the items move, then the money (the seller gets the
|
||||
whole price); of two players who buy at once (the game runs every click on one
|
||||
thread), the second finds it gone. Bids wait in the `auction` pool: a higher bid gives the last one back at once,
|
||||
raising one's own only holds the difference, and a bid in the last `snipe_seconds` (60) moves the end back to
|
||||
60 seconds away. When an auction ends, the seller is paid the winning bid and the items go to the winner's mail. Sellers
|
||||
and outbid players who are offline find a note when they come back. Everything (listings, bids, mail, notes) is
|
||||
written in `economy.json` with the balances, in one step: a crash can't keep the money and lose the items, or the
|
||||
reverse. A listing carries the whole stack (since 0.31): its enchantments, name, damage, lore, a shulker box's
|
||||
or a bundle's contents and every other component, written as the game writes it in player files, and given back
|
||||
exactly so (Horizon's `native_give_stack`) to the buyer, the winner, or the seller when it comes back. `/ah sell
|
||||
<price> <count>` lists part of the stack in hand; the rest stays in the hand, the same. The menu shows the stack
|
||||
itself (its tooltip), with the listing's price, seller and time under it. A stack is taken only if it would come
|
||||
back the same (its data written and read back equal); not market charts (free at `/market`), nor a stack holding
|
||||
more than 32 KiB of data (a box of books). Listings and mail from before 0.31 hold plain items and are given back as
|
||||
before. Survival and adventure players only. `config.json`'s `auction`:
|
||||
`enabled`, `max_listings` (8 a player), `listing_hours` (48), `auction_hours` (12),
|
||||
`auction_hours_max` (24), `bid_step_percent` (5: the smallest raise), `snipe_seconds` (60). Ended listings are
|
||||
looked at once a second (a pass over the open listings).
|
||||
|
||||
**Money in and out.** Money enters the game through what the market buys from players, the daily reward, the
|
||||
casino's float (once) and operators' `/eco give` (with a reason everyone reads, logged as `eco_give`). It leaves
|
||||
through what the market sells and operators' `/eco take`. There are no fees, taxes, bank, contracts or futures: no
|
||||
VAT, land or wealth tax, no tax on market trades, no teleport, auction or coinflip fee. A world from before 0.30 is
|
||||
cleaned up at its first start: bank savings and the margins of open futures positions go back to their owners
|
||||
(`bank_closed`, `futures_closed`), and the Treasury, its insurance fund, contracts and land tax owed are dropped.
|
||||
`config.json`'s old `taxes`, `bank`, `jobs` and `futures` sections and the `fee_percent` and `listing_fee_percent`
|
||||
keys are ignored.
|
||||
|
||||
**The report.** `/eco stats` (anyone) shows the money supply (wallets and money held, each pool, the casino's pot),
|
||||
what the server made today (market sales, the daily reward, `/eco give`) and destroyed (market purchases, operators'
|
||||
`/eco take`), and how evenly money is spread (Gini over players
|
||||
with money, the richest ten's share). At 00:00 UTC everyone reads the day before in one line (`report.daily_report`).
|
||||
A snapshot of each day is kept 60 days (`report.history_days`) in `economy.json` (`ext.report`). Once a minute
|
||||
`world/horizon-smp/web/economy.json` (`report.web_json`) is written off the tick thread for the site's /hub:
|
||||
`{"schema": 1, "generated": <ms>, "currency", "today", "pools", "minted_today", "burned_today", "days": [...],
|
||||
"daily_report": "<yesterday's line>"}`. The server's Discord relay posts only chat, joins, leaves and deaths: the
|
||||
daily line reaches Discord through whoever reads that file (Kira).
|
||||
|
||||
Other parts reach the pools through `Economy::to_pool`, `from_pool`, `pool_move` and `settle_pool` (a bet against a
|
||||
pool: the stake goes in, the payout comes out, never more than the pool holds), each one audit line.
|
||||
|
||||
The currency is earned in game only: it can't be bought with real money or cashed out (Mojang's usage guidelines).
|
||||
|
||||
## Data
|
||||
@@ -61,9 +304,19 @@ 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) |
|
||||
| `economy.json` | balances by player UUID, with the name last seen, and `held`: coinflip stakes and lottery tickets being decided |
|
||||
| `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 |
|
||||
| `config.json` | `currency` (default `coins`), `starting_balance` (default 0), `market`: `enabled`, `items` (each item's `buy`, what the market asks, and `sell`, what it pays, 0 closing that side), `daily_sell_coins` (2000) and `daily_sell_items` (640), 0 for no limit; `shop`: `enabled`, `title`, `categories` (`name`, `icon`, `items`); `web`: `url` (Kira's `/smp/ingest`) and `token` (empty: nothing is sent), where the pages of `web/` go; written with the defaults the first time, and again when a newer version adds keys (your values kept) |
|
||||
| `economy.json` | balances by player UUID, with the name last seen; `held`: coinflip stakes and lottery tickets being decided; `pools`: the casino's pot and the auction house's bids; `ext`: other parts' state written in the same step as the money (`auction`: the listings, bids, mail and notes) |
|
||||
| `places.json` | the spawn, the warps, each player's homes, and the seconds each player has played (written once a minute) |
|
||||
| `nicks.json` | each player's nickname and when they last changed it |
|
||||
| `staff.log` | one line per `/invsee` or `/endersee` menu in which something moved: who, whose, what was taken and put |
|
||||
| `market.json` | what each player sold today (coins and items), for the daily limits |
|
||||
| `market_prices.json` | each item's price factor and history (minutes for 24 h, hours for 30 days: `[time, mid, traded, sold]`), what each player bought in the last day (no selling it back), the day's summary |
|
||||
| `web/market.json` | for the website (`/hub/market`, through Kira), written by a thread each minute prices move: `schema` 1, `generated`, `currency`, `tax_percent` (always 0, for older readers), `daily_summary` (`day`, `lines`), `items` (`id`, `name`, `category`, `price` (the one price; `buy` and `sell` repeat it on the sides the market trades, for older readers), `mid`, `base`, `change_1h`, `change_24h`, `volume_24h`, `day`: 5-minute points, `month`: hours, each `{step, t, price, volume}`). When `web.token` is set, the same thread POSTs each page of `web/` that changed (gzipped, `Authorization: Bearer <token>`) to `<web.url>/<name>`: Kira keeps the last one and serves it to the site; the server needs no inbound access and Kira no key to it |
|
||||
| `presence.json` | the players who have joined (no welcome again), and the day each last got the daily reward |
|
||||
| `board.json` | the players who hid the sidebar (`/sidebar off`) |
|
||||
| `casino.json` | the day's ledger: each player's bets, wins and rounds today (the daily loss cap), the day's biggest wins, each game's totals, the pot's last sweep |
|
||||
| `claims.json` | each claimed chunk (`<dimension> <chunk x> <chunk z>`) with its owner, and whom each owner trusts |
|
||||
| `audit.log` | one JSON line per change, written before the change: `time` (ms), `kind` (`open`, `pay`, `grant`, `ah_listing_fee`...), `amount`, `from`/`to` with their balance before, `by` for an operator, `to_pool`/`from_pool` with `pool_before` and `reason` for a pool's movement; a trade's line (`trade`: both players, their items and money) comes before its swap, `trade_failed` after one that didn't go through |
|
||||
|
||||
`economy.json` is written whole to a temporary file, flushed and renamed over the old one, at most once a server tick
|
||||
and when the world closes: a crash leaves the old file or the new one. If it can't be read, the economy stays off (the
|
||||
@@ -72,16 +325,23 @@ 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: twenty-seven modules and the HEAD hooks |
|
||||
| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs`, `smp_recipes.rs`, `smp_shop.rs`, `smp_nick.rs`, `smp_trade.rs`, `smp_games.rs`, `smp_lottery.rs`, `smp_places.rs`, `smp_claims.rs`, `smp_board.rs`, `smp_presence.rs`, `smp_auction.rs`, `smp_report.rs`, `smp_market_prices.rs`, `smp_market_menu.rs`, `smp_market_chart.rs`, `smp_invsee.rs` | The rules, no game types (a trade's stacks are a type parameter): added to the game as `game::smp_store`, `game::smp_economy`, `game::smp_market`, `game::smp_recipes`, `game::smp_shop`, `game::smp_nick`, `game::smp_trade`, `game::smp_games`, `game::smp_lottery`, `game::smp_places`, `game::smp_claims`, `game::smp_board`, `game::smp_presence`, `game::smp_auction`, `game::smp_report`, `game::smp_market_prices`, `game::smp_market_menu`, `game::smp_market_chart`, `game::smp_invsee`; also this crate's modules (`src/lib.rs`), for their tests |
|
||||
| `mixins/smp/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 |
|
||||
| `mixins/smp/smp_casino.rs`, `smp_casino_roulette.rs`, `smp_casino_slots.rs`, `smp_casino_crash.rs`, `smp_casino_blackjack.rs` | The casino's rules, menus and effects (`game::smp_casino*`) |
|
||||
| `mixins/smp/casino_glue.rs` | The casino's effects for a stock client: menu items, sounds, titles, fireworks, boss bars (`game::smp_casino_glue`) |
|
||||
| `proof.py` | A dedicated server built with the mod and two real clients, on fresh temporary data |
|
||||
| `proof_casino.py` | The casino's proof (`SMP_PROOF=casino`): both clients play every game through the menus |
|
||||
|
||||
## 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": "975c07d9151847e6af13afc4b5ab37b6bbe78ef5",
|
||||
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
|
||||
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
|
||||
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
|
||||
}
|
||||
@@ -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,333 @@
|
||||
// The casino in the server's game: its menus, and what its effects become
|
||||
// for a stock client (smp_casino's `Fx`): menu items, sounds and titles
|
||||
// through the console's `playsound` and `title`, the action bar, fireworks
|
||||
// and particles over a winner, a line for everyone. glue.rs calls it from the
|
||||
// command, menu and tick hooks with the SMP's state already locked; the
|
||||
// console commands it builds are run by glue.rs once that lock is let go.
|
||||
// Added to the game as `game::smp_casino_glue`.
|
||||
|
||||
use crate::game::{
|
||||
Game,
|
||||
native_server::NativeServerPlayer,
|
||||
smp_casino::{self, Casino, Color, Fx, Icon},
|
||||
smp_economy::{Economy, Out, Player},
|
||||
};
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
lan::data::component_nbt,
|
||||
text::{self, Text},
|
||||
};
|
||||
|
||||
/// The crash's boss bars: one a player (a bar's players can't be a list of
|
||||
/// names, and tags don't reach a server's players), `smp:casino_crash_<i>`
|
||||
/// for the name in `bars[i]`, and the colour they all have.
|
||||
static BARS: std::sync::Mutex<(Vec<Option<String>>, &str)> =
|
||||
std::sync::Mutex::new((Vec::new(), "yellow"));
|
||||
/// The most bars at once; each taken off at the first hide of a run,
|
||||
/// whatever it held (a run before may have left one).
|
||||
const MAX_BARS: usize = 32;
|
||||
static CLEARED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
fn bar_id(i: usize) -> String {
|
||||
format!("smp:casino_crash_{i}")
|
||||
}
|
||||
|
||||
fn color(c: Color) -> u32 {
|
||||
match c {
|
||||
Color::White => text::WHITE,
|
||||
Color::Gray => text::GRAY,
|
||||
Color::DarkGray => text::DARK_GRAY,
|
||||
Color::Gold => text::GOLD,
|
||||
Color::Yellow => text::YELLOW,
|
||||
Color::Green => text::GREEN,
|
||||
Color::Red => text::RED,
|
||||
Color::Aqua => text::AQUA,
|
||||
}
|
||||
}
|
||||
|
||||
fn stack(icon: Icon) -> ItemStack {
|
||||
let mut stack = ItemStack {
|
||||
item: icon.item.into(),
|
||||
count: u32::from(icon.count.clamp(1, 99)),
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
let title =
|
||||
serde_json::json!({ "text": icon.name, "italic": false, "color": icon.color.name() });
|
||||
stack.data_mut().custom_name = Some(component_nbt(&title));
|
||||
stack
|
||||
}
|
||||
|
||||
fn items(casino: &Casino, economy: &Economy, uuid: u128, now: i64) -> Vec<Option<ItemStack>> {
|
||||
casino
|
||||
.layout(economy, uuid, now)
|
||||
.into_iter()
|
||||
.map(|c| c.map(stack))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Opens (again) the player's casino menu on its view: a new window, titled
|
||||
/// for the game. False when it couldn't open.
|
||||
fn open(game: &mut Game<'_>, casino: &mut Casino, economy: &Economy, uuid: u128, now: i64) -> bool {
|
||||
let Some(view) = casino.seats.get(&uuid).map(|s| s.view) else {
|
||||
return false;
|
||||
};
|
||||
let items = items(casino, economy, uuid, now);
|
||||
let title = Text::literal(view.title()).with_color(text::DARK_GRAY);
|
||||
match game.native_open_menu(uuid, smp_casino::ROWS, title, items) {
|
||||
Some(w) => {
|
||||
if let Some(s) = casino.seats.get_mut(&uuid) {
|
||||
s.window = Some(w);
|
||||
}
|
||||
true
|
||||
}
|
||||
None => {
|
||||
casino.seats.remove(&uuid);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `/casino ...` (inside the player's command).
|
||||
pub(in crate::game) fn command(
|
||||
game: &mut Game<'_>,
|
||||
casino: &mut Casino,
|
||||
economy: &mut Economy,
|
||||
me: &Player,
|
||||
now: i64,
|
||||
line: &str,
|
||||
) -> Vec<Out> {
|
||||
let Some((mut out, view)) = casino.command(economy, me, now, line) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if let Some(view) = view {
|
||||
casino.sit(me.uuid, &me.name, view);
|
||||
if !open(game, casino, economy, me.uuid, now) {
|
||||
out.push(Out::Error("The casino couldn't open.".into()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A click in a player's casino menu: the console commands its effects need,
|
||||
/// for after the SMP's lock.
|
||||
pub(in crate::game) fn clicked(
|
||||
game: &mut Game<'_>,
|
||||
casino: &mut Casino,
|
||||
economy: &mut Economy,
|
||||
uuid: u128,
|
||||
slot: i32,
|
||||
shift: bool,
|
||||
) -> Vec<String> {
|
||||
let Ok(slot) = usize::try_from(slot) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if slot >= smp_casino::SIZE {
|
||||
return Vec::new();
|
||||
}
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
economy.now = now;
|
||||
let fx = casino.click(economy, uuid, slot, shift, now);
|
||||
apply(game, casino, economy, fx, now)
|
||||
}
|
||||
|
||||
/// The casino's share of the server's tick: frames, rounds that end, the
|
||||
/// pot's float (once). The console commands, for after the lock.
|
||||
pub(in crate::game) fn tick(
|
||||
game: &mut Game<'_>,
|
||||
casino: &mut Casino,
|
||||
economy: &mut Economy,
|
||||
now: i64,
|
||||
online: Option<&[u128]>,
|
||||
) -> Vec<String> {
|
||||
let mut fx = casino.tick(economy, now);
|
||||
// Once a second: crash bets of players who left.
|
||||
if let Some(online) = online {
|
||||
fx.extend(casino.gone(economy, online, now));
|
||||
}
|
||||
if fx.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
apply(game, casino, economy, fx, now)
|
||||
}
|
||||
|
||||
/// Carries out `fx`: menus and lines now, console commands returned.
|
||||
fn apply(
|
||||
game: &mut Game<'_>,
|
||||
casino: &mut Casino,
|
||||
economy: &Economy,
|
||||
fx: Vec<Fx>,
|
||||
now: i64,
|
||||
) -> Vec<String> {
|
||||
let mut commands = Vec::new();
|
||||
let mut players: Option<Vec<NativeServerPlayer>> = None;
|
||||
let mut shown = Vec::new();
|
||||
for f in fx {
|
||||
// Where a player is, asked once for the whole batch.
|
||||
let mut find = |uuid: u128, game: &Game<'_>| -> Option<NativeServerPlayer> {
|
||||
players
|
||||
.get_or_insert_with(|| game.native_server_players())
|
||||
.iter()
|
||||
.find(|p| p.uuid == uuid)
|
||||
.cloned()
|
||||
};
|
||||
match f {
|
||||
Fx::Out(Out::Tell(uuid, s)) => {
|
||||
game.native_tell(uuid, Text::literal(s).with_color(text::GOLD));
|
||||
}
|
||||
Fx::Out(Out::All(s)) => game.native_broadcast(Text::literal(s).with_color(text::GOLD)),
|
||||
Fx::Out(Out::Reply(s) | Out::Error(s)) => log::info!("[smp] casino: {s}"),
|
||||
Fx::Sound {
|
||||
to,
|
||||
sound,
|
||||
volume,
|
||||
pitch,
|
||||
} => {
|
||||
if let Some(p) = find(to, game) {
|
||||
let [x, y, z] = p.position;
|
||||
commands.push(format!(
|
||||
"execute in {} run playsound {sound} master {} {x:.2} {y:.2} {z:.2} {volume} {pitch}",
|
||||
p.dimension, p.name
|
||||
));
|
||||
}
|
||||
}
|
||||
Fx::Title {
|
||||
to,
|
||||
title,
|
||||
subtitle,
|
||||
color: c,
|
||||
} => {
|
||||
if let Some(p) = find(to, game) {
|
||||
let line = |s: &str, bold: bool| {
|
||||
serde_json::json!({ "text": s, "color": c.name(), "bold": bold })
|
||||
.to_string()
|
||||
};
|
||||
commands.push(format!("title {} times 5 40 15", p.name));
|
||||
commands.push(format!(
|
||||
"title {} subtitle {}",
|
||||
p.name,
|
||||
line(&subtitle, false)
|
||||
));
|
||||
commands.push(format!("title {} title {}", p.name, line(&title, true)));
|
||||
}
|
||||
}
|
||||
Fx::Bar {
|
||||
to,
|
||||
text: s,
|
||||
color: c,
|
||||
} => {
|
||||
game.native_action_bar(to, Text::literal(s).with_color(color(c)));
|
||||
}
|
||||
Fx::Fireworks(to) => {
|
||||
if let Some(p) = find(to, game) {
|
||||
let [x, y, z] = p.position;
|
||||
// High enough over the player that their blast (5 blocks) never hurts.
|
||||
for (dx, dz, colours) in [
|
||||
(-3.0, 0.0, "16766720,16733525"),
|
||||
(3.0, 1.5, "5635925,16777045"),
|
||||
(0.0, -3.0, "5636095,16733695"),
|
||||
] {
|
||||
commands.push(format!(
|
||||
"execute in {} run summon minecraft:firework_rocket {:.2} {:.2} {:.2} {{LifeTime:18,FireworksItem:{{id:\"minecraft:firework_rocket\",count:1,components:{{\"minecraft:fireworks\":{{flight_duration:1b,explosions:[{{shape:\"large_ball\",colors:[I;{colours}],has_trail:true,has_twinkle:true}}]}}}}}}}}",
|
||||
p.dimension,
|
||||
x + dx,
|
||||
y + 9.0,
|
||||
z + dz
|
||||
));
|
||||
}
|
||||
commands.push(format!(
|
||||
"execute in {} run particle minecraft:totem_of_undying {x:.2} {:.2} {z:.2} 0.6 1 0.6 0.4 60 normal",
|
||||
p.dimension,
|
||||
y + 1.0
|
||||
));
|
||||
}
|
||||
}
|
||||
Fx::Announce(s) => {
|
||||
log::info!("[smp] casino: {s}");
|
||||
game.native_broadcast(
|
||||
Text::literal(format!("[Casino] {s}")).with_color(text::GOLD),
|
||||
);
|
||||
}
|
||||
Fx::Show(uuid) => {
|
||||
// One picture per menu per batch.
|
||||
if !shown.contains(&uuid)
|
||||
&& casino.seats.get(&uuid).is_some_and(|s| s.window.is_some())
|
||||
{
|
||||
shown.push(uuid);
|
||||
let items = items(casino, economy, uuid, now);
|
||||
game.native_set_menu_items(uuid, items);
|
||||
}
|
||||
}
|
||||
Fx::Open(uuid) => {
|
||||
shown.push(uuid);
|
||||
open(game, casino, economy, uuid, now);
|
||||
}
|
||||
Fx::Boss {
|
||||
text: line,
|
||||
color: c,
|
||||
value,
|
||||
} => {
|
||||
let mut bars = BARS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(c) = c {
|
||||
bars.1 = match c {
|
||||
Color::Green => "green",
|
||||
Color::Red => "red",
|
||||
_ => "yellow",
|
||||
};
|
||||
}
|
||||
let name = serde_json::json!({ "text": line, "color": "white" });
|
||||
for (i, _) in bars.0.iter().enumerate().filter(|(_, b)| b.is_some()) {
|
||||
let id = bar_id(i);
|
||||
commands.push(format!("bossbar set {id} name {name}"));
|
||||
commands.push(format!("bossbar set {id} value {value}"));
|
||||
if c.is_some() {
|
||||
commands.push(format!("bossbar set {id} color {}", bars.1));
|
||||
}
|
||||
}
|
||||
}
|
||||
Fx::BossPlayers(names) => {
|
||||
let mut bars = BARS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let colour = bars.1;
|
||||
for (i, bar) in bars.0.iter_mut().enumerate() {
|
||||
if bar.as_ref().is_some_and(|n| !names.contains(n)) {
|
||||
*bar = None;
|
||||
commands.push(format!("bossbar remove {}", bar_id(i)));
|
||||
}
|
||||
}
|
||||
for name in names {
|
||||
if bars.0.iter().any(|b| b.as_ref() == Some(&name)) {
|
||||
continue;
|
||||
}
|
||||
let i = match bars.0.iter().position(Option::is_none) {
|
||||
Some(i) => i,
|
||||
None if bars.0.len() < MAX_BARS => {
|
||||
bars.0.push(None);
|
||||
bars.0.len() - 1
|
||||
}
|
||||
None => continue,
|
||||
};
|
||||
let id = bar_id(i);
|
||||
commands.push(format!("bossbar add {id} \"\""));
|
||||
commands.push(format!("bossbar set {id} max 100"));
|
||||
commands.push(format!("bossbar set {id} color {colour}"));
|
||||
commands.push(format!("bossbar set {id} players {name}"));
|
||||
bars.0[i] = Some(name);
|
||||
}
|
||||
}
|
||||
Fx::BossHide => {
|
||||
// Every bar taken off.
|
||||
let mut bars = BARS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let all = !CLEARED.swap(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let n = if all { MAX_BARS } else { bars.0.len() };
|
||||
for i in 0..n {
|
||||
if all || bars.0[i].is_some() {
|
||||
commands.push(format!("bossbar remove {}", bar_id(i)));
|
||||
}
|
||||
}
|
||||
bars.0.clear();
|
||||
bars.1 = "yellow";
|
||||
}
|
||||
}
|
||||
}
|
||||
commands
|
||||
}
|
||||
@@ -5,6 +5,9 @@ fn smp_perform_command(
|
||||
command: &str,
|
||||
ran: &mut bool,
|
||||
) {
|
||||
if !*ran {
|
||||
crate::game::smp_invsee_glue::perform(game, player, command, ran);
|
||||
}
|
||||
if !*ran {
|
||||
crate::game::smp_glue::perform(game, player, command, ran);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+1884
-24
File diff suppressed because it is too large.
Load diff
@@ -1,12 +1,12 @@
|
||||
[mod]
|
||||
id = "smp"
|
||||
version = "0.5.0"
|
||||
version = "0.31.0"
|
||||
priority = 100
|
||||
|
||||
# The commit of horizon main it was tested on. It uses only the native hook points (hooks 10 in
|
||||
# compatibility.json): it builds on any Horizon that has them.
|
||||
# The commit of horizon it was tested on: it needs horizon#6106 (merged at 975c07d): native_inventory,
|
||||
# native_move_stack, native_send_map, lore on the wire.
|
||||
[compatibility]
|
||||
horizon_revision = "f533c3af7d41d7ecc3b015a9fbb0dbf5eb64ae26"
|
||||
horizon_revision = "975c07d9151847e6af13afc4b5ab37b6bbe78ef5"
|
||||
|
||||
[[module]]
|
||||
id = "store"
|
||||
@@ -26,6 +26,48 @@ file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_market"
|
||||
source = "smp_market.rs"
|
||||
|
||||
[[module]]
|
||||
id = "market_prices"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_market_prices"
|
||||
source = "smp_market_prices.rs"
|
||||
|
||||
[[module]]
|
||||
id = "market_chart"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_market_chart"
|
||||
source = "smp_market_chart.rs"
|
||||
|
||||
[[module]]
|
||||
id = "market_menu"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_market_menu"
|
||||
source = "smp_market_menu.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 = "report"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_report"
|
||||
source = "smp_report.rs"
|
||||
|
||||
[[module]]
|
||||
id = "auction"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_auction"
|
||||
source = "smp_auction.rs"
|
||||
|
||||
[[module]]
|
||||
id = "trade"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
@@ -44,6 +86,84 @@ file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_lottery"
|
||||
source = "smp_lottery.rs"
|
||||
|
||||
[[module]]
|
||||
id = "casino"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_casino"
|
||||
source = "smp_casino.rs"
|
||||
|
||||
[[module]]
|
||||
id = "casino_roulette"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_casino_roulette"
|
||||
source = "smp_casino_roulette.rs"
|
||||
|
||||
[[module]]
|
||||
id = "casino_slots"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_casino_slots"
|
||||
source = "smp_casino_slots.rs"
|
||||
|
||||
[[module]]
|
||||
id = "casino_crash"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_casino_crash"
|
||||
source = "smp_casino_crash.rs"
|
||||
|
||||
[[module]]
|
||||
id = "casino_blackjack"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_casino_blackjack"
|
||||
source = "smp_casino_blackjack.rs"
|
||||
|
||||
[[module]]
|
||||
id = "casino_glue"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_casino_glue"
|
||||
source = "casino_glue.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 = "invsee"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_invsee"
|
||||
source = "smp_invsee.rs"
|
||||
|
||||
[[module]]
|
||||
id = "invsee_glue"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_invsee_glue"
|
||||
source = "invsee_glue.rs"
|
||||
|
||||
[[module]]
|
||||
id = "glue"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
@@ -113,3 +233,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,314 @@
|
||||
// `/invsee` and `/endersee` in the server's game (smp_invsee's rules): the
|
||||
// menus open, their clicks and closings, the inventories through the native
|
||||
// helpers (`native_inventory`, `native_move_stack`), each move in the SMP's
|
||||
// audit log, the staff's summary in the server log, `staff.log` and the
|
||||
// chat of the other operators on. Its own state, apart from the SMP's: the
|
||||
// tick costs one atomic load while no menu is open. Added to the game as
|
||||
// `game::smp_invsee_glue`.
|
||||
|
||||
use crate::game::{
|
||||
Game,
|
||||
native_inventories::NativeInventoryKind,
|
||||
native_menus::native_menu_inventory_slot,
|
||||
native_server::{NativeCommand, NativeServerPlayer},
|
||||
smp_economy::{Out, Player},
|
||||
smp_glue,
|
||||
smp_invsee::{self, Cell, Invsee, Kind, Move},
|
||||
smp_store,
|
||||
};
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
lan::data::component_nbt,
|
||||
text::{self, Text},
|
||||
};
|
||||
use std::sync::{
|
||||
Mutex,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering::Relaxed},
|
||||
};
|
||||
|
||||
struct State {
|
||||
invsee: Invsee<ItemStack>,
|
||||
/// Asked in a command, opened at the next tick: (operator, target UUID,
|
||||
/// real name, which).
|
||||
pending: Vec<(u128, u128, String, Kind)>,
|
||||
}
|
||||
|
||||
static STATE: Mutex<Option<State>> = Mutex::new(None);
|
||||
/// A menu open or one to open: the tick has work.
|
||||
static ACTIVE: AtomicBool = AtomicBool::new(false);
|
||||
static TICKS: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn state() -> std::sync::MutexGuard<'static, Option<State>> {
|
||||
let mut s = STATE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
s.get_or_insert_with(|| State {
|
||||
invsee: Invsee::default(),
|
||||
pending: Vec::new(),
|
||||
});
|
||||
s
|
||||
}
|
||||
|
||||
impl smp_invsee::Stack for ItemStack {
|
||||
fn id(&self) -> String {
|
||||
self.item.to_string()
|
||||
}
|
||||
fn count(&self) -> u32 {
|
||||
self.count
|
||||
}
|
||||
}
|
||||
|
||||
fn native(kind: Kind) -> NativeInventoryKind {
|
||||
match kind {
|
||||
Kind::Player => NativeInventoryKind::Player,
|
||||
Kind::Ender => NativeInventoryKind::Ender,
|
||||
}
|
||||
}
|
||||
|
||||
/// The game's inventories, for the rules.
|
||||
struct GameWorld<'a, 'g>(&'a mut Game<'g>);
|
||||
|
||||
impl smp_invsee::World<ItemStack> for GameWorld<'_, '_> {
|
||||
fn read(&self, uuid: u128, kind: Kind) -> Result<(Vec<Option<ItemStack>>, bool), String> {
|
||||
self.0.native_inventory(uuid, native(kind)).map(|i| (i.slots, i.online))
|
||||
}
|
||||
fn move_stack(&mut self, from: (u128, Kind, usize), expected: &ItemStack, to: (u128, Kind)) -> Result<(), String> {
|
||||
self.0.native_move_stack((from.0, native(from.1), from.2), expected, (to.0, native(to.1)))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn commands(commands: &mut Vec<NativeCommand>) {
|
||||
for usage in ["invsee <player:word>", "endersee <player:word>"] {
|
||||
commands.push(NativeCommand::op(usage, smp_invsee::LEVEL));
|
||||
}
|
||||
}
|
||||
|
||||
/// `/invsee` and `/endersee`; anything else is left to the SMP.
|
||||
pub(in crate::game) fn perform(game: &mut Game<'_>, player: &NativeServerPlayer, line: &str, ran: &mut bool) {
|
||||
let word = line.split(' ').next().unwrap_or_default();
|
||||
if !smp_invsee::COMMANDS.contains(&word) || !game.native_on(smp_glue::ID) {
|
||||
return;
|
||||
}
|
||||
*ran = true;
|
||||
let me = Player {
|
||||
uuid: player.uuid,
|
||||
name: player.name.clone(),
|
||||
level: player.permission,
|
||||
};
|
||||
let find = |name: &str| smp_glue::find_player(game, name);
|
||||
let mut guard = state();
|
||||
let s = guard.as_mut().expect("made");
|
||||
let (out, open) = s.invsee.command(&me, &find, line).unwrap_or_default();
|
||||
if let Some((target, name, kind)) = open {
|
||||
s.pending.retain(|p| p.0 != me.uuid);
|
||||
s.pending.push((me.uuid, target, name, kind));
|
||||
ACTIVE.store(true, Relaxed);
|
||||
}
|
||||
drop(guard);
|
||||
for o in out {
|
||||
match o {
|
||||
Out::Error(e) => game.native_feedback(Text::literal(e).with_color(text::RED)),
|
||||
Out::Reply(r) => game.native_feedback(Text::literal(r)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn named(item: &str, name: &str, color: &str) -> ItemStack {
|
||||
let mut stack = ItemStack {
|
||||
item: item.into(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
let title = serde_json::json!({ "text": name, "italic": false, "color": color });
|
||||
stack.data_mut().custom_name = Some(component_nbt(&title));
|
||||
stack
|
||||
}
|
||||
|
||||
fn items(invsee: &Invsee<ItemStack>, op: u128) -> Vec<Option<ItemStack>> {
|
||||
invsee
|
||||
.layout(op)
|
||||
.into_iter()
|
||||
.map(|c| match c {
|
||||
Cell::Empty => None,
|
||||
Cell::Stack(s) => Some(s),
|
||||
Cell::Filler => Some(named("minecraft:gray_stained_glass_pane", " ", "gray")),
|
||||
Cell::Label(l) => Some(named("minecraft:light_gray_stained_glass_pane", l, "gray")),
|
||||
Cell::Close => Some(named("minecraft:barrier", "Close", "red")),
|
||||
Cell::Info { name, online, busy } => {
|
||||
let line = match &busy {
|
||||
Some(b) => format!("{name}: {b} (nothing moves now)"),
|
||||
None if online => format!("{name} (online): click theirs to take, yours to give"),
|
||||
None => format!("{name} (offline, their saved file): click theirs to take, yours to give"),
|
||||
};
|
||||
Some(named("minecraft:player_head", &line, if busy.is_some() { "red" } else { "yellow" }))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn title(name: &str, kind: Kind) -> Text {
|
||||
Text::literal(match kind {
|
||||
Kind::Player => format!("{name}'s inventory"),
|
||||
Kind::Ender => format!("{name}'s ender chest"),
|
||||
})
|
||||
}
|
||||
|
||||
/// The audit line of a move: written before it is made ("asked"), and
|
||||
/// again when the game refused it.
|
||||
fn audit(dir: &std::path::Path, m: &Move, outcome: &str) -> Result<(), String> {
|
||||
smp_glue::audit(
|
||||
dir,
|
||||
serde_json::json!({
|
||||
"kind": if m.take { "invsee_take" } else { "invsee_put" },
|
||||
"op": smp_store::uuid_string(m.op),
|
||||
"target": smp_store::uuid_string(m.target),
|
||||
"inventory": match m.kind { Kind::Player => "inventory", Kind::Ender => "ender_chest" },
|
||||
"item": m.item,
|
||||
"count": m.count,
|
||||
"outcome": outcome,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// The staff's line: the server log, `staff.log`, the other operators on.
|
||||
fn staff(game: &mut Game<'_>, op: u128, line: &str) {
|
||||
log::warn!("[smp] staff: {line}");
|
||||
if let Some(dir) = game.native_world_dir() {
|
||||
use std::io::Write;
|
||||
let path = dir.join(smp_store::DIR).join("staff.log");
|
||||
let stamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
|
||||
let _ = writeln!(f, "{stamp} {line}");
|
||||
}
|
||||
}
|
||||
for p in game.native_server_players() {
|
||||
if p.uuid != op && p.permission >= smp_invsee::LEVEL {
|
||||
game.native_tell(p.uuid, Text::literal(format!("[Staff] {line}")).with_color(text::GRAY));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Each tick: one load while nothing is open. Menus to open now; once a
|
||||
/// second, every open menu read again, and those of operators gone closed.
|
||||
pub(in crate::game) fn tick(game: &mut Game<'_>) {
|
||||
if !ACTIVE.load(Relaxed) {
|
||||
return;
|
||||
}
|
||||
let n = TICKS.fetch_add(1, Relaxed);
|
||||
let mut guard = state();
|
||||
let s = guard.as_mut().expect("made");
|
||||
let mut tell = Vec::new();
|
||||
for (op, target, name, kind) in std::mem::take(&mut s.pending) {
|
||||
let opened = s.invsee.open(op, target, &name, kind, &GameWorld(game));
|
||||
match opened {
|
||||
Ok(()) => {
|
||||
let window = game.native_open_menu(op, smp_invsee::ROWS, title(&name, kind), items(&s.invsee, op));
|
||||
match window {
|
||||
Some(w) => s.invsee.views.get_mut(&op).expect("opened").window = Some(w),
|
||||
None => {
|
||||
s.invsee.views.remove(&op);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tell.push((op, format!("Can't open {name}'s inventory: {e}."))),
|
||||
}
|
||||
}
|
||||
let mut summaries = Vec::new();
|
||||
if n % 20 == 0 {
|
||||
let on: Vec<(u128, String)> = game.native_server_players().into_iter().map(|p| (p.uuid, p.name)).collect();
|
||||
let ops: Vec<u128> = s.invsee.views.keys().copied().collect();
|
||||
for op in ops {
|
||||
match on.iter().find(|p| p.0 == op) {
|
||||
None => summaries.extend(s.invsee.close(op, &smp_store::uuid_string(op)).map(|l| (op, l))),
|
||||
Some(_) => {
|
||||
if s.invsee.refresh(op, &GameWorld(game)) {
|
||||
game.native_set_menu_items(op, items(&s.invsee, op));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ACTIVE.store(!s.invsee.views.is_empty() || !s.pending.is_empty(), Relaxed);
|
||||
drop(guard);
|
||||
for (op, line) in tell {
|
||||
game.native_tell(op, Text::literal(line).with_color(text::RED));
|
||||
}
|
||||
for (op, line) in summaries {
|
||||
staff(game, op, &line);
|
||||
}
|
||||
}
|
||||
|
||||
/// A click in an `/invsee` menu: true when it was one.
|
||||
pub(in crate::game) fn menu_clicked(game: &mut Game<'_>, uuid: u128, window: i32, slot: i32, mode: i32) -> bool {
|
||||
let mut guard = state();
|
||||
let s = guard.as_mut().expect("made");
|
||||
if s.invsee.views.get(&uuid).is_none_or(|v| v.window != Some(window)) {
|
||||
return false;
|
||||
}
|
||||
let Some(dir) = game.native_world_dir() else {
|
||||
return true;
|
||||
};
|
||||
if !matches!(mode, 0 | 1) {
|
||||
return true;
|
||||
}
|
||||
let mut asked: Option<Move> = None;
|
||||
// Each move is in the audit log before it is made; one the log
|
||||
// refuses isn't made.
|
||||
let mut record = |m: &Move| {
|
||||
audit(&dir, m, "asked")?;
|
||||
asked = Some(m.clone());
|
||||
Ok(())
|
||||
};
|
||||
let own = native_menu_inventory_slot(smp_invsee::SIZE, slot);
|
||||
let mut world = GameWorld(game);
|
||||
let result = match own {
|
||||
Some(i) => match world.0.native_player_stacks(uuid).and_then(|st| st.get(i).cloned().flatten()) {
|
||||
Some(stack) => s.invsee.click_own(uuid, i, &stack, &mut world, &mut record).map(|_| false),
|
||||
None => Ok(false),
|
||||
},
|
||||
None => match usize::try_from(slot) {
|
||||
Ok(m) if m < smp_invsee::SIZE => s.invsee.click_menu(uuid, m, &mut world, &mut record).map(|c| c == Some(true)),
|
||||
_ => Ok(false),
|
||||
},
|
||||
};
|
||||
if let (Some(m), Err(e)) = (&asked, &result)
|
||||
&& let Err(err) = audit(&dir, m, &format!("refused: {e}"))
|
||||
{
|
||||
log::error!("[smp] invsee audit: {err}");
|
||||
}
|
||||
let name = game.native_server_players().into_iter().find(|p| p.uuid == uuid).map(|p| p.name);
|
||||
let close = matches!(result, Ok(true));
|
||||
let summary = if close { s.invsee.close(uuid, name.as_deref().unwrap_or("?")) } else { None };
|
||||
let refreshed = (!close).then(|| items(&s.invsee, uuid));
|
||||
ACTIVE.store(!s.invsee.views.is_empty() || !s.pending.is_empty(), Relaxed);
|
||||
drop(guard);
|
||||
if let Err(e) = &result {
|
||||
game.native_tell(uuid, Text::literal(format!("Nothing moved: {e}.")).with_color(text::RED));
|
||||
}
|
||||
if close {
|
||||
game.native_close_menu(uuid);
|
||||
} else if let Some(items) = refreshed {
|
||||
game.native_set_menu_items(uuid, items);
|
||||
}
|
||||
if let Some(line) = summary {
|
||||
staff(game, uuid, &line);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// An `/invsee` menu closed: true when it was one.
|
||||
pub(in crate::game) fn menu_closed(game: &mut Game<'_>, uuid: u128, window: i32) -> bool {
|
||||
let mut guard = state();
|
||||
let s = guard.as_mut().expect("made");
|
||||
if s.invsee.views.get(&uuid).is_none_or(|v| v.window != Some(window)) {
|
||||
return false;
|
||||
}
|
||||
let name = game.native_server_players().into_iter().find(|p| p.uuid == uuid).map(|p| p.name);
|
||||
let summary = s.invsee.close(uuid, name.as_deref().unwrap_or("?"));
|
||||
ACTIVE.store(!s.invsee.views.is_empty() || !s.pending.is_empty(), Relaxed);
|
||||
drop(guard);
|
||||
if let Some(line) = summary {
|
||||
staff(game, uuid, &line);
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
// A click in a menu a native mod opened: the SMP's trade menu, when it is one.
|
||||
fn smp_menu_clicked(game: &mut crate::game::Game<'_>, uuid: u128, window: i32, slot: i32, mode: i32) {
|
||||
crate::game::smp_glue::menu_clicked(game, uuid, window, slot, mode);
|
||||
if !crate::game::smp_invsee_glue::menu_clicked(game, uuid, window, slot, mode) {
|
||||
crate::game::smp_glue::menu_clicked(game, uuid, window, slot, mode);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
// A menu a native mod opened closed: a trade's ends, nothing moved.
|
||||
fn smp_menu_closed(game: &mut crate::game::Game<'_>, uuid: u128, window: i32) {
|
||||
crate::game::smp_glue::menu_closed(game, uuid, window);
|
||||
if !crate::game::smp_invsee_glue::menu_closed(game, uuid, window) {
|
||||
crate::game::smp_glue::menu_closed(game, uuid, window);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// The SMP's commands in every player's command tree.
|
||||
fn smp_register_commands(commands: &mut Vec<crate::game::native_server::NativeCommand>) {
|
||||
crate::game::smp_glue::commands(commands);
|
||||
crate::game::smp_invsee_glue::commands(commands);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// A player right clicks a block: kept out of the land others claimed.
|
||||
fn smp_right_click_block(game: &mut crate::game::Game<'_>, pos: [i32; 3], canceled: &mut bool) {
|
||||
crate::game::smp_glue::guard(game, pos, canceled);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,657 @@
|
||||
// The casino's blackjack: one player against the dealer, six decks shuffled
|
||||
// for every hand. The dealer stands on all 17s and checks for blackjack
|
||||
// first; a blackjack pays 3 to 2; the player may double on their first two
|
||||
// cards (one more card, the bet doubled); no split, insurance or surrender.
|
||||
// With basic strategy the house keeps about 1% (the test plays 400,000
|
||||
// hands). The dealer draws one card every 0.6 s in the menu. Closing the
|
||||
// menu or leaving stands on the hand, as does a minute without a move: it
|
||||
// is played out and paid all the same, once. Added to the game as
|
||||
// `game::smp_casino_blackjack`; also a module of this crate, for its tests.
|
||||
|
||||
use super::smp_casino::{Color, Icon, Rng, Seat, Stake, filler, icon};
|
||||
|
||||
pub const DECKS: usize = 6;
|
||||
/// Between two of the dealer's cards.
|
||||
pub const DEALER_MS: i64 = 600;
|
||||
/// A hand left this long without a move stands.
|
||||
pub const IDLE_MS: i64 = 60_000;
|
||||
|
||||
pub const HELP: [&str; 1] = [
|
||||
"Blackjack (6 decks, shuffled every hand): the dealer stands on all 17s; blackjack pays 3 to 2; double on your first two cards; no split or insurance. House edge about 1% with basic strategy.",
|
||||
];
|
||||
|
||||
/// A card: rank 1 (ace) to 13 (king), suit 0 to 3.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Card {
|
||||
pub rank: u8,
|
||||
pub suit: u8,
|
||||
}
|
||||
|
||||
impl Card {
|
||||
pub fn value(self) -> u8 {
|
||||
self.rank.min(10)
|
||||
}
|
||||
pub fn name(self) -> String {
|
||||
let rank = match self.rank {
|
||||
1 => "A".to_owned(),
|
||||
11 => "J".to_owned(),
|
||||
12 => "Q".to_owned(),
|
||||
13 => "K".to_owned(),
|
||||
n => n.to_string(),
|
||||
};
|
||||
format!("{rank}{}", ["♠", "♥", "♦", "♣"][usize::from(self.suit % 4)])
|
||||
}
|
||||
fn red(self) -> bool {
|
||||
matches!(self.suit, 1 | 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// A hand's best total, and whether an ace counts 11 in it (soft).
|
||||
pub fn total(cards: &[Card]) -> (u8, bool) {
|
||||
let hard: u8 = cards.iter().map(|c| c.value()).sum();
|
||||
if cards.iter().any(|c| c.rank == 1) && hard + 10 <= 21 {
|
||||
(hard + 10, true)
|
||||
} else {
|
||||
(hard, false)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn blackjack(cards: &[Card]) -> bool {
|
||||
cards.len() == 2 && total(cards).0 == 21
|
||||
}
|
||||
|
||||
/// Six decks, from which a hand draws without putting back.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Shoe(Vec<Card>);
|
||||
|
||||
impl Shoe {
|
||||
pub fn new() -> Self {
|
||||
let mut cards = Vec::with_capacity(52 * DECKS);
|
||||
for _ in 0..DECKS {
|
||||
for suit in 0..4 {
|
||||
for rank in 1..=13 {
|
||||
cards.push(Card { rank, suit });
|
||||
}
|
||||
}
|
||||
}
|
||||
Self(cards)
|
||||
}
|
||||
pub fn draw(&mut self, rng: &mut Rng) -> Card {
|
||||
let i = rng.below(self.0.len() as u64) as usize;
|
||||
self.0.swap_remove(i)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Shoe {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Phase {
|
||||
/// The player's move; since when.
|
||||
Player { since: i64 },
|
||||
/// The dealer draws; their next card at.
|
||||
Dealer { next: i64 },
|
||||
}
|
||||
|
||||
/// How a hand ended, for the line and the payout.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum End {
|
||||
Blackjack,
|
||||
Win,
|
||||
Push,
|
||||
Lose,
|
||||
Bust,
|
||||
DealerBlackjack,
|
||||
}
|
||||
|
||||
impl End {
|
||||
/// What the hand pays back on `stake` (the stake included).
|
||||
pub fn payout(self, stake: i64) -> i64 {
|
||||
match self {
|
||||
End::Blackjack => stake + stake * 3 / 2,
|
||||
End::Win => stake * 2,
|
||||
End::Push => stake,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Hand {
|
||||
pub stake: Stake,
|
||||
pub player: Vec<Card>,
|
||||
pub dealer: Vec<Card>,
|
||||
pub shoe: Shoe,
|
||||
pub doubled: bool,
|
||||
pub phase: Phase,
|
||||
}
|
||||
|
||||
impl Hand {
|
||||
/// How it ends, once the dealer is done (or at once for a blackjack or a
|
||||
/// bust).
|
||||
fn end(&self) -> Option<End> {
|
||||
let (p, _) = total(&self.player);
|
||||
let (d, _) = total(&self.dealer);
|
||||
if blackjack(&self.player) {
|
||||
return Some(if blackjack(&self.dealer) {
|
||||
End::Push
|
||||
} else {
|
||||
End::Blackjack
|
||||
});
|
||||
}
|
||||
if blackjack(&self.dealer) {
|
||||
return Some(End::DealerBlackjack);
|
||||
}
|
||||
if p > 21 {
|
||||
return Some(End::Bust);
|
||||
}
|
||||
if !matches!(self.phase, Phase::Dealer { .. }) || d < 17 {
|
||||
return None;
|
||||
}
|
||||
Some(if d > 21 || p > d {
|
||||
End::Win
|
||||
} else if p == d {
|
||||
End::Push
|
||||
} else {
|
||||
End::Lose
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A hand played out, to be paid.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Done {
|
||||
pub hand: Hand,
|
||||
pub end: End,
|
||||
}
|
||||
|
||||
/// A player's table: the last hand, shown until the next.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Table {
|
||||
pub last: Option<(Vec<Card>, Vec<Card>, End)>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Blackjack {
|
||||
pub hands: Vec<Hand>,
|
||||
}
|
||||
|
||||
impl Blackjack {
|
||||
pub fn busy(&self) -> bool {
|
||||
!self.hands.is_empty()
|
||||
}
|
||||
pub fn playing(&self, uuid: u128) -> bool {
|
||||
self.hands.iter().any(|h| h.stake.uuid == uuid)
|
||||
}
|
||||
fn hand(&mut self, uuid: u128) -> Option<&mut Hand> {
|
||||
self.hands.iter_mut().find(|h| h.stake.uuid == uuid)
|
||||
}
|
||||
/// The bet is held: two cards each. A blackjack ends it at once.
|
||||
pub fn deal(&mut self, stake: Stake, now: i64, rng: &mut Rng) -> Option<Done> {
|
||||
let mut shoe = Shoe::new();
|
||||
let mut player = Vec::with_capacity(6);
|
||||
let mut dealer = Vec::with_capacity(6);
|
||||
for _ in 0..2 {
|
||||
player.push(shoe.draw(rng));
|
||||
dealer.push(shoe.draw(rng));
|
||||
}
|
||||
let hand = Hand {
|
||||
stake,
|
||||
player,
|
||||
dealer,
|
||||
shoe,
|
||||
doubled: false,
|
||||
phase: Phase::Player { since: now },
|
||||
};
|
||||
// The dealer checks for blackjack first.
|
||||
if let Some(end) = hand.end() {
|
||||
return Some(Done { hand, end });
|
||||
}
|
||||
self.hands.push(hand);
|
||||
None
|
||||
}
|
||||
fn finish(&mut self, uuid: u128) -> Option<Done> {
|
||||
let i = self.hands.iter().position(|h| h.stake.uuid == uuid)?;
|
||||
let end = self.hands[i].end()?;
|
||||
Some(Done {
|
||||
hand: self.hands.swap_remove(i),
|
||||
end,
|
||||
})
|
||||
}
|
||||
/// One more card; a bust ends the hand.
|
||||
pub fn hit(&mut self, uuid: u128, now: i64, rng: &mut Rng) -> Option<Done> {
|
||||
let h = self.hand(uuid)?;
|
||||
if !matches!(h.phase, Phase::Player { .. }) {
|
||||
return None;
|
||||
}
|
||||
let card = h.shoe.draw(rng);
|
||||
h.player.push(card);
|
||||
h.phase = Phase::Player { since: now };
|
||||
if total(&h.player).0 >= 21 {
|
||||
if total(&h.player).0 == 21 {
|
||||
h.phase = Phase::Dealer {
|
||||
next: now + DEALER_MS,
|
||||
};
|
||||
return None;
|
||||
}
|
||||
return self.finish(uuid);
|
||||
}
|
||||
None
|
||||
}
|
||||
/// The dealer plays.
|
||||
pub fn stand(&mut self, uuid: u128, now: i64) -> bool {
|
||||
match self.hand(uuid) {
|
||||
Some(h) if matches!(h.phase, Phase::Player { .. }) => {
|
||||
h.phase = Phase::Dealer {
|
||||
next: now + DEALER_MS,
|
||||
};
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// Whether `uuid` may double now (first two cards); the caller holds the
|
||||
/// second bet first, then calls `doubled`.
|
||||
pub fn may_double(&self, uuid: u128) -> bool {
|
||||
self.hands.iter().any(|h| {
|
||||
h.stake.uuid == uuid && h.player.len() == 2 && matches!(h.phase, Phase::Player { .. })
|
||||
})
|
||||
}
|
||||
/// The bet doubled (held): one card, then the dealer plays.
|
||||
pub fn doubled(&mut self, uuid: u128, extra: i64, now: i64, rng: &mut Rng) -> Option<Done> {
|
||||
let h = self.hand(uuid)?;
|
||||
h.stake.amount += extra;
|
||||
h.doubled = true;
|
||||
let card = h.shoe.draw(rng);
|
||||
h.player.push(card);
|
||||
h.phase = Phase::Dealer {
|
||||
next: now + DEALER_MS,
|
||||
};
|
||||
if total(&h.player).0 > 21 {
|
||||
return self.finish(uuid);
|
||||
}
|
||||
None
|
||||
}
|
||||
/// The dealers' cards due by `now`; hands left idle stand. The hands
|
||||
/// played out, taken out to be paid once, and those that changed.
|
||||
pub fn step(&mut self, now: i64, rng: &mut Rng) -> (Vec<Done>, Vec<u128>) {
|
||||
let mut changed = Vec::new();
|
||||
for h in &mut self.hands {
|
||||
match h.phase {
|
||||
Phase::Player { since } if now - since >= IDLE_MS => {
|
||||
h.phase = Phase::Dealer {
|
||||
next: now + DEALER_MS,
|
||||
};
|
||||
changed.push(h.stake.uuid);
|
||||
}
|
||||
Phase::Dealer { next } if now >= next => {
|
||||
// The hidden card turns first (shown from this step), then one card a step.
|
||||
if total(&h.dealer).0 < 17 {
|
||||
let card = h.shoe.draw(rng);
|
||||
h.dealer.push(card);
|
||||
h.phase = Phase::Dealer {
|
||||
next: now + DEALER_MS,
|
||||
};
|
||||
} else {
|
||||
h.phase = Phase::Dealer { next: i64::MIN };
|
||||
}
|
||||
changed.push(h.stake.uuid);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut done = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < self.hands.len() {
|
||||
let h = &self.hands[i];
|
||||
let settled = matches!(h.phase, Phase::Dealer { next: i64::MIN });
|
||||
match h.end() {
|
||||
Some(end) if settled || matches!(end, End::Bust) => done.push(Done {
|
||||
hand: self.hands.swap_remove(i),
|
||||
end,
|
||||
}),
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
(done, changed)
|
||||
}
|
||||
|
||||
/// The table's cells (all but the bottom row's shared cells).
|
||||
pub fn layout(&self, cells: &mut [Option<Icon>], uuid: u128, seat: &Seat) {
|
||||
for c in &mut cells[..45] {
|
||||
*c = filler("minecraft:green_stained_glass_pane");
|
||||
}
|
||||
let hand = self.hands.iter().find(|h| h.stake.uuid == uuid);
|
||||
let (player, dealer, hidden, message) = match (hand, &seat.blackjack.last) {
|
||||
(Some(h), _) => {
|
||||
let hidden = matches!(h.phase, Phase::Player { .. });
|
||||
let msg = if hidden {
|
||||
"Hit, stand or double".to_owned()
|
||||
} else {
|
||||
"The dealer plays...".to_owned()
|
||||
};
|
||||
(&h.player[..], &h.dealer[..], hidden, msg)
|
||||
}
|
||||
(None, Some((p, d, end))) => (&p[..], &d[..], false, end_line(*end)),
|
||||
(None, None) => (
|
||||
&[][..],
|
||||
&[][..],
|
||||
false,
|
||||
"Place your bet and deal".to_owned(),
|
||||
),
|
||||
};
|
||||
for (i, c) in dealer.iter().take(9).enumerate() {
|
||||
cells[9 + i] = if hidden && i == 1 {
|
||||
icon("minecraft:map", "Hidden card", Color::Gray)
|
||||
} else {
|
||||
card(*c)
|
||||
};
|
||||
}
|
||||
for (i, c) in player.iter().take(9).enumerate() {
|
||||
cells[27 + i] = card(*c);
|
||||
}
|
||||
if !dealer.is_empty() {
|
||||
let shown = if hidden {
|
||||
format!("Dealer shows {}", total(&dealer[..1]).0)
|
||||
} else {
|
||||
format!("Dealer: {}", show_total(dealer))
|
||||
};
|
||||
cells[4] = icon("minecraft:armor_stand", shown, Color::White);
|
||||
cells[36] = icon(
|
||||
"minecraft:name_tag",
|
||||
format!("You: {}", show_total(player)),
|
||||
Color::White,
|
||||
);
|
||||
}
|
||||
cells[22] = icon("minecraft:oak_sign", message, Color::Yellow);
|
||||
if hand.is_some_and(|h| matches!(h.phase, Phase::Player { .. })) {
|
||||
cells[38] = icon("minecraft:lime_wool", "HIT", Color::Green);
|
||||
cells[40] = icon("minecraft:red_wool", "STAND", Color::Red);
|
||||
if hand.is_some_and(|h| h.player.len() == 2) {
|
||||
cells[42] = icon(
|
||||
"minecraft:gold_block",
|
||||
"DOUBLE (one card, twice the bet)",
|
||||
Color::Gold,
|
||||
);
|
||||
}
|
||||
}
|
||||
cells[53] = if hand.is_some() {
|
||||
icon("minecraft:gray_concrete", "Hand in play", Color::Gray)
|
||||
} else {
|
||||
icon("minecraft:lime_concrete", "DEAL", Color::Green)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn show_total(cards: &[Card]) -> String {
|
||||
match total(cards) {
|
||||
(t, true) if t < 21 => format!("{t} (soft)"),
|
||||
(t, _) => t.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// How a hand ended, in a line.
|
||||
pub fn end_line(end: End) -> String {
|
||||
match end {
|
||||
End::Blackjack => "Blackjack! Paid 3 to 2".into(),
|
||||
End::Win => "You win".into(),
|
||||
End::Push => "Push: your bet back".into(),
|
||||
End::Lose => "The dealer wins".into(),
|
||||
End::Bust => "Bust".into(),
|
||||
End::DealerBlackjack => "The dealer has blackjack".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A card as a cell: its blackjack value as the count.
|
||||
fn card(c: Card) -> Option<Icon> {
|
||||
let mut i = icon(
|
||||
"minecraft:paper",
|
||||
c.name(),
|
||||
if c.red() { Color::Red } else { Color::White },
|
||||
);
|
||||
if let Some(i) = i.as_mut() {
|
||||
i.count = c.value();
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::smp_casino::{Fx, Game, POT, View, tests::*};
|
||||
use super::*;
|
||||
|
||||
fn c(rank: u8) -> Card {
|
||||
Card { rank, suit: 0 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totals_soft_and_hard() {
|
||||
assert_eq!(total(&[c(1), c(6)]), (17, true));
|
||||
assert_eq!(total(&[c(1), c(6), c(10)]), (17, false));
|
||||
assert_eq!(total(&[c(1), c(1), c(9)]), (21, true));
|
||||
assert_eq!(total(&[c(13), c(12)]), (20, false));
|
||||
assert!(blackjack(&[c(1), c(11)]) && !blackjack(&[c(1), c(5), c(5)]));
|
||||
assert_eq!(End::Blackjack.payout(10), 25);
|
||||
assert_eq!(End::Blackjack.payout(15), 37, "3 to 2, rounded down");
|
||||
assert_eq!(
|
||||
(
|
||||
End::Win.payout(10),
|
||||
End::Push.payout(10),
|
||||
End::Lose.payout(10)
|
||||
),
|
||||
(20, 10, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dealer_stands_on_all_17s() {
|
||||
let mut rng = Rng::from_seed([3; 32]);
|
||||
for _ in 0..2000 {
|
||||
let mut b = Blackjack::default();
|
||||
let stake = Stake {
|
||||
uuid: A,
|
||||
name: "Alex".into(),
|
||||
game: Game::Blackjack,
|
||||
amount: 10,
|
||||
};
|
||||
if b.deal(stake, 0, &mut rng).is_some() {
|
||||
continue;
|
||||
}
|
||||
b.stand(A, 0);
|
||||
let mut now = 0;
|
||||
loop {
|
||||
now += DEALER_MS;
|
||||
let (done, _) = b.step(now, &mut rng);
|
||||
if let Some(d) = done.into_iter().next() {
|
||||
let (t, _) = total(&d.hand.dealer);
|
||||
assert!(t >= 17, "{:?}", d.hand.dealer);
|
||||
// It stopped at the first 17 or more.
|
||||
assert!(
|
||||
total(&d.hand.dealer[..d.hand.dealer.len() - 1]).0 < 17
|
||||
|| d.hand.dealer.len() == 2
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Basic strategy for 6 decks, dealer stands on 17, no split.
|
||||
fn strategy(player: &[Card], up: Card) -> char {
|
||||
let (t, soft) = total(player);
|
||||
let u = up.value();
|
||||
let u = if u == 1 { 11 } else { u };
|
||||
let two = player.len() == 2;
|
||||
let d = |yes: bool| {
|
||||
if yes && two {
|
||||
'D'
|
||||
} else if yes {
|
||||
'H'
|
||||
} else {
|
||||
'S'
|
||||
}
|
||||
};
|
||||
if soft {
|
||||
return match t {
|
||||
13 | 14 => {
|
||||
if (5..=6).contains(&u) && two {
|
||||
'D'
|
||||
} else {
|
||||
'H'
|
||||
}
|
||||
}
|
||||
15 | 16 => {
|
||||
if (4..=6).contains(&u) && two {
|
||||
'D'
|
||||
} else {
|
||||
'H'
|
||||
}
|
||||
}
|
||||
17 => {
|
||||
if (3..=6).contains(&u) && two {
|
||||
'D'
|
||||
} else {
|
||||
'H'
|
||||
}
|
||||
}
|
||||
18 => {
|
||||
if (3..=6).contains(&u) {
|
||||
d(true)
|
||||
} else if u <= 8 {
|
||||
'S'
|
||||
} else {
|
||||
'H'
|
||||
}
|
||||
}
|
||||
_ => 'S',
|
||||
};
|
||||
}
|
||||
match t {
|
||||
0..=8 => 'H',
|
||||
9 => {
|
||||
if (3..=6).contains(&u) && two {
|
||||
'D'
|
||||
} else {
|
||||
'H'
|
||||
}
|
||||
}
|
||||
10 => {
|
||||
if u <= 9 && two {
|
||||
'D'
|
||||
} else {
|
||||
'H'
|
||||
}
|
||||
}
|
||||
11 => {
|
||||
if u <= 10 && two {
|
||||
'D'
|
||||
} else {
|
||||
'H'
|
||||
}
|
||||
}
|
||||
12 => {
|
||||
if (4..=6).contains(&u) {
|
||||
'S'
|
||||
} else {
|
||||
'H'
|
||||
}
|
||||
}
|
||||
13..=16 => {
|
||||
if u <= 6 {
|
||||
'S'
|
||||
} else {
|
||||
'H'
|
||||
}
|
||||
}
|
||||
_ => 'S',
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_strategy_gets_back_about_99_percent() {
|
||||
let mut rng = Rng::from_seed([3; 32]);
|
||||
let (mut staked, mut back) = (0i64, 0i64);
|
||||
for _ in 0..400_000 {
|
||||
let mut b = Blackjack::default();
|
||||
let stake = Stake {
|
||||
uuid: A,
|
||||
name: "Alex".into(),
|
||||
game: Game::Blackjack,
|
||||
amount: 100,
|
||||
};
|
||||
let mut done = b.deal(stake, 0, &mut rng);
|
||||
while done.is_none() {
|
||||
let h = &b.hands[0];
|
||||
if !matches!(h.phase, Phase::Player { .. }) {
|
||||
break;
|
||||
}
|
||||
done = match strategy(&h.player, h.dealer[0]) {
|
||||
'H' => b.hit(A, 0, &mut rng),
|
||||
'D' => b.doubled(A, 100, 0, &mut rng),
|
||||
_ => {
|
||||
b.stand(A, 0);
|
||||
None
|
||||
}
|
||||
};
|
||||
}
|
||||
let mut now = 0;
|
||||
while done.is_none() {
|
||||
now += DEALER_MS;
|
||||
done = b.step(now, &mut rng).0.into_iter().next();
|
||||
}
|
||||
let d = done.unwrap();
|
||||
staked += d.hand.stake.amount;
|
||||
back += d.end.payout(d.hand.stake.amount);
|
||||
}
|
||||
let rtp = back as f64 / staked as f64;
|
||||
assert!((0.97..1.0).contains(&rtp), "{rtp}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hand_through_the_casino_pays_once_and_closing_stands() {
|
||||
let (mut e, mut c, dir) = setup("blackjack", 100_000);
|
||||
let before = money(&e);
|
||||
let mut paid_hands = 0;
|
||||
let mut now = 0;
|
||||
c.sit(A, "Alex", View::Blackjack);
|
||||
for round in 0..40 {
|
||||
c.seats.get_mut(&A).unwrap().window = Some(round);
|
||||
c.click(&mut e, A, 53, false, now);
|
||||
c.click(&mut e, A, 53, false, now + 10);
|
||||
assert!(e.held(A) <= 10, "one deal per click");
|
||||
if c.blackjack.playing(A) {
|
||||
if round % 3 == 0 {
|
||||
// Double, with the second bet held.
|
||||
c.click(&mut e, A, 42, false, now + 20);
|
||||
c.click(&mut e, A, 42, false, now + 30);
|
||||
assert!(e.held(A) <= 20);
|
||||
} else if round % 3 == 1 {
|
||||
// The menu closes: the hand stands.
|
||||
assert!(c.closed(A, round, 0));
|
||||
c.sit(A, "Alex", View::Blackjack);
|
||||
} else {
|
||||
// Stand by hand; a second click does nothing more.
|
||||
c.click(&mut e, A, 40, false, now + 20);
|
||||
c.click(&mut e, A, 40, false, now + 30);
|
||||
}
|
||||
}
|
||||
for _ in 0..20 {
|
||||
now += 300;
|
||||
c.tick(&mut e, now);
|
||||
}
|
||||
if !c.blackjack.playing(A) {
|
||||
paid_hands += 1;
|
||||
}
|
||||
assert_eq!(money(&e), before);
|
||||
now += 5000;
|
||||
}
|
||||
assert_eq!(paid_hands, 40);
|
||||
assert_eq!(e.held(A), 0);
|
||||
assert_eq!(c.ledger.totals["blackjack"].rounds, 40);
|
||||
let _ = (e.pool(POT), Fx::BossHide);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
// The casino's crash: one round for everyone. The first bet opens a 10 s
|
||||
// betting window. Then the multiplier climbs from 1.00x (2x at 8 s, 10x at
|
||||
// 27 s) until it crashes; whoever cashes out first gets their bet times the
|
||||
// multiplier at that moment, and the others lose it. The crash point is
|
||||
// drawn when the flight starts, P(crash >= x) = (100% - edge) / x, so with
|
||||
// the 4% edge any way of playing gets back 96% on average. A crash at
|
||||
// 1.00x happens about 5% of the time, and the flight never goes past the cap
|
||||
// (100x). A player may set an automatic cash-out before the flight. Leaving
|
||||
// the server mid-flight cashes out at that moment, and closing the menu
|
||||
// doesn't (/casino crash opens it again). Every menu, the boss bar and the
|
||||
// action bar show the climb, 5 pictures a second. Added to the game as
|
||||
// `game::smp_casino_crash`; also a module of this crate, for its tests.
|
||||
|
||||
use super::smp_casino::{Color, FRAME_MS, Icon, Rng, Seat, Stake, filler, icon};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
pub const BETTING_MS: i64 = 10_000;
|
||||
/// How long the crash stays shown before the next round may open.
|
||||
pub const SHOWN_MS: i64 = 3000;
|
||||
/// The multiplier doubles every 8 s.
|
||||
const DOUBLING_MS: f64 = 8000.0;
|
||||
/// The automatic cash-outs a player can pick, in hundredths (0: none).
|
||||
pub const AUTOS: [u32; 7] = [0, 150, 200, 300, 500, 1000, 2500];
|
||||
|
||||
pub const HELP: [&str; 1] = [
|
||||
"Crash: bet before the flight, then cash out before it crashes; you get your bet times the multiplier. P(crash at x or more) = 96% / x, capped at 100x: whatever you do, 96% is paid back on average (house edge 4%).",
|
||||
];
|
||||
|
||||
/// The multiplier, in hundredths, `ms` into a flight.
|
||||
pub fn multiple_at(ms: i64) -> u32 {
|
||||
let m = 100.0 * (ms.max(0) as f64 * std::f64::consts::LN_2 / DOUBLING_MS).exp();
|
||||
m.floor().min(f64::from(u32::MAX)) as u32
|
||||
}
|
||||
|
||||
/// The first millisecond the multiplier reaches `h` hundredths.
|
||||
pub fn time_to(h: u32) -> i64 {
|
||||
let mut t =
|
||||
((f64::from(h.max(100)) / 100.0).ln() / std::f64::consts::LN_2 * DOUBLING_MS).ceil() as i64;
|
||||
while t > 0 && multiple_at(t - 1) >= h {
|
||||
t -= 1;
|
||||
}
|
||||
while multiple_at(t) < h {
|
||||
t += 1;
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// A crash point, in hundredths: P(>= x) = (1 - edge) / x, at least 1.00x,
|
||||
/// at most `max` (in whole times the bet).
|
||||
pub fn draw(rng: &mut Rng, edge_percent: i64, max: i64) -> u32 {
|
||||
let u = (rng.next_u64() >> 11) as f64 / (1u64 << 53) as f64;
|
||||
let keep = 1.0 - edge_percent.clamp(0, 99) as f64 / 100.0;
|
||||
let x = (100.0 * keep / (1.0 - u)).floor();
|
||||
x.clamp(100.0, (max.max(1) * 100) as f64) as u32
|
||||
}
|
||||
|
||||
/// "2.31x".
|
||||
pub fn times(h: u32) -> String {
|
||||
format!("{}.{:02}x", h / 100, h % 100)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Phase {
|
||||
Idle,
|
||||
Betting {
|
||||
ends: i64,
|
||||
},
|
||||
Flying {
|
||||
started: i64,
|
||||
crash: u32,
|
||||
crash_at: i64,
|
||||
},
|
||||
Crashed {
|
||||
at: i64,
|
||||
crash: u32,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Bet {
|
||||
pub stake: Stake,
|
||||
/// Cash out by itself at this multiplier (hundredths); 0: by hand.
|
||||
pub auto: u32,
|
||||
}
|
||||
|
||||
/// What moved in a step of the round.
|
||||
#[derive(Default, Debug)]
|
||||
pub struct Step {
|
||||
pub took_off: bool,
|
||||
/// Bets cashed out by their automatic cash-out: paid at that multiplier.
|
||||
pub cashed: Vec<(Bet, u32)>,
|
||||
/// The crash, and the bets still in: lost.
|
||||
pub crashed: Option<(u32, Vec<Bet>)>,
|
||||
}
|
||||
|
||||
pub struct Crash {
|
||||
pub phase: Phase,
|
||||
pub bets: Vec<Bet>,
|
||||
/// The last rounds' crash points, newest first.
|
||||
pub history: VecDeque<u32>,
|
||||
}
|
||||
|
||||
impl Default for Crash {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
phase: Phase::Idle,
|
||||
bets: Vec::new(),
|
||||
history: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Crash {
|
||||
pub fn busy(&self) -> bool {
|
||||
self.phase != Phase::Idle
|
||||
}
|
||||
pub fn playing(&self, uuid: u128) -> bool {
|
||||
self.bets.iter().any(|b| b.stake.uuid == uuid)
|
||||
}
|
||||
/// Whether a bet may be placed now (before a flight).
|
||||
pub fn open(&self) -> bool {
|
||||
matches!(self.phase, Phase::Idle | Phase::Betting { .. })
|
||||
}
|
||||
/// A held bet joins the next flight (`open` was checked).
|
||||
pub fn join(&mut self, stake: Stake, auto: u32, now: i64) {
|
||||
if self.phase == Phase::Idle {
|
||||
self.phase = Phase::Betting {
|
||||
ends: now + BETTING_MS,
|
||||
};
|
||||
}
|
||||
self.bets.push(Bet { stake, auto });
|
||||
}
|
||||
/// `uuid` cashes out: their bet and the multiplier, when the flight is
|
||||
/// still climbing. Once only: the bet leaves the round.
|
||||
pub fn cash_out(&mut self, uuid: u128, now: i64) -> Option<(Bet, u32)> {
|
||||
let Phase::Flying {
|
||||
started, crash_at, ..
|
||||
} = self.phase
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if now >= crash_at {
|
||||
return None;
|
||||
}
|
||||
let i = self.bets.iter().position(|b| b.stake.uuid == uuid)?;
|
||||
Some((self.bets.swap_remove(i), multiple_at(now - started)))
|
||||
}
|
||||
/// A bet taken back before the flight (its player left): None once flying.
|
||||
pub fn withdraw(&mut self, uuid: u128) -> Option<Bet> {
|
||||
if !matches!(self.phase, Phase::Betting { .. }) {
|
||||
return None;
|
||||
}
|
||||
let i = self.bets.iter().position(|b| b.stake.uuid == uuid)?;
|
||||
let bet = self.bets.swap_remove(i);
|
||||
if self.bets.is_empty() {
|
||||
self.phase = Phase::Idle;
|
||||
}
|
||||
Some(bet)
|
||||
}
|
||||
/// The round moves on to `now`.
|
||||
pub fn step(&mut self, now: i64, rng: &mut Rng, edge_percent: i64, max: i64) -> Step {
|
||||
let mut step = Step::default();
|
||||
if let Phase::Betting { ends } = self.phase
|
||||
&& now >= ends
|
||||
{
|
||||
let crash = draw(rng, edge_percent, max);
|
||||
self.phase = Phase::Flying {
|
||||
started: ends,
|
||||
crash,
|
||||
crash_at: ends + time_to(crash),
|
||||
};
|
||||
step.took_off = true;
|
||||
}
|
||||
if let Phase::Flying {
|
||||
started,
|
||||
crash,
|
||||
crash_at,
|
||||
} = self.phase
|
||||
{
|
||||
// Automatic cash-outs below the crash, reached by now.
|
||||
let mut i = 0;
|
||||
while i < self.bets.len() {
|
||||
let b = &self.bets[i];
|
||||
if b.auto > 0 && b.auto < crash && now >= started + time_to(b.auto) {
|
||||
let bet = self.bets.swap_remove(i);
|
||||
let at = bet.auto;
|
||||
step.cashed.push((bet, at));
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if now >= crash_at {
|
||||
step.crashed = Some((crash, std::mem::take(&mut self.bets)));
|
||||
self.phase = Phase::Crashed {
|
||||
at: crash_at,
|
||||
crash,
|
||||
};
|
||||
self.history.push_front(crash);
|
||||
self.history.truncate(8);
|
||||
}
|
||||
}
|
||||
if let Phase::Crashed { at, .. } = self.phase
|
||||
&& now >= at + SHOWN_MS
|
||||
{
|
||||
self.phase = Phase::Idle;
|
||||
}
|
||||
step
|
||||
}
|
||||
/// The picture the round is at: it changes 5 times a second in flight,
|
||||
/// once a second before.
|
||||
pub fn frame(&self, now: i64) -> i64 {
|
||||
match self.phase {
|
||||
Phase::Idle => 0,
|
||||
Phase::Betting { ends } => 1_000_000 + (ends - now).max(0) / 1000,
|
||||
Phase::Flying { started, .. } => 2_000_000 + (now - started).max(0) / FRAME_MS,
|
||||
Phase::Crashed { .. } => 3_000_000,
|
||||
}
|
||||
}
|
||||
/// The multiplier now, in flight.
|
||||
pub fn now_multiple(&self, now: i64) -> Option<u32> {
|
||||
match self.phase {
|
||||
Phase::Flying { started, crash, .. } => Some(multiple_at(now - started).min(crash)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The boss bar's line, colour and fill (0 to 100).
|
||||
pub fn boss(&self, now: i64, max: i64) -> Option<(String, Color, u32)> {
|
||||
let fill = |h: u32| {
|
||||
let f = (f64::from(h) / 100.0).ln() / (max.max(2) as f64).ln();
|
||||
(f * 100.0).clamp(0.0, 100.0) as u32
|
||||
};
|
||||
Some(match self.phase {
|
||||
Phase::Idle => return None,
|
||||
Phase::Betting { ends } => (
|
||||
format!(
|
||||
"Crash: takes off in {} s · {} bet(s) · /casino crash",
|
||||
((ends - now).max(0) + 999) / 1000,
|
||||
self.bets.len()
|
||||
),
|
||||
Color::Yellow,
|
||||
0,
|
||||
),
|
||||
Phase::Flying { .. } => {
|
||||
let h = self.now_multiple(now).unwrap_or(100);
|
||||
(format!("Crash: {}", times(h)), Color::Green, fill(h))
|
||||
}
|
||||
Phase::Crashed { crash, .. } => (
|
||||
format!("Crash: crashed at {}", times(crash)),
|
||||
Color::Red,
|
||||
fill(crash),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// The crash menu's cells (all but the bottom row's shared cells).
|
||||
pub fn layout(&self, cells: &mut [Option<Icon>], uuid: u128, seat: &Seat, now: i64, max: i64) {
|
||||
// The chart: 9 columns of time, 4 rows of (log) multiplier.
|
||||
let (elapsed, top, crashed) = match self.phase {
|
||||
Phase::Flying { started, crash, .. } => {
|
||||
(now - started, multiple_at(now - started).min(crash), false)
|
||||
}
|
||||
Phase::Crashed { crash, .. } => (time_to(crash), crash, true),
|
||||
_ => (0, 100, false),
|
||||
};
|
||||
let level = |h: u32| {
|
||||
let f = (f64::from(h) / 100.0).ln() / (max.max(2) as f64).ln().min(5f64.ln() * 2.0);
|
||||
(f * 4.0).clamp(0.0, 4.0)
|
||||
};
|
||||
let fill = if crashed {
|
||||
"minecraft:red_stained_glass_pane"
|
||||
} else {
|
||||
"minecraft:lime_stained_glass_pane"
|
||||
};
|
||||
for col in 0..9usize {
|
||||
let h = multiple_at(elapsed * (col as i64 + 1) / 9);
|
||||
let l = level(h);
|
||||
for row in 0..4usize {
|
||||
let from_bottom = 3 - row;
|
||||
cells[row * 9 + col] = if elapsed > 0 && l > from_bottom as f64 {
|
||||
filler(fill)
|
||||
} else {
|
||||
filler("minecraft:black_stained_glass_pane")
|
||||
};
|
||||
}
|
||||
}
|
||||
if elapsed > 0 {
|
||||
let row = 3 - (level(top).ceil() as usize).clamp(1, 4).saturating_sub(1);
|
||||
let (item, color) = if crashed {
|
||||
("minecraft:tnt", Color::Red)
|
||||
} else {
|
||||
("minecraft:firework_rocket", Color::Green)
|
||||
};
|
||||
let name = if crashed {
|
||||
format!("Crashed at {}", times(top))
|
||||
} else {
|
||||
times(top)
|
||||
};
|
||||
cells[row * 9 + 8] = icon(item, name, color);
|
||||
}
|
||||
for c in &mut cells[36..45] {
|
||||
*c = filler("minecraft:gray_stained_glass_pane");
|
||||
}
|
||||
let status = match self.phase {
|
||||
Phase::Idle => "No round: the first bet opens one (10 s to join)".to_owned(),
|
||||
Phase::Betting { ends } => {
|
||||
format!("Takes off in {} s", ((ends - now).max(0) + 999) / 1000)
|
||||
}
|
||||
Phase::Flying { .. } => format!("Flying: {}", times(top)),
|
||||
Phase::Crashed { crash, .. } => format!("Crashed at {}", times(crash)),
|
||||
};
|
||||
cells[38] = icon("minecraft:clock", status, Color::Yellow);
|
||||
cells[39] = icon(
|
||||
"minecraft:paper",
|
||||
format!("{} bet(s) in this round", self.bets.len()),
|
||||
Color::White,
|
||||
);
|
||||
let past: Vec<String> = self.history.iter().take(6).map(|h| times(*h)).collect();
|
||||
cells[40] = icon(
|
||||
"minecraft:map",
|
||||
if past.is_empty() {
|
||||
"Last crashes: none yet".to_owned()
|
||||
} else {
|
||||
format!("Last crashes: {}", past.join(", "))
|
||||
},
|
||||
Color::Gray,
|
||||
);
|
||||
cells[42] = icon(
|
||||
"minecraft:repeater",
|
||||
match seat.crash_auto {
|
||||
0 => "Auto cash-out: off (click to set)".to_owned(),
|
||||
a => format!("Auto cash-out at {} (click to change)", times(a)),
|
||||
},
|
||||
Color::Aqua,
|
||||
);
|
||||
let mine = self.bets.iter().find(|b| b.stake.uuid == uuid);
|
||||
cells[53] = match (self.phase, mine) {
|
||||
(Phase::Flying { .. }, Some(b)) => icon(
|
||||
"minecraft:gold_block",
|
||||
format!(
|
||||
"CASH OUT at {}: {}",
|
||||
times(top),
|
||||
b.stake.amount * i64::from(top) / 100
|
||||
),
|
||||
Color::Gold,
|
||||
),
|
||||
(Phase::Idle | Phase::Betting { .. }, Some(b)) => icon(
|
||||
"minecraft:lime_stained_glass",
|
||||
format!("Your bet is in: {}", b.stake.amount),
|
||||
Color::Green,
|
||||
),
|
||||
(Phase::Idle | Phase::Betting { .. }, None) => {
|
||||
icon("minecraft:lime_concrete", "BET", Color::Green)
|
||||
}
|
||||
_ => icon(
|
||||
"minecraft:gray_concrete",
|
||||
"Wait for the next round",
|
||||
Color::Gray,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::smp_casino::{Fx, Game, POT, View, tests::*};
|
||||
use super::*;
|
||||
|
||||
fn stake(uuid: u128, amount: i64) -> Stake {
|
||||
Stake {
|
||||
uuid,
|
||||
name: "x".into(),
|
||||
game: Game::Crash,
|
||||
amount,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_and_its_inverse() {
|
||||
assert_eq!(multiple_at(0), 100);
|
||||
assert_eq!(multiple_at(8000), 200);
|
||||
assert_eq!(multiple_at(16_000), 400);
|
||||
for h in [100, 101, 150, 199, 200, 231, 1000, 10_000] {
|
||||
let t = time_to(h);
|
||||
assert!(
|
||||
multiple_at(t) >= h && (t == 0 || multiple_at(t - 1) < h),
|
||||
"{h}"
|
||||
);
|
||||
}
|
||||
assert_eq!(times(231), "2.31x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crash_points_follow_96_over_x() {
|
||||
let mut rng = Rng::from_seed([3; 32]);
|
||||
let n = 200_000;
|
||||
let points: Vec<u32> = (0..n).map(|_| draw(&mut rng, 4, 100)).collect();
|
||||
let share = |x: u32| points.iter().filter(|p| **p >= x).count() as f64 / n as f64;
|
||||
for x in [100, 150, 200, 500, 1000] {
|
||||
let expected = if x == 100 {
|
||||
1.0
|
||||
} else {
|
||||
0.96 * 100.0 / f64::from(x)
|
||||
};
|
||||
assert!(
|
||||
(share(x) - expected).abs() < 0.01,
|
||||
"{x}: {} vs {expected}",
|
||||
share(x)
|
||||
);
|
||||
}
|
||||
let instant = points.iter().filter(|p| **p == 100).count() as f64 / n as f64;
|
||||
// Below 1.01x, shown as 1.00x: 1 - 96/101.
|
||||
assert!((instant - 0.0495).abs() < 0.005, "{instant}");
|
||||
assert!(points.iter().all(|p| (100..=10_000).contains(p)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_round_cashes_out_by_hand_and_by_itself_then_crashes() {
|
||||
let mut rng = Rng::from_seed([3; 32]);
|
||||
let mut c = Crash::default();
|
||||
c.join(stake(A, 100), 0, 0);
|
||||
c.join(stake(B, 100), 150, 1000);
|
||||
assert_eq!(c.phase, Phase::Betting { ends: BETTING_MS });
|
||||
assert!(c.cash_out(A, 5000).is_none(), "not before the flight");
|
||||
// A crash at 3.00x.
|
||||
|
||||
c.phase = Phase::Flying {
|
||||
started: BETTING_MS,
|
||||
crash: 300,
|
||||
crash_at: BETTING_MS + time_to(300),
|
||||
};
|
||||
let s = c.step(BETTING_MS + time_to(150), &mut rng, 4, 100);
|
||||
assert_eq!(s.cashed.len(), 1);
|
||||
assert_eq!((s.cashed[0].0.stake.uuid, s.cashed[0].1), (B, 150));
|
||||
let (bet, m) = c.cash_out(A, BETTING_MS + 8000).unwrap();
|
||||
assert_eq!((bet.stake.uuid, m), (A, 200));
|
||||
assert!(c.cash_out(A, BETTING_MS + 8100).is_none(), "once");
|
||||
c.join(stake(B, 50), 0, 0);
|
||||
let s = c.step(BETTING_MS + time_to(300), &mut rng, 4, 100);
|
||||
let (crash, lost) = s.crashed.unwrap();
|
||||
assert_eq!((crash, lost.len()), (300, 1));
|
||||
assert!(
|
||||
c.cash_out(B, BETTING_MS + time_to(300)).is_none(),
|
||||
"too late"
|
||||
);
|
||||
c.step(BETTING_MS + time_to(300) + SHOWN_MS, &mut rng, 4, 100);
|
||||
assert_eq!(c.phase, Phase::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn through_the_casino_every_bet_is_paid_once_and_leaving_cashes_out() {
|
||||
let (mut e, mut c, dir) = setup("crash", 100_000);
|
||||
let before = money(&e);
|
||||
for (u, n) in [(A, "Alex"), (B, "Steve")] {
|
||||
c.sit(u, n, View::Crash);
|
||||
c.seats.get_mut(&u).unwrap().window = Some(7);
|
||||
c.click(&mut e, u, 53, false, 0);
|
||||
c.click(&mut e, u, 53, false, 20);
|
||||
}
|
||||
assert_eq!((e.held(A), e.held(B)), (10, 10), "double clicks bet once");
|
||||
c.tick(&mut e, BETTING_MS);
|
||||
let Phase::Flying {
|
||||
crash, crash_at, ..
|
||||
} = c.crash.phase
|
||||
else {
|
||||
panic!("{:?}", c.crash.phase)
|
||||
};
|
||||
// Steve leaves the server right away; Alex keeps flying.
|
||||
c.gone(&mut e, &[A], BETTING_MS);
|
||||
assert_eq!(e.held(B), 0);
|
||||
assert_eq!(
|
||||
e.balance(B),
|
||||
10_000 - 10 + 10 * 100 / 100,
|
||||
"cashed out at 1.00x"
|
||||
);
|
||||
// Closing the menu doesn't cash out.
|
||||
assert!(c.closed(A, 7, 0));
|
||||
let mut t = BETTING_MS;
|
||||
while t <= crash_at + SHOWN_MS {
|
||||
c.tick(&mut e, t);
|
||||
t += 50;
|
||||
}
|
||||
c.tick(&mut e, crash_at + SHOWN_MS);
|
||||
assert_eq!(e.held(A), 0);
|
||||
assert_eq!(
|
||||
e.balance(A),
|
||||
9990,
|
||||
"lost: no cash-out before {}",
|
||||
times(crash)
|
||||
);
|
||||
assert_eq!(money(&e), before);
|
||||
assert!(!c.animating());
|
||||
let _ = e.pool(POT);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_flight_is_drawn_at_take_off_and_shown_5_times_a_second() {
|
||||
let (mut e, mut c, dir) = setup("crash-frames", 100_000);
|
||||
c.sit(A, "Alex", View::Crash);
|
||||
c.seats.get_mut(&A).unwrap().window = Some(7);
|
||||
c.seats.get_mut(&A).unwrap().crash_auto = 0;
|
||||
c.click(&mut e, A, 53, false, 0);
|
||||
c.tick(&mut e, BETTING_MS);
|
||||
c.crash.phase = Phase::Flying {
|
||||
started: BETTING_MS,
|
||||
crash: 10_000,
|
||||
crash_at: BETTING_MS + time_to(10_000),
|
||||
};
|
||||
let mut shows = 0;
|
||||
for t in (BETTING_MS + 1..BETTING_MS + 2002).step_by(50) {
|
||||
shows += c
|
||||
.tick(&mut e, t)
|
||||
.iter()
|
||||
.filter(|f| matches!(f, Fx::Show(A)))
|
||||
.count();
|
||||
}
|
||||
assert_eq!(shows, 10, "2 s of flight, 10 pictures");
|
||||
// A cash-out click at 1.00x + 2 s.
|
||||
let t = BETTING_MS + 2000;
|
||||
c.click(&mut e, A, 53, false, t);
|
||||
let m = i64::from(multiple_at(2000));
|
||||
assert_eq!(e.balance(A), 10_000 - 10 + 10 * m / 100);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playing_any_way_pays_back_about_96_percent() {
|
||||
let mut rng = Rng::from_seed([3; 32]);
|
||||
let n = 100_000;
|
||||
for target in [110u32, 200, 500, 2000] {
|
||||
let mut back = 0i64;
|
||||
for _ in 0..n {
|
||||
let crash = draw(&mut rng, 4, 100);
|
||||
if target < crash {
|
||||
back += i64::from(target);
|
||||
}
|
||||
}
|
||||
let rtp = back as f64 / (n as f64 * 100.0);
|
||||
assert!((0.90..1.02).contains(&rtp), "{target}: {rtp}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
// The casino's roulette: European, one zero, 37 pockets in the wheel's real
|
||||
// order. One bet a spin (a number, red or black, odd or even, 1-18 or 19-36,
|
||||
// a dozen, a column), paid at the true odds, so the house's edge is the zero:
|
||||
// 1/37 = 2.70% on every bet. The pocket is drawn the moment the player
|
||||
// spins; the menu's top rows then show the wheel turning under a pointer,
|
||||
// fast first and slowing down to land on it (20 pictures over 4 s). Closing
|
||||
// the menu or leaving doesn't stop it: the round ends and is paid all the
|
||||
// same. Added to the game as `game::smp_casino_roulette`; also a module of
|
||||
// this crate, for its tests.
|
||||
|
||||
use super::smp_casino::{Color, FRAME_MS, Icon, Rng, Seat, Stake, View, filler, icon};
|
||||
|
||||
/// The pockets in the order they sit on the wheel.
|
||||
pub const WHEEL: [u8; 37] = [
|
||||
0, 32, 15, 19, 4, 21, 2, 25, 17, 34, 6, 27, 13, 36, 11, 30, 8, 23, 10, 5, 24, 16, 33, 1, 20,
|
||||
14, 31, 9, 22, 18, 29, 7, 28, 12, 35, 3, 26,
|
||||
];
|
||||
const RED: [u8; 18] = [
|
||||
1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36,
|
||||
];
|
||||
/// Pictures of a spin, `FRAME_MS` apart.
|
||||
pub const FRAMES: i64 = 20;
|
||||
|
||||
pub const HELP: [&str; 2] = [
|
||||
"Roulette (European, one zero, 37 pockets): a number pays 35 to 1; a dozen or a column 2 to 1; red, black, odd, even, 1-18, 19-36 1 to 1.",
|
||||
"Zero loses every bet but a bet on 0. House edge 2.70% on every bet (97.3% paid back on average).",
|
||||
];
|
||||
|
||||
pub fn is_red(n: u8) -> bool {
|
||||
RED.contains(&n)
|
||||
}
|
||||
|
||||
/// "17 Black".
|
||||
pub fn pocket_name(n: u8) -> String {
|
||||
let colour = match n {
|
||||
0 => "Green",
|
||||
n if is_red(n) => "Red",
|
||||
_ => "Black",
|
||||
};
|
||||
format!("{n} {colour}")
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Bet {
|
||||
Number(u8),
|
||||
Red,
|
||||
Black,
|
||||
Odd,
|
||||
Even,
|
||||
Low,
|
||||
High,
|
||||
/// 1: 1-12, 2: 13-24, 3: 25-36.
|
||||
Dozen(u8),
|
||||
/// 1: 1, 4, 7... 34; 2: 2, 5... 35; 3: 3, 6... 36.
|
||||
Column(u8),
|
||||
}
|
||||
|
||||
impl Bet {
|
||||
pub fn wins(self, n: u8) -> bool {
|
||||
if n == 0 {
|
||||
return self == Bet::Number(0);
|
||||
}
|
||||
match self {
|
||||
Bet::Number(k) => k == n,
|
||||
Bet::Red => is_red(n),
|
||||
Bet::Black => !is_red(n),
|
||||
Bet::Odd => !n.is_multiple_of(2),
|
||||
Bet::Even => n.is_multiple_of(2),
|
||||
Bet::Low => n <= 18,
|
||||
Bet::High => n >= 19,
|
||||
Bet::Dozen(d) => (n - 1) / 12 + 1 == d,
|
||||
Bet::Column(c) => (n - 1) % 3 + 1 == c,
|
||||
}
|
||||
}
|
||||
/// What a winning bet pays back for each coin staked, the stake included.
|
||||
pub fn multiple(self) -> i64 {
|
||||
match self {
|
||||
Bet::Number(_) => 36,
|
||||
Bet::Dozen(_) | Bet::Column(_) => 3,
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
/// What the bet pays back on pocket `n`: 0 when it loses.
|
||||
pub fn payout(self, n: u8, stake: i64) -> i64 {
|
||||
if self.wins(n) {
|
||||
stake * self.multiple()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> String {
|
||||
match self {
|
||||
Bet::Number(n) => format!("{n}"),
|
||||
Bet::Red => "Red".into(),
|
||||
Bet::Black => "Black".into(),
|
||||
Bet::Odd => "Odd".into(),
|
||||
Bet::Even => "Even".into(),
|
||||
Bet::Low => "1 to 18".into(),
|
||||
Bet::High => "19 to 36".into(),
|
||||
Bet::Dozen(d) => format!(
|
||||
"{} 12",
|
||||
["1st", "2nd", "3rd"][usize::from(d.clamp(1, 3) - 1)]
|
||||
),
|
||||
Bet::Column(c) => format!("Column {c}"),
|
||||
}
|
||||
}
|
||||
fn odds(self) -> &'static str {
|
||||
match self.multiple() {
|
||||
36 => "35 to 1",
|
||||
3 => "2 to 1",
|
||||
_ => "1 to 1",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A player's table: the bet chosen, where the wheel stopped, the last
|
||||
/// pocket and whether it won.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Table {
|
||||
pub bet: Option<Bet>,
|
||||
/// The wheel's index under the pointer.
|
||||
pub at: usize,
|
||||
pub last: Option<(u8, bool)>,
|
||||
}
|
||||
|
||||
/// A spin: drawn when it starts, paid when its last picture is shown.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Spin {
|
||||
pub stake: Stake,
|
||||
pub bet: Bet,
|
||||
/// The wheel's index under the pointer at the start, and where it stops.
|
||||
pub from: usize,
|
||||
pub stop: usize,
|
||||
/// Pockets the wheel passes: two turns and the way to `stop`.
|
||||
pub total: i64,
|
||||
pub started: i64,
|
||||
}
|
||||
|
||||
impl Spin {
|
||||
/// The wheel's index under the pointer at frame `f` (0..=FRAMES): an
|
||||
/// ease-out, fast first, the last pockets one by one.
|
||||
pub fn position(&self, f: i64) -> usize {
|
||||
let f = f.clamp(0, FRAMES);
|
||||
let done = self.total * (FRAMES * FRAMES - (FRAMES - f) * (FRAMES - f)) / (FRAMES * FRAMES);
|
||||
(self.from + done as usize) % 37
|
||||
}
|
||||
fn frame(&self, now: i64) -> i64 {
|
||||
((now - self.started) / FRAME_MS).clamp(0, FRAMES)
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub fn test(stake: Stake) -> Self {
|
||||
Self {
|
||||
stake,
|
||||
bet: Bet::Red,
|
||||
from: 0,
|
||||
stop: 0,
|
||||
total: 74,
|
||||
started: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The spins under way.
|
||||
#[derive(Default)]
|
||||
pub struct Roulette {
|
||||
pub rounds: Vec<Spin>,
|
||||
}
|
||||
|
||||
impl Roulette {
|
||||
pub fn busy(&self) -> bool {
|
||||
!self.rounds.is_empty()
|
||||
}
|
||||
pub fn playing(&self, uuid: u128) -> bool {
|
||||
self.rounds.iter().any(|s| s.stake.uuid == uuid)
|
||||
}
|
||||
/// The bet is held: the pocket is drawn now, the wheel starts at `from`.
|
||||
pub fn spin(&mut self, stake: Stake, bet: Bet, from: usize, now: i64, rng: &mut Rng) {
|
||||
let stop = rng.below(37) as usize;
|
||||
let from = from % 37;
|
||||
self.rounds.push(Spin {
|
||||
stake,
|
||||
bet,
|
||||
from,
|
||||
stop,
|
||||
total: 74 + ((stop + 37 - from) % 37) as i64,
|
||||
started: now,
|
||||
});
|
||||
}
|
||||
/// The picture `uuid`'s spin is at.
|
||||
pub fn frame(&self, uuid: u128, now: i64) -> Option<i64> {
|
||||
self.rounds
|
||||
.iter()
|
||||
.find(|s| s.stake.uuid == uuid)
|
||||
.map(|s| s.frame(now))
|
||||
}
|
||||
/// The spins whose wheel has stopped, each with its pocket: taken out,
|
||||
/// to be paid once.
|
||||
pub fn ended(&mut self, now: i64) -> Vec<(Spin, u8)> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < self.rounds.len() {
|
||||
if now - self.rounds[i].started >= FRAMES * FRAME_MS {
|
||||
let s = self.rounds.swap_remove(i);
|
||||
let n = WHEEL[s.stop];
|
||||
out.push((s, n));
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The table's cells (all but the bottom row).
|
||||
pub fn layout(&self, cells: &mut [Option<Icon>], uuid: u128, seat: &Seat, now: i64) {
|
||||
let t = &seat.roulette;
|
||||
if seat.view == View::RouletteNumbers {
|
||||
for n in 1..=36u8 {
|
||||
cells[usize::from(n) - 1] = pocket(n, Some(Bet::Number(n)) == t.bet);
|
||||
}
|
||||
for c in &mut cells[36..45] {
|
||||
*c = filler("minecraft:gray_stained_glass_pane");
|
||||
}
|
||||
cells[36] = icon("minecraft:arrow", "Back to the table", Color::White);
|
||||
cells[40] = pocket(0, t.bet == Some(Bet::Number(0)));
|
||||
return;
|
||||
}
|
||||
let spin = self.rounds.iter().find(|s| s.stake.uuid == uuid);
|
||||
let at = spin.map_or(t.at, |s| s.position(s.frame(now)));
|
||||
for c in &mut cells[..9] {
|
||||
*c = filler("minecraft:black_stained_glass_pane");
|
||||
}
|
||||
let pointer = match (spin, t.last) {
|
||||
(Some(_), _) => "The wheel turns...".to_owned(),
|
||||
(None, Some((n, true))) => format!("{}: you won!", pocket_name(n)),
|
||||
(None, Some((n, false))) => format!("{}: no luck", pocket_name(n)),
|
||||
(None, None) => "The ball lands under this arrow".to_owned(),
|
||||
};
|
||||
cells[4] = icon("minecraft:hopper", pointer, Color::Yellow);
|
||||
for k in 0..9 {
|
||||
let n = WHEEL[(at + 37 + k - 4) % 37];
|
||||
cells[9 + k] = pocket(n, false);
|
||||
}
|
||||
let bets: [(usize, Bet, &'static str); 15] = [
|
||||
(18, Bet::Red, "minecraft:red_wool"),
|
||||
(19, Bet::Black, "minecraft:black_wool"),
|
||||
(20, Bet::Odd, "minecraft:white_wool"),
|
||||
(21, Bet::Even, "minecraft:gray_wool"),
|
||||
(22, Bet::Low, "minecraft:cyan_terracotta"),
|
||||
(23, Bet::High, "minecraft:purple_terracotta"),
|
||||
(24, Bet::Dozen(1), "minecraft:light_blue_concrete"),
|
||||
(25, Bet::Dozen(2), "minecraft:light_blue_concrete"),
|
||||
(26, Bet::Dozen(3), "minecraft:light_blue_concrete"),
|
||||
(27, Bet::Column(1), "minecraft:yellow_concrete"),
|
||||
(28, Bet::Column(2), "minecraft:yellow_concrete"),
|
||||
(29, Bet::Column(3), "minecraft:yellow_concrete"),
|
||||
(33, Bet::Number(0), "minecraft:lime_concrete"),
|
||||
(30, Bet::Low, ""),
|
||||
(31, Bet::Low, ""),
|
||||
];
|
||||
for (slot, bet, item) in bets {
|
||||
if item.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let chosen = t.bet == Some(bet);
|
||||
let name = format!(
|
||||
"{}{} (pays {})",
|
||||
if chosen { "» " } else { "" },
|
||||
bet.label(),
|
||||
bet.odds()
|
||||
);
|
||||
let mut i = icon(
|
||||
item,
|
||||
name,
|
||||
if chosen { Color::Yellow } else { Color::White },
|
||||
);
|
||||
if let (Some(i), Bet::Dozen(k) | Bet::Column(k)) = (i.as_mut(), bet) {
|
||||
i.count = k;
|
||||
}
|
||||
cells[slot] = i;
|
||||
}
|
||||
for slot in [30, 32, 34, 35] {
|
||||
cells[slot] = filler("minecraft:gray_stained_glass_pane");
|
||||
}
|
||||
let number = match t.bet {
|
||||
Some(Bet::Number(n)) if n > 0 => format!("» Number {n} (pays 35 to 1): pick another"),
|
||||
_ => "A single number (pays 35 to 1)...".into(),
|
||||
};
|
||||
cells[31] = icon("minecraft:target", number, Color::White);
|
||||
let shade = match (spin.is_some(), t.last) {
|
||||
(false, Some((_, true))) => "minecraft:lime_stained_glass_pane",
|
||||
(false, Some((_, false))) => "minecraft:red_stained_glass_pane",
|
||||
_ => "minecraft:gray_stained_glass_pane",
|
||||
};
|
||||
for c in &mut cells[36..45] {
|
||||
*c = filler(shade);
|
||||
}
|
||||
cells[40] = icon(
|
||||
"minecraft:paper",
|
||||
match t.bet {
|
||||
Some(b) => format!("Your bet: {} (pays {})", b.label(), b.odds()),
|
||||
None => "Pick a bet above, then spin".into(),
|
||||
},
|
||||
Color::White,
|
||||
);
|
||||
cells[53] = if spin.is_some() {
|
||||
icon("minecraft:gray_concrete", "The wheel turns...", Color::Gray)
|
||||
} else {
|
||||
icon("minecraft:lime_concrete", "SPIN", Color::Green)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// A pocket as a cell: its colour, its number as the count.
|
||||
fn pocket(n: u8, chosen: bool) -> Option<Icon> {
|
||||
let item = match n {
|
||||
0 => "minecraft:lime_concrete",
|
||||
n if is_red(n) => "minecraft:red_concrete",
|
||||
_ => "minecraft:black_concrete",
|
||||
};
|
||||
let name = format!("{}{}", if chosen { "» " } else { "" }, pocket_name(n));
|
||||
let mut i = icon(
|
||||
item,
|
||||
name,
|
||||
if chosen { Color::Yellow } else { Color::White },
|
||||
);
|
||||
if let Some(i) = i.as_mut() {
|
||||
i.count = n.max(1);
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// What a click on the table does.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Click {
|
||||
Nothing,
|
||||
Show,
|
||||
Spin,
|
||||
}
|
||||
|
||||
/// A click on the table (not its bottom row).
|
||||
pub fn click(seat: &mut Seat, slot: usize) -> Click {
|
||||
let t = &mut seat.roulette;
|
||||
if seat.view == View::RouletteNumbers {
|
||||
let bet = match slot {
|
||||
0..36 => Bet::Number(slot as u8 + 1),
|
||||
40 => Bet::Number(0),
|
||||
36 => {
|
||||
seat.view = View::Roulette;
|
||||
return Click::Show;
|
||||
}
|
||||
_ => return Click::Nothing,
|
||||
};
|
||||
t.bet = Some(bet);
|
||||
seat.view = View::Roulette;
|
||||
return Click::Show;
|
||||
}
|
||||
let bet = match slot {
|
||||
18 => Bet::Red,
|
||||
19 => Bet::Black,
|
||||
20 => Bet::Odd,
|
||||
21 => Bet::Even,
|
||||
22 => Bet::Low,
|
||||
23 => Bet::High,
|
||||
24..=26 => Bet::Dozen(slot as u8 - 23),
|
||||
27..=29 => Bet::Column(slot as u8 - 26),
|
||||
33 => Bet::Number(0),
|
||||
31 => {
|
||||
seat.view = View::RouletteNumbers;
|
||||
return Click::Show;
|
||||
}
|
||||
53 => return Click::Spin,
|
||||
_ => return Click::Nothing,
|
||||
};
|
||||
t.bet = Some(bet);
|
||||
Click::Show
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::smp_casino::{Fx, Game, POT, tests::*};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_wheel_holds_each_number_once_and_the_colours_alternate() {
|
||||
let mut seen = WHEEL.to_vec();
|
||||
seen.sort_unstable();
|
||||
assert_eq!(seen, (0..=36).collect::<Vec<u8>>());
|
||||
for pair in WHEEL[1..].windows(2) {
|
||||
assert_ne!(is_red(pair[0]), is_red(pair[1]), "{pair:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_bet_pays_its_true_odds_and_the_edge_is_one_in_37() {
|
||||
let mut bets = vec![
|
||||
Bet::Red,
|
||||
Bet::Black,
|
||||
Bet::Odd,
|
||||
Bet::Even,
|
||||
Bet::Low,
|
||||
Bet::High,
|
||||
];
|
||||
bets.extend((1..=3).flat_map(|k| [Bet::Dozen(k), Bet::Column(k)]));
|
||||
bets.extend((0..=36).map(Bet::Number));
|
||||
for bet in bets {
|
||||
let winners = (0..=36u8).filter(|n| bet.wins(*n)).count() as i64;
|
||||
assert_eq!(
|
||||
winners * bet.multiple(),
|
||||
36,
|
||||
"{bet:?}: 36 of 37 coins paid back"
|
||||
);
|
||||
// Over the 37 pockets, a stake of 37 x 100 gets back 36 x 100.
|
||||
let back: i64 = (0..=36).map(|n| bet.payout(n, 100)).sum();
|
||||
assert_eq!(back, 3600, "{bet:?}");
|
||||
}
|
||||
assert!(!Bet::Red.wins(0) && !Bet::Even.wins(0) && !Bet::Low.wins(0));
|
||||
assert!(Bet::Column(1).wins(34) && Bet::Column(3).wins(36) && Bet::Dozen(3).wins(25));
|
||||
assert_eq!(Bet::Number(17).payout(17, 10), 360);
|
||||
assert_eq!(Bet::Dozen(2).payout(13, 10), 30);
|
||||
assert_eq!(Bet::Black.payout(17, 10), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_slows_down_and_lands_on_the_drawn_pocket() {
|
||||
let mut rng = Rng::from_seed([3; 32]);
|
||||
for from in [0, 5, 36] {
|
||||
for _ in 0..50 {
|
||||
let mut r = Roulette::default();
|
||||
let stake = Stake {
|
||||
uuid: A,
|
||||
name: "Alex".into(),
|
||||
game: Game::Roulette,
|
||||
amount: 10,
|
||||
};
|
||||
r.spin(stake, Bet::Red, from, 0, &mut rng);
|
||||
let s = &r.rounds[0];
|
||||
assert_eq!(s.position(0), from);
|
||||
assert_eq!(s.position(FRAMES), s.stop);
|
||||
let steps: Vec<i64> = (1..=FRAMES)
|
||||
.map(|f| {
|
||||
let done = |f: i64| {
|
||||
s.total * (FRAMES * FRAMES - (FRAMES - f).pow(2)) / (FRAMES * FRAMES)
|
||||
};
|
||||
done(f) - done(f - 1)
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
steps.windows(2).all(|w| w[0] >= w[1] - 1),
|
||||
"slows down: {steps:?}"
|
||||
);
|
||||
assert!(steps[0] >= 7 && *steps.last().unwrap() <= 1, "{steps:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spin_through_the_menu_pays_once_even_when_the_menu_closes() {
|
||||
let (mut e, mut c, dir) = setup("roulette", 50_000);
|
||||
let before = money(&e);
|
||||
c.sit(A, "Alex", View::Roulette);
|
||||
c.seats.get_mut(&A).unwrap().window = Some(3);
|
||||
assert!(
|
||||
c.click(&mut e, A, 53, false, 0)
|
||||
.iter()
|
||||
.any(|f| matches!(f, Fx::Bar { .. })),
|
||||
"no bet yet"
|
||||
);
|
||||
c.click(&mut e, A, 49, false, 0);
|
||||
assert_eq!(c.seats[&A].stake, 25);
|
||||
c.click(&mut e, A, 18, false, 0);
|
||||
assert_eq!(c.seats[&A].roulette.bet, Some(Bet::Red));
|
||||
c.click(&mut e, A, 53, false, 0);
|
||||
// A double click: the second spin is refused, nothing more held.
|
||||
c.click(&mut e, A, 53, false, 50);
|
||||
assert_eq!(e.held(A), 25);
|
||||
// Frames: one picture per 200 ms, not per tick.
|
||||
let shows = |fx: &[Fx]| fx.iter().filter(|f| matches!(f, Fx::Show(A))).count();
|
||||
assert_eq!(
|
||||
shows(&c.tick(&mut e, 10)),
|
||||
0,
|
||||
"the spin's click showed frame 0"
|
||||
);
|
||||
assert_eq!(shows(&c.tick(&mut e, 210)), 1);
|
||||
assert_eq!(shows(&c.tick(&mut e, 260)), 0);
|
||||
assert_eq!(shows(&c.tick(&mut e, 400)), 1);
|
||||
// The menu closes mid-spin: the round still ends and is paid, once.
|
||||
assert!(c.closed(A, 3, 0));
|
||||
let n = WHEEL[c.roulette.rounds[0].stop];
|
||||
c.tick(&mut e, 3999);
|
||||
assert_eq!(e.held(A), 25, "not before the wheel stops");
|
||||
c.tick(&mut e, 4000);
|
||||
c.tick(&mut e, 4200);
|
||||
assert_eq!(e.held(A), 0);
|
||||
let expected = 10_000 - 25 + Bet::Red.payout(n, 25);
|
||||
assert_eq!(e.balance(A), expected);
|
||||
assert_eq!(money(&e), before);
|
||||
assert_eq!(c.ledger.totals["roulette"].rounds, 1);
|
||||
assert!(!c.animating());
|
||||
let _ = e.pool(POT);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_spins_keep_about_the_house_edge() {
|
||||
let (mut e, mut c, dir) = setup("edge", 1_000_000_000);
|
||||
c.config.daily_loss_cap = i64::MAX / 4;
|
||||
c.config.daily_win_cap = i64::MAX / 4;
|
||||
c.config.cooldown_ms = 0;
|
||||
let mut rng = Rng::from_seed([3; 32]);
|
||||
let mut now = 0;
|
||||
for i in 0..20_000 {
|
||||
if e.balance(A) < 100 {
|
||||
e.adjust("eco_give", A, 1_000_000, "test").unwrap();
|
||||
}
|
||||
let stake = c.bet(&mut e, A, "Alex", Game::Roulette, 10, now).unwrap();
|
||||
// Even-money bets: little spread, so 20,000 spins show the edge.
|
||||
let bet = if i % 2 == 0 { Bet::Red } else { Bet::Odd };
|
||||
c.roulette.spin(stake, bet, 0, now, &mut rng);
|
||||
now += FRAMES * FRAME_MS;
|
||||
c.tick(&mut e, now);
|
||||
}
|
||||
let t = &c.ledger.totals["roulette"];
|
||||
let rtp = t.paid as f64 / t.wagered as f64;
|
||||
assert!((0.94..1.0).contains(&rtp), "{rtp}");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
// The casino's slot machine: three reels of 32 stops with item symbols, one
|
||||
// payline (the middle row). Each reel's stop is drawn when the player pulls
|
||||
// the lever; the reels then scroll in the menu and stop one after another
|
||||
// (at 1.6, 2.4 and 3.2 s), and the line is paid from the published paytable:
|
||||
// three of a kind, or two berries anywhere on it. The reels hold each symbol
|
||||
// a fixed number of times, so the odds are exact: 94.2% of what is staked is
|
||||
// paid back on average (the test enumerates all 32,768 lines), and one pull
|
||||
// in 4.5 pays. Closing the menu or leaving doesn't stop a pull: it ends and
|
||||
// is paid all the same. Added to the game as `game::smp_casino_slots`; also
|
||||
// a module of this crate, for its tests.
|
||||
|
||||
use super::smp_casino::{Color, FRAME_MS, Icon, Rng, Seat, Stake, filler, icon};
|
||||
|
||||
/// The symbols: item, name, stops on each reel, what three on the line pay
|
||||
/// for each coin staked (the stake included).
|
||||
pub const SYMBOLS: [(&str, &str, usize, i64); 6] = [
|
||||
("minecraft:sweet_berries", "Berries", 9, 6),
|
||||
("minecraft:melon_slice", "Melon", 8, 10),
|
||||
("minecraft:golden_apple", "Golden Apple", 7, 15),
|
||||
("minecraft:emerald", "Emerald", 5, 30),
|
||||
("minecraft:diamond", "Diamond", 2, 100),
|
||||
("minecraft:nether_star", "Nether Star", 1, 500),
|
||||
];
|
||||
/// Two berries on the line (not three) pay this for each coin staked.
|
||||
pub const TWO_BERRIES: i64 = 2;
|
||||
const BERRIES: u8 = 0;
|
||||
pub const STOPS: usize = 32;
|
||||
|
||||
/// The reels, by stop: indices into `SYMBOLS`, no symbol twice in a row.
|
||||
pub const REELS: [[u8; STOPS]; 3] = [
|
||||
[
|
||||
1, 0, 2, 3, 1, 4, 0, 1, 3, 1, 0, 5, 1, 2, 0, 1, 0, 2, 0, 2, 3, 2, 3, 2, 0, 1, 2, 1, 0, 4,
|
||||
3, 0,
|
||||
],
|
||||
[
|
||||
1, 5, 0, 1, 0, 1, 2, 3, 1, 0, 2, 3, 0, 3, 2, 4, 2, 1, 0, 1, 2, 0, 3, 2, 4, 1, 0, 2, 3, 0,
|
||||
1, 0,
|
||||
],
|
||||
[
|
||||
0, 1, 4, 3, 1, 0, 2, 0, 2, 3, 2, 0, 3, 0, 2, 1, 0, 4, 1, 2, 1, 2, 1, 3, 0, 1, 0, 2, 3, 0,
|
||||
1, 5,
|
||||
],
|
||||
];
|
||||
/// The frame each reel stops at; the pull is paid at the last.
|
||||
pub const STOP_AT: [i64; 3] = [8, 12, 16];
|
||||
pub const FRAMES: i64 = 16;
|
||||
|
||||
pub const HELP: [&str; 2] = [
|
||||
"Slots (3 reels of 32 stops, one line): three Nether Stars pay 500x, Diamonds 100x, Emeralds 30x, Golden Apples 15x, Melons 10x, Berries 6x; two Berries 2x (the stake included).",
|
||||
"Each reel holds 9 Berries, 8 Melons, 7 Golden Apples, 5 Emeralds, 2 Diamonds, 1 Nether Star: 94.2% paid back on average (house edge 5.8%), one pull in 4.5 pays.",
|
||||
];
|
||||
|
||||
/// What a line pays for each coin staked: 0 when it loses.
|
||||
pub fn multiple(line: [u8; 3]) -> i64 {
|
||||
if line[0] == line[1] && line[1] == line[2] {
|
||||
return SYMBOLS[usize::from(line[0])].3;
|
||||
}
|
||||
if line.iter().filter(|s| **s == BERRIES).count() == 2 {
|
||||
return TWO_BERRIES;
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
pub fn line(stops: [usize; 3]) -> [u8; 3] {
|
||||
[0, 1, 2].map(|r| REELS[r][stops[r] % STOPS])
|
||||
}
|
||||
|
||||
/// "Diamond · Diamond · Melon".
|
||||
pub fn line_name(stops: [usize; 3]) -> String {
|
||||
line(stops).map(|s| SYMBOLS[usize::from(s)].1).join(" · ")
|
||||
}
|
||||
|
||||
/// A player's machine: where its reels stand and whether the last pull won.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Machine {
|
||||
pub stops: [usize; 3],
|
||||
pub last: Option<bool>,
|
||||
}
|
||||
|
||||
/// A pull: drawn when it starts, paid when the last reel stops.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Pull {
|
||||
pub stake: Stake,
|
||||
pub stops: [usize; 3],
|
||||
pub started: i64,
|
||||
}
|
||||
|
||||
impl Pull {
|
||||
fn frame(&self, now: i64) -> i64 {
|
||||
((now - self.started) / FRAME_MS).clamp(0, FRAMES)
|
||||
}
|
||||
/// Reel `r`'s stop at frame `f`: scrolling, slower as it nears its stop.
|
||||
pub fn position(&self, r: usize, f: i64) -> usize {
|
||||
let left = (STOP_AT[r] - f).max(0);
|
||||
let behind = (left * (left + 1) / 2) as usize;
|
||||
(self.stops[r] + STOPS * 8 - behind % STOPS) % STOPS
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Slots {
|
||||
pub rounds: Vec<Pull>,
|
||||
}
|
||||
|
||||
impl Slots {
|
||||
pub fn busy(&self) -> bool {
|
||||
!self.rounds.is_empty()
|
||||
}
|
||||
pub fn playing(&self, uuid: u128) -> bool {
|
||||
self.rounds.iter().any(|p| p.stake.uuid == uuid)
|
||||
}
|
||||
pub fn pull(&mut self, stake: Stake, now: i64, rng: &mut Rng) {
|
||||
let stops = [0; 3].map(|_| rng.below(STOPS as u64) as usize);
|
||||
self.rounds.push(Pull {
|
||||
stake,
|
||||
stops,
|
||||
started: now,
|
||||
});
|
||||
}
|
||||
/// The picture `uuid`'s pull is at, and whether a reel stops on it.
|
||||
pub fn frame(&self, uuid: u128, now: i64) -> Option<(i64, bool)> {
|
||||
let p = self.rounds.iter().find(|p| p.stake.uuid == uuid)?;
|
||||
let f = p.frame(now);
|
||||
Some((f, STOP_AT.contains(&f)))
|
||||
}
|
||||
/// The pulls whose last reel stopped: taken out, to be paid once.
|
||||
pub fn ended(&mut self, now: i64) -> Vec<Pull> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < self.rounds.len() {
|
||||
if now - self.rounds[i].started >= FRAMES * FRAME_MS {
|
||||
out.push(self.rounds.swap_remove(i));
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The machine's cells (all but the bottom row).
|
||||
pub fn layout(&self, cells: &mut [Option<Icon>], uuid: u128, seat: &Seat, now: i64) {
|
||||
let m = &seat.slots;
|
||||
let pull = self.rounds.iter().find(|p| p.stake.uuid == uuid);
|
||||
for c in &mut cells[..45] {
|
||||
*c = filler("minecraft:black_stained_glass_pane");
|
||||
}
|
||||
for c in &mut cells[..9] {
|
||||
*c = filler("minecraft:yellow_stained_glass_pane");
|
||||
}
|
||||
let top = match (pull, m.last) {
|
||||
(Some(_), _) => "The reels turn...".to_owned(),
|
||||
(None, Some(true)) => format!("{}: you won!", line_name(m.stops)),
|
||||
(None, Some(false)) => format!("{}: no luck", line_name(m.stops)),
|
||||
(None, None) => "Three of a kind on the middle row wins".to_owned(),
|
||||
};
|
||||
cells[4] = icon("minecraft:bell", top, Color::Yellow);
|
||||
let won = pull.is_none() && m.last == Some(true);
|
||||
let marker = if won {
|
||||
"minecraft:gold_block"
|
||||
} else {
|
||||
"minecraft:lime_stained_glass_pane"
|
||||
};
|
||||
cells[18] = icon(marker, "Payline →", Color::Green);
|
||||
cells[26] = icon(marker, "← Payline", Color::Green);
|
||||
for (r, col) in [2usize, 4, 6].into_iter().enumerate() {
|
||||
let at = match pull {
|
||||
Some(p) => p.position(r, p.frame(now)),
|
||||
None => m.stops[r],
|
||||
};
|
||||
for (row, d) in [(1usize, STOPS - 1), (2, 0), (3, 1)] {
|
||||
let s = SYMBOLS[usize::from(REELS[r][(at + d) % STOPS])];
|
||||
let name = if row == 2 {
|
||||
s.1.to_owned()
|
||||
} else {
|
||||
" ".to_owned()
|
||||
};
|
||||
cells[row * 9 + col] = icon(
|
||||
s.0,
|
||||
name,
|
||||
if row == 2 {
|
||||
Color::White
|
||||
} else {
|
||||
Color::DarkGray
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
// The paytable, on the fifth row.
|
||||
for (i, (item, name, _, pays)) in SYMBOLS.iter().enumerate().rev() {
|
||||
cells[36 + (5 - i)] = icon(item, format!("3 {name}: {pays}x"), Color::Gold);
|
||||
}
|
||||
cells[42] = icon(
|
||||
"minecraft:sweet_berries",
|
||||
format!("2 Berries: {TWO_BERRIES}x"),
|
||||
Color::Gold,
|
||||
);
|
||||
cells[43] = icon("minecraft:book", "94.2% paid back on average", Color::Aqua);
|
||||
cells[53] = if pull.is_some() {
|
||||
icon("minecraft:gray_concrete", "The reels turn...", Color::Gray)
|
||||
} else {
|
||||
icon("minecraft:lever", "PULL", Color::Green)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::smp_casino::{Fx, Game, View, tests::*};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn each_reel_holds_the_published_stops() {
|
||||
for reel in REELS {
|
||||
for (i, s) in SYMBOLS.iter().enumerate() {
|
||||
assert_eq!(
|
||||
reel.iter().filter(|x| usize::from(**x) == i).count(),
|
||||
s.2,
|
||||
"{}",
|
||||
s.1
|
||||
);
|
||||
}
|
||||
for k in 0..STOPS {
|
||||
assert_ne!(reel[k], reel[(k + 1) % STOPS], "no symbol twice in a row");
|
||||
}
|
||||
}
|
||||
assert_eq!(SYMBOLS.iter().map(|s| s.2).sum::<usize>(), STOPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_paytable_pays_back_94_2_percent() {
|
||||
let (mut back, mut hits) = (0i64, 0);
|
||||
for a in 0..STOPS {
|
||||
for b in 0..STOPS {
|
||||
for c in 0..STOPS {
|
||||
let m = multiple(line([a, b, c]));
|
||||
back += m;
|
||||
hits += i64::from(m > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let lines = (STOPS * STOPS * STOPS) as f64;
|
||||
let rtp = back as f64 / lines;
|
||||
assert!((0.92..=0.95).contains(&rtp), "{rtp}");
|
||||
assert_eq!(format!("{:.1}", rtp * 100.0), "94.2");
|
||||
assert_eq!(format!("{:.1}", lines / hits as f64), "4.5");
|
||||
assert_eq!(multiple([5, 5, 5]), 500);
|
||||
assert_eq!(multiple([0, 3, 0]), 2);
|
||||
assert_eq!(multiple([0, 0, 0]), 6);
|
||||
assert_eq!(multiple([0, 3, 4]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reels_scroll_and_stop_one_after_another_on_the_draw() {
|
||||
let p = Pull {
|
||||
stake: Stake {
|
||||
uuid: A,
|
||||
name: "Alex".into(),
|
||||
game: Game::Slots,
|
||||
amount: 10,
|
||||
},
|
||||
stops: [3, 17, 31],
|
||||
started: 0,
|
||||
};
|
||||
for (r, stop) in STOP_AT.into_iter().enumerate() {
|
||||
assert_ne!(p.position(r, stop - 1), p.stops[r], "still turning");
|
||||
for f in stop..=FRAMES {
|
||||
assert_eq!(p.position(r, f), p.stops[r]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pull_is_paid_once_and_frames_stay_at_5_a_second() {
|
||||
let (mut e, mut c, dir) = setup("slots", 100_000);
|
||||
let before = money(&e);
|
||||
c.sit(A, "Alex", View::Slots);
|
||||
c.seats.get_mut(&A).unwrap().window = Some(5);
|
||||
c.click(&mut e, A, 53, false, 0);
|
||||
c.click(&mut e, A, 53, false, 30);
|
||||
assert_eq!(e.held(A), 10, "the double click pulled once");
|
||||
let mut shows = 0;
|
||||
for t in (0..FRAMES * FRAME_MS).step_by(50) {
|
||||
shows += c
|
||||
.tick(&mut e, t)
|
||||
.iter()
|
||||
.filter(|f| matches!(f, Fx::Show(A)))
|
||||
.count();
|
||||
}
|
||||
assert_eq!(shows as i64, FRAMES - 1, "one picture per frame");
|
||||
let stops = c.slots.rounds[0].stops;
|
||||
c.tick(&mut e, FRAMES * FRAME_MS);
|
||||
assert_eq!(e.held(A), 0);
|
||||
assert_eq!(e.balance(A), 10_000 - 10 + 10 * multiple(line(stops)));
|
||||
assert_eq!(money(&e), before);
|
||||
assert!(!c.animating());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,781 @@
|
||||
// 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>,
|
||||
/// Blocks around 0 0, in every dimension, where no one may claim (free
|
||||
/// for all); 0 for none. Claims already there are dropped at start.
|
||||
pub free_radius: i64,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
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(),
|
||||
free_radius: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a chunk touches the free zone around 0 0 (any dimension).
|
||||
fn in_free_zone(&self, cx: i32, cz: i32) -> bool {
|
||||
let r = self.config.free_radius;
|
||||
let near = |c: i32| i64::from(c) * 16 <= r && i64::from(c) * 16 + 15 >= -r;
|
||||
r > 0 && near(cx) && near(cz)
|
||||
}
|
||||
|
||||
/// Drops the claims inside the free zone (a radius set after they were
|
||||
/// made): one line each, for the log.
|
||||
pub fn clear_free_zone(&mut self) -> Vec<String> {
|
||||
let inside: Vec<(String, i32, i32, u128)> = self
|
||||
.owners
|
||||
.iter()
|
||||
.flat_map(|(d, c)| {
|
||||
c.iter()
|
||||
.filter(|((x, z), _)| self.in_free_zone(*x, *z))
|
||||
.map(|((x, z), u)| (d.clone(), *x, *z, *u))
|
||||
})
|
||||
.collect();
|
||||
let mut lines = Vec::new();
|
||||
for (dimension, cx, cz, owner) in inside {
|
||||
if let Some(c) = self.owners.get_mut(&dimension) {
|
||||
c.remove(&(cx, cz));
|
||||
}
|
||||
self.data.chunks.remove(&key(&dimension, cx, cz));
|
||||
self.changed = true;
|
||||
lines.push(format!(
|
||||
"chunk {cx} {cz} (x {}, z {}) in {dimension} of {} is in the free zone: unclaimed",
|
||||
cx * 16,
|
||||
cz * 16,
|
||||
smp_store::uuid_string(owner)
|
||||
));
|
||||
}
|
||||
lines.sort();
|
||||
lines
|
||||
}
|
||||
|
||||
pub fn save(&mut self) -> Result<(), String> {
|
||||
if self.changed {
|
||||
smp_store::write_json(&self.path, &self.data)?;
|
||||
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.
|
||||
pub 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 if self.in_free_zone(cx, cz) => vec![Out::Error(format!(
|
||||
"No claims within {} blocks of 0 0: that land is free for all.",
|
||||
self.config.free_radius
|
||||
))],
|
||||
None => {
|
||||
let (held, limit) = (self.held(me.uuid), self.limit(played));
|
||||
if held >= limit {
|
||||
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 no_claims_in_the_free_zone_and_old_ones_are_dropped() {
|
||||
let d = dir();
|
||||
let mut c = Claims::open(&d, Config::default()).unwrap();
|
||||
let alex = player(A, 0);
|
||||
for (x, z) in [(8.0, 8.0), (-520.0, 8.0), (600.0, 600.0)] {
|
||||
c.command(&alex, &at(x, z), 1_000_000, &find, &name_of, "claim")
|
||||
.unwrap();
|
||||
}
|
||||
c.save().unwrap();
|
||||
let config = Config {
|
||||
free_radius: 500,
|
||||
..Config::default()
|
||||
};
|
||||
let mut c = Claims::open(&d, config).unwrap();
|
||||
// Chunk 0 0 and chunk -33 0 (x -528 to -513: outside 500) stay apart.
|
||||
assert_eq!(c.clear_free_zone().len(), 1);
|
||||
let ow = "minecraft:overworld";
|
||||
assert_eq!(c.owner(ow, 8, 8), None);
|
||||
assert_eq!(c.owner(ow, -520, 8), Some(A));
|
||||
assert_eq!(c.owner(ow, 600, 600), Some(A));
|
||||
let out = c
|
||||
.command(
|
||||
&alex,
|
||||
&at(490.0, -490.0),
|
||||
1_000_000,
|
||||
&find,
|
||||
&name_of,
|
||||
"claim",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(&out[0], Out::Error(e) if e.contains("free for all")));
|
||||
// The zone is the same in every dimension.
|
||||
let nether = Place {
|
||||
dimension: "minecraft:the_nether".into(),
|
||||
..at(8.0, 8.0)
|
||||
};
|
||||
let out = c
|
||||
.command(&alex, &nether, 1_000_000, &find, &name_of, "claim")
|
||||
.unwrap();
|
||||
assert!(matches!(&out[0], Out::Error(e) if e.contains("free for all")));
|
||||
// Saved without them.
|
||||
c.save().unwrap();
|
||||
let again = Claims::open(&d, Config::default()).unwrap();
|
||||
assert_eq!(again.owner(ow, 8, 8), None);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_world_stays_out_of_a_claim() {
|
||||
let d = dir();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,9 @@ pub struct Account {
|
||||
pub balance: i64,
|
||||
}
|
||||
|
||||
/// Milliseconds in a day.
|
||||
pub const DAY_MS: i64 = 86_400_000;
|
||||
|
||||
/// `economy.json`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Accounts {
|
||||
@@ -52,8 +55,58 @@ pub struct Accounts {
|
||||
/// it twice. Given back when the economy opens (a restart voids games).
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub held: BTreeMap<String, i64>,
|
||||
/// Other parts' state that holds money or items (the auction house's
|
||||
/// listings, bids and mail), by part: written in the same file as the
|
||||
/// balances, so one write moves both and a crash can't split them.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub ext: BTreeMap<String, serde_json::Value>,
|
||||
/// System pools, by name: the casino's pot, the auction house's bids.
|
||||
/// Money in a pool belongs to no player: never in `accounts()`,
|
||||
/// `/baltop` or the sidebar.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub pools: BTreeMap<String, i64>,
|
||||
/// Money made and destroyed by the server (`adjust`: market sales and
|
||||
/// purchases, the daily reward, operators' `/eco give` and `/eco take`),
|
||||
/// by kind, for `/eco stats`.
|
||||
#[serde(default)]
|
||||
pub flows: Flows,
|
||||
}
|
||||
|
||||
/// Money the server made (`minted`) or took out of the game (`burned`),
|
||||
/// today and the day before, by kind (the audit kind's first word).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Flows {
|
||||
pub day: i64,
|
||||
pub minted: BTreeMap<String, i64>,
|
||||
pub burned: BTreeMap<String, i64>,
|
||||
pub minted_yesterday: BTreeMap<String, i64>,
|
||||
pub burned_yesterday: BTreeMap<String, i64>,
|
||||
}
|
||||
|
||||
impl Flows {
|
||||
/// Starts `day` (moving the last one to yesterday's when it was the day
|
||||
/// before).
|
||||
pub fn roll(&mut self, day: i64) {
|
||||
if self.day == day {
|
||||
return;
|
||||
}
|
||||
let next = self.day + 1 == day;
|
||||
self.minted_yesterday = std::mem::take(&mut self.minted);
|
||||
self.burned_yesterday = std::mem::take(&mut self.burned);
|
||||
if !next {
|
||||
self.minted_yesterday.clear();
|
||||
self.burned_yesterday.clear();
|
||||
}
|
||||
self.day = day;
|
||||
}
|
||||
}
|
||||
|
||||
/// What 0.30 took out (the Treasury, its insurance fund, the bank, the
|
||||
/// futures, the jobs, the taxes): their pools and their state in `ext`.
|
||||
const RETIRED_POOLS: [&str; 4] = ["treasury", "insurance", "bank", "futures"];
|
||||
const RETIRED_PARTS: [&str; 4] = ["bank", "futures", "jobs", "tax"];
|
||||
|
||||
/// A player as a command sees them.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Player {
|
||||
@@ -80,6 +133,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,8 +153,10 @@ impl Economy {
|
||||
path,
|
||||
audit: Audit::new(dir.join("audit.log")),
|
||||
dirty: false,
|
||||
revision: 0,
|
||||
now: 0,
|
||||
};
|
||||
economy.retire()?;
|
||||
// Games the last run left undecided: their stakes go back.
|
||||
let held: Vec<(String, i64)> = economy.accounts.held.clone().into_iter().collect();
|
||||
for (key, amount) in held {
|
||||
@@ -109,6 +167,97 @@ impl Economy {
|
||||
Ok(economy)
|
||||
}
|
||||
|
||||
/// Once, on a world from before 0.30: bank savings and open futures
|
||||
/// positions' margins go back to their owners (logged `bank_closed`,
|
||||
/// `futures_closed`), and the retired parts' pools and state are dropped.
|
||||
fn retire(&mut self) -> Result<(), String> {
|
||||
let mut back: Vec<(&str, u128, i64)> = Vec::new();
|
||||
let ext = &self.accounts.ext;
|
||||
if let Some(accounts) = ext
|
||||
.get("bank")
|
||||
.and_then(|b| b.get("accounts"))
|
||||
.and_then(|a| a.as_object())
|
||||
{
|
||||
for (key, saving) in accounts {
|
||||
let amount = saving.get("amount").and_then(|a| a.as_i64()).unwrap_or(0);
|
||||
if let Some(u) = super::smp_store::parse_uuid(key) {
|
||||
back.push(("bank_closed", u, amount));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(positions) = ext
|
||||
.get("futures")
|
||||
.and_then(|f| f.get("positions"))
|
||||
.and_then(|p| p.as_array())
|
||||
{
|
||||
for p in positions {
|
||||
let owner = p
|
||||
.get("owner")
|
||||
.and_then(|o| o.as_str())
|
||||
.and_then(super::smp_store::parse_uuid);
|
||||
let margin = p.get("margin").and_then(|m| m.as_i64()).unwrap_or(0);
|
||||
if let Some(u) = owner {
|
||||
back.push(("futures_closed", u, margin));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (kind, uuid, amount) in back.into_iter().filter(|b| b.2 > 0) {
|
||||
let entry = self.entry(kind, None, Some(uuid), amount, "");
|
||||
self.audit.append(&entry)?;
|
||||
let account = self.accounts.accounts.entry(uuid_string(uuid)).or_default();
|
||||
account.balance = account.balance.saturating_add(amount);
|
||||
self.touch();
|
||||
}
|
||||
for part in RETIRED_PARTS {
|
||||
if self.accounts.ext.remove(part).is_some() {
|
||||
self.touch();
|
||||
}
|
||||
}
|
||||
for pool in RETIRED_POOLS {
|
||||
if self.accounts.pools.remove(pool).is_some() {
|
||||
self.touch();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn touch(&mut self) {
|
||||
self.dirty = true;
|
||||
self.revision += 1;
|
||||
}
|
||||
|
||||
/// 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 the server made and destroyed, by kind (rolled to `now`'s day).
|
||||
pub fn flows(&mut self) -> &Flows {
|
||||
self.accounts.flows.roll(self.now.div_euclid(DAY_MS));
|
||||
&self.accounts.flows
|
||||
}
|
||||
|
||||
/// All the money held for games.
|
||||
pub fn held_total(&self) -> i64 {
|
||||
self.accounts.held.values().sum()
|
||||
}
|
||||
|
||||
/// Every pool, by name.
|
||||
pub fn pools(&self) -> &BTreeMap<String, i64> {
|
||||
&self.accounts.pools
|
||||
}
|
||||
|
||||
/// Money `uuid` has held for a game.
|
||||
pub fn held(&self, uuid: u128) -> i64 {
|
||||
self.accounts
|
||||
@@ -139,7 +288,7 @@ impl Economy {
|
||||
.expect("checked")
|
||||
.balance -= amount;
|
||||
*self.accounts.held.entry(key).or_default() += amount;
|
||||
self.dirty = true;
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -155,23 +304,22 @@ 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(())
|
||||
}
|
||||
|
||||
/// A game decided: each stake leaves held, and `winner` gets `prize`
|
||||
/// (at most the stakes together), logged as `kind`.
|
||||
/// A game decided: each stake leaves held, and `winner` gets them all,
|
||||
/// logged as `kind`.
|
||||
pub fn award(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
stakes: &[(u128, i64)],
|
||||
winner: u128,
|
||||
prize: i64,
|
||||
) -> Result<(), String> {
|
||||
let total: i64 = stakes.iter().map(|(_, a)| *a).sum();
|
||||
if stakes.iter().any(|(u, a)| self.held(*u) < *a) || !(0..=total).contains(&prize) {
|
||||
if stakes.iter().any(|(u, a)| self.held(*u) < *a) {
|
||||
return Err("The stakes are not held.".into());
|
||||
}
|
||||
let prize: i64 = stakes.iter().map(|(_, a)| *a).sum();
|
||||
let mut entry = self.entry(kind, None, Some(winner), prize, "");
|
||||
entry["stakes"] = stakes
|
||||
.iter()
|
||||
@@ -187,7 +335,7 @@ impl Economy {
|
||||
.entry(uuid_string(winner))
|
||||
.or_default();
|
||||
account.balance = account.balance.saturating_add(prize);
|
||||
self.dirty = true;
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -200,6 +348,223 @@ impl Economy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Money in the pool `name` (0 for one never used).
|
||||
pub fn pool(&self, name: &str) -> i64 {
|
||||
self.accounts.pools.get(name).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn pool_add(&mut self, name: &str, delta: i64) {
|
||||
let p = self.accounts.pools.entry(name.to_owned()).or_default();
|
||||
*p = p.saturating_add(delta);
|
||||
if *p == 0 {
|
||||
self.accounts.pools.remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// A bid, a bet: `amount` of `uuid`'s balance goes into `pool`, logged
|
||||
/// as `kind`. An Err when they hold less.
|
||||
pub fn to_pool(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
uuid: u128,
|
||||
pool: &str,
|
||||
amount: i64,
|
||||
reason: &str,
|
||||
) -> Result<(), String> {
|
||||
self.into_pool(kind, uuid, pool, amount, reason, "")
|
||||
}
|
||||
|
||||
/// [`Self::to_pool`] by an operator (`by`: their UUID), logged so.
|
||||
pub fn into_pool(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
uuid: u128,
|
||||
pool: &str,
|
||||
amount: i64,
|
||||
reason: &str,
|
||||
by: &str,
|
||||
) -> Result<(), String> {
|
||||
if amount <= 0 || amount > MAX_AMOUNT {
|
||||
return Err("The amount must be a positive whole number.".into());
|
||||
}
|
||||
let key = uuid_string(uuid);
|
||||
let Some(a) = self.accounts.accounts.get(&key) else {
|
||||
return Err("No such account.".into());
|
||||
};
|
||||
if a.balance < amount {
|
||||
return Err(format!("Only {} there.", self.money(a.balance)));
|
||||
}
|
||||
let mut entry = self.entry(kind, Some(uuid), None, amount, by);
|
||||
entry["to_pool"] = pool.into();
|
||||
entry["pool_before"] = self.pool(pool).into();
|
||||
if !reason.is_empty() {
|
||||
entry["reason"] = reason.into();
|
||||
}
|
||||
self.audit.append(&entry)?;
|
||||
self.accounts
|
||||
.accounts
|
||||
.get_mut(&key)
|
||||
.expect("checked")
|
||||
.balance -= amount;
|
||||
self.pool_add(pool, amount);
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A payment from `pool` to `uuid` (a refund, a payout),
|
||||
/// logged as `kind`: never more than the pool holds.
|
||||
#[allow(dead_code)] // the casino and the auction house
|
||||
pub fn from_pool(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
pool: &str,
|
||||
uuid: u128,
|
||||
amount: i64,
|
||||
reason: &str,
|
||||
) -> Result<(), String> {
|
||||
self.out_of_pool(kind, pool, uuid, amount, reason, "")
|
||||
}
|
||||
|
||||
/// [`Self::from_pool`] by an operator (`by`: their UUID), logged so.
|
||||
pub fn out_of_pool(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
pool: &str,
|
||||
uuid: u128,
|
||||
amount: i64,
|
||||
reason: &str,
|
||||
by: &str,
|
||||
) -> Result<(), String> {
|
||||
if amount <= 0 || amount > MAX_AMOUNT {
|
||||
return Err("The amount must be a positive whole number.".into());
|
||||
}
|
||||
let key = uuid_string(uuid);
|
||||
let Some(a) = self.accounts.accounts.get(&key) else {
|
||||
return Err("No such account.".into());
|
||||
};
|
||||
if a.balance.saturating_add(amount) > MAX_AMOUNT * 1000 {
|
||||
return Err("That account can't hold more.".into());
|
||||
}
|
||||
let held = self.pool(pool);
|
||||
if held < amount {
|
||||
return Err(format!("The {pool} only holds {}.", self.money(held)));
|
||||
}
|
||||
let mut entry = self.entry(kind, None, Some(uuid), amount, by);
|
||||
entry["from_pool"] = pool.into();
|
||||
entry["pool_before"] = held.into();
|
||||
if !reason.is_empty() {
|
||||
entry["reason"] = reason.into();
|
||||
}
|
||||
self.audit.append(&entry)?;
|
||||
self.accounts
|
||||
.accounts
|
||||
.get_mut(&key)
|
||||
.expect("checked")
|
||||
.balance += amount;
|
||||
self.pool_add(pool, -amount);
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Moves `amount` from one pool to another, logged as `kind`; an Err
|
||||
/// when `from` holds less.
|
||||
#[allow(dead_code)] // the casino and the auction house
|
||||
pub fn pool_move(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
from: &str,
|
||||
to: &str,
|
||||
amount: i64,
|
||||
reason: &str,
|
||||
) -> Result<(), String> {
|
||||
if amount <= 0 || amount > MAX_AMOUNT * 1000 || from == to {
|
||||
return Err("The amount must be a positive whole number.".into());
|
||||
}
|
||||
let held = self.pool(from);
|
||||
if held < amount {
|
||||
return Err(format!("The {from} only holds {}.", self.money(held)));
|
||||
}
|
||||
let mut entry = serde_json::json!({
|
||||
"time": self.now, "kind": kind, "amount": amount,
|
||||
"from_pool": from, "from_pool_before": held,
|
||||
"to_pool": to, "to_pool_before": self.pool(to),
|
||||
});
|
||||
if !reason.is_empty() {
|
||||
entry["reason"] = reason.into();
|
||||
}
|
||||
self.audit.append(&entry)?;
|
||||
self.pool_add(from, -amount);
|
||||
self.pool_add(to, amount);
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Money made for a pool (the casino's float, once), logged as `kind`
|
||||
/// and counted as made.
|
||||
pub fn mint_pool(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
pool: &str,
|
||||
amount: i64,
|
||||
reason: &str,
|
||||
) -> Result<(), String> {
|
||||
if amount <= 0 || amount > MAX_AMOUNT {
|
||||
return Err("The amount must be a positive whole number.".into());
|
||||
}
|
||||
let entry = serde_json::json!({
|
||||
"time": self.now, "kind": kind, "amount": amount,
|
||||
"to_pool": pool, "pool_before": self.pool(pool), "reason": reason,
|
||||
});
|
||||
self.audit.append(&entry)?;
|
||||
self.pool_add(pool, amount);
|
||||
self.count_flow(kind, amount);
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A bet against a pool decided: `uuid`'s held `stake` goes into
|
||||
/// `pool`, then `payout` (at most what the pool then holds) comes out
|
||||
/// to them; one log line. What was paid. Never makes money.
|
||||
#[allow(dead_code)] // the casino and the auction house
|
||||
pub fn settle_pool(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
uuid: u128,
|
||||
stake: i64,
|
||||
payout: i64,
|
||||
pool: &str,
|
||||
) -> Result<i64, String> {
|
||||
if stake < 0 || payout < 0 || self.held(uuid) < stake {
|
||||
return Err("The stake is not held.".into());
|
||||
}
|
||||
let before = self.pool(pool);
|
||||
let paid = payout.min(before.saturating_add(stake));
|
||||
let mut entry = self.entry(kind, None, Some(uuid), paid, "");
|
||||
entry["stake"] = stake.into();
|
||||
entry["pool"] = pool.into();
|
||||
entry["pool_before"] = before.into();
|
||||
entry["asked"] = payout.into();
|
||||
self.audit.append(&entry)?;
|
||||
let key = uuid_string(uuid);
|
||||
self.take_held(&key, stake);
|
||||
self.pool_add(pool, stake - paid);
|
||||
let account = self.accounts.accounts.entry(key).or_default();
|
||||
account.balance = account.balance.saturating_add(paid);
|
||||
self.touch();
|
||||
Ok(paid)
|
||||
}
|
||||
|
||||
/// A part's state kept with the balances (`Accounts::ext`).
|
||||
pub fn ext(&self, part: &str) -> Option<&serde_json::Value> {
|
||||
self.accounts.ext.get(part)
|
||||
}
|
||||
|
||||
/// Replaces a part's state, written with the balances at the next save.
|
||||
pub fn set_ext(&mut self, part: &str, value: serde_json::Value) {
|
||||
self.accounts.ext.insert(part.to_owned(), value);
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Writes `economy.json` when it changed (end of a tick, world closing).
|
||||
pub fn save(&mut self) -> Result<(), String> {
|
||||
self.audit.sync();
|
||||
@@ -226,7 +591,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 +604,7 @@ impl Economy {
|
||||
balance: start,
|
||||
},
|
||||
);
|
||||
self.dirty = true;
|
||||
self.touch();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,6 +619,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,13 +713,25 @@ impl Economy {
|
||||
.get_mut(&uuid_string(to))
|
||||
.expect("checked")
|
||||
.balance = to_after;
|
||||
self.dirty = true;
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds (or with a negative `delta`, takes) money from the server: the
|
||||
/// market, games, operators. The balance never goes below zero.
|
||||
pub fn adjust(&mut self, kind: &str, uuid: u128, delta: i64, by: &str) -> Result<i64, String> {
|
||||
self.adjust_why(kind, uuid, delta, by, "")
|
||||
}
|
||||
|
||||
/// [`Self::adjust`] with the operator's reason in the log.
|
||||
pub fn adjust_why(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
uuid: u128,
|
||||
delta: i64,
|
||||
by: &str,
|
||||
reason: &str,
|
||||
) -> Result<i64, String> {
|
||||
let key = uuid_string(uuid);
|
||||
let Some(a) = self.accounts.accounts.get(&key) else {
|
||||
return Err("No such account.".into());
|
||||
@@ -362,17 +748,34 @@ impl Economy {
|
||||
} else {
|
||||
(None, Some(uuid))
|
||||
};
|
||||
let entry = self.entry(kind, from, to, delta.abs(), by);
|
||||
let mut entry = self.entry(kind, from, to, delta.abs(), by);
|
||||
if !reason.is_empty() {
|
||||
entry["reason"] = reason.into();
|
||||
}
|
||||
self.audit.append(&entry)?;
|
||||
self.accounts
|
||||
.accounts
|
||||
.get_mut(&key)
|
||||
.expect("checked")
|
||||
.balance = after;
|
||||
self.dirty = true;
|
||||
self.count_flow(kind, delta);
|
||||
self.touch();
|
||||
Ok(after)
|
||||
}
|
||||
|
||||
/// Counts money made (`delta` > 0) or destroyed, by the kind's first word.
|
||||
fn count_flow(&mut self, kind: &str, delta: i64) {
|
||||
let flows = &mut self.accounts.flows;
|
||||
flows.roll(self.now.div_euclid(DAY_MS));
|
||||
let family = kind.split(' ').next().unwrap_or(kind).to_owned();
|
||||
let map = if delta >= 0 {
|
||||
&mut flows.minted
|
||||
} else {
|
||||
&mut flows.burned
|
||||
};
|
||||
*map.entry(family).or_default() += delta.abs();
|
||||
}
|
||||
|
||||
/// Appends a line of another kind to the audit log (a trade's items),
|
||||
/// with the time; the change it records waits for it.
|
||||
pub fn record(&mut self, mut entry: serde_json::Value) -> Result<(), String> {
|
||||
@@ -392,16 +795,22 @@ 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 (no command uses it now that `/eco give *` is gone).
|
||||
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" => self.eco(me, args),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -468,40 +877,55 @@ impl Economy {
|
||||
out
|
||||
}
|
||||
|
||||
/// `/eco give <player> <amount> <reason>` (made for them: an event
|
||||
/// prize, a bug refund) and `/eco take <player> <amount> [reason]` (a
|
||||
/// cheat's gains, destroyed): operators of level 3. A gift is told to
|
||||
/// everyone, with its reason.
|
||||
fn eco(&mut self, me: &Player, args: &[&str]) -> Vec<Out> {
|
||||
if me.level < 3 {
|
||||
return vec![Out::Error("Only operators of level 3 may use /eco.".into())];
|
||||
}
|
||||
let [verb, who, amount] = args else {
|
||||
const USAGE: &str =
|
||||
"Usage: /eco give <player> <amount> <reason>, /eco take <player> <amount> [reason]";
|
||||
let (give, who, amount, reason) = match args {
|
||||
[verb @ ("give" | "take"), who, amount, reason @ ..] => {
|
||||
(*verb == "give", *who, *amount, reason.join(" "))
|
||||
}
|
||||
_ => return vec![Out::Error(USAGE.into())],
|
||||
};
|
||||
let Some(amount) = parse_amount(amount) else {
|
||||
return vec![Out::Error(
|
||||
"Usage: /eco give|take|set <player> <amount>".into(),
|
||||
"The amount must be a positive whole number.".into(),
|
||||
)];
|
||||
};
|
||||
let amount = match amount.parse::<i64>() {
|
||||
Ok(a) if (0..=MAX_AMOUNT).contains(&a) => a,
|
||||
_ => {
|
||||
return vec![Out::Error(
|
||||
"The amount must be a whole number from 0.".into(),
|
||||
)];
|
||||
}
|
||||
};
|
||||
let reason = reason.trim().to_owned();
|
||||
if give && reason.chars().count() < 3 {
|
||||
return vec![Out::Error(
|
||||
"Say why: /eco give <player> <amount> <reason> (an event prize, a bug refund...)"
|
||||
.into(),
|
||||
)];
|
||||
}
|
||||
let Some((uuid, account)) = self.find(who) else {
|
||||
return vec![Out::Error(format!("No player named {who} has an account."))];
|
||||
};
|
||||
let name = account.name.clone();
|
||||
let delta = match *verb {
|
||||
"give" => amount,
|
||||
"take" => -amount,
|
||||
"set" => amount - account.balance,
|
||||
_ => {
|
||||
return vec![Out::Error(
|
||||
"Usage: /eco give|take|set <player> <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)))],
|
||||
let (kind, delta) = if give {
|
||||
("eco_give", amount)
|
||||
} else {
|
||||
("eco_take", -amount)
|
||||
};
|
||||
match self.adjust_why(kind, uuid, delta, &by, &reason) {
|
||||
Ok(_) if give => vec![Out::All(format!(
|
||||
"{} gave {} to {name}: {reason}.",
|
||||
me.name,
|
||||
self.money(amount)
|
||||
))],
|
||||
Ok(after) => vec![Out::Reply(format!(
|
||||
"Took {} from {name}. They have {}.",
|
||||
self.money(amount),
|
||||
self.money(after)
|
||||
))],
|
||||
Err(e) => vec![Out::Error(e)],
|
||||
}
|
||||
}
|
||||
@@ -559,7 +983,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 +1024,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 +1041,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));
|
||||
@@ -627,29 +1054,174 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eco_is_for_level_3_and_never_goes_below_zero() {
|
||||
fn operators_give_with_a_reason_and_take_and_both_are_logged() {
|
||||
let (mut e, dir) = economy("eco");
|
||||
let alex = player(ALEX, "Alex", 2);
|
||||
let op = player(ALEX, "Alex", 4);
|
||||
assert_eq!(
|
||||
e.command(&alex, "eco give Steve 5").unwrap(),
|
||||
e.command(&player(ALEX, "Alex", 2), &[], "eco give Steve 5 a prize")
|
||||
.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())]
|
||||
assert!(
|
||||
matches!(&e.command(&op, &[], "eco give Steve 5").unwrap()[0], Out::Error(s) if s.starts_with("Say why")),
|
||||
"a gift needs its reason"
|
||||
);
|
||||
assert!(
|
||||
matches!(&e.command(&op, &[], "eco set Steve 5").unwrap()[0], Out::Error(s) if s.starts_with("Usage"))
|
||||
);
|
||||
assert_eq!(
|
||||
e.command(&op, "eco take Steve 200").unwrap(),
|
||||
[Out::Error("Only 105 coins there.".into())]
|
||||
e.command(&op, &[], "money give Steve 50 build contest")
|
||||
.unwrap(),
|
||||
[Out::All(
|
||||
"Alex gave 50 coins to Steve: build contest.".into()
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
e.command(&op, "eco set Steve 1234567").unwrap(),
|
||||
[Out::Reply("Steve now has 1,234,567 coins.".into())]
|
||||
e.command(&op, &[], "eco take Steve 200").unwrap(),
|
||||
[Out::Error("Only 150 coins there.".into())]
|
||||
);
|
||||
assert_eq!(
|
||||
e.command(&op, &[], "eco take Steve 40 duped diamonds")
|
||||
.unwrap(),
|
||||
[Out::Reply(
|
||||
"Took 40 coins from Steve. They have 110 coins.".into()
|
||||
)]
|
||||
);
|
||||
assert_eq!(e.balance(STEVE), 110);
|
||||
let flows = e.flows();
|
||||
assert_eq!(
|
||||
(flows.minted["eco_give"], flows.burned["eco_take"]),
|
||||
(50, 40)
|
||||
);
|
||||
let log = log_lines(&dir);
|
||||
assert_eq!(log.last().unwrap()["kind"], "eco_set");
|
||||
assert_eq!(log.last().unwrap()["by"], uuid_string(ALEX));
|
||||
let [.., give, take] = &log[..] else {
|
||||
panic!("{log:?}")
|
||||
};
|
||||
assert_eq!(
|
||||
(
|
||||
give["kind"].as_str(),
|
||||
give["reason"].as_str(),
|
||||
give["amount"].as_i64()
|
||||
),
|
||||
(Some("eco_give"), Some("build contest"), Some(50))
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
take["kind"].as_str(),
|
||||
take["reason"].as_str(),
|
||||
take["from"].as_str()
|
||||
),
|
||||
(
|
||||
Some("eco_take"),
|
||||
Some("duped diamonds"),
|
||||
Some(uuid_string(STEVE).as_str())
|
||||
)
|
||||
);
|
||||
assert_eq!(take["by"], uuid_string(ALEX));
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pools_pay_only_what_they_hold_and_survive_a_restart() {
|
||||
let (mut e, dir) = economy("pools");
|
||||
e.to_pool("ah_bid", ALEX, "ah", 30, "").unwrap();
|
||||
assert!(
|
||||
e.to_pool("ah_bid", ALEX, "ah", 71, "").is_err(),
|
||||
"more than Alex has"
|
||||
);
|
||||
assert!(
|
||||
e.from_pool("ah_sale", "ah", STEVE, 31, "").is_err(),
|
||||
"more than the pool holds"
|
||||
);
|
||||
e.from_pool("ah_sale", "ah", STEVE, 10, "").unwrap();
|
||||
e.pool_move("test", "ah", "casino", 5, "").unwrap();
|
||||
e.mint_pool("casino_float", "casino", 100, "the casino's float")
|
||||
.unwrap();
|
||||
assert_eq!((e.pool("ah"), e.pool("casino")), (15, 105));
|
||||
assert_eq!((e.balance(ALEX), e.balance(STEVE)), (70, 110));
|
||||
assert_eq!(e.flows().minted["casino_float"], 100);
|
||||
// Pools are nobody's: not in the ranking.
|
||||
assert_eq!(e.accounts().len(), 2);
|
||||
e.save().unwrap();
|
||||
let again = Economy::open(&dir, Config::default()).unwrap();
|
||||
assert_eq!((again.pool("ah"), again.pool("casino")), (15, 105));
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_world_from_before_0_30_gets_its_savings_and_margins_back() {
|
||||
let dir = std::env::temp_dir().join(format!("smp-eco-retire-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let (a, s) = (uuid_string(ALEX), uuid_string(STEVE));
|
||||
let old = serde_json::json!({
|
||||
"accounts": {a.clone(): {"name": "Alex", "balance": 10}, s.clone(): {"name": "Steve", "balance": 20}},
|
||||
"pools": {"treasury": 5000, "insurance": 300, "bank": 700, "futures": 90, "casino": 40},
|
||||
"treasury": {"income": {"vat": 12}, "log": []},
|
||||
"ext": {
|
||||
"bank": {"accounts": {a.clone(): {"amount": 500, "low": 500, "earned": 3}, s.clone(): {"amount": 200}}},
|
||||
"futures": {"positions": [{"id": 1, "owner": s.clone(), "name": "Steve", "item": "minecraft:diamond",
|
||||
"long": true, "size": 1.0, "entry": 200.0, "margin": 90, "leverage": 2, "opened": 0}]},
|
||||
"jobs": {"day": 3},
|
||||
"tax": {"week": 2},
|
||||
"auction": {"listings": []}
|
||||
}
|
||||
});
|
||||
fs::write(dir.join("economy.json"), old.to_string()).unwrap();
|
||||
let mut e = Economy::open(&dir, Config::default()).unwrap();
|
||||
assert_eq!((e.balance(ALEX), e.balance(STEVE)), (510, 310));
|
||||
assert_eq!(
|
||||
e.pools().keys().collect::<Vec<_>>(),
|
||||
["casino"],
|
||||
"the retired pools are gone"
|
||||
);
|
||||
assert!(
|
||||
e.ext("bank").is_none()
|
||||
&& e.ext("futures").is_none()
|
||||
&& e.ext("jobs").is_none()
|
||||
&& e.ext("tax").is_none()
|
||||
);
|
||||
assert!(e.ext("auction").is_some(), "the auction house stays");
|
||||
let kinds: Vec<_> = log_lines(&dir)
|
||||
.iter()
|
||||
.map(|l| l["kind"].as_str().unwrap().to_owned())
|
||||
.collect();
|
||||
assert_eq!(kinds, ["bank_closed", "bank_closed", "futures_closed"]);
|
||||
e.save().unwrap();
|
||||
let saved = fs::read_to_string(dir.join("economy.json")).unwrap();
|
||||
assert!(!saved.contains("treasury"), "{saved}");
|
||||
// Only once.
|
||||
let again = Economy::open(&dir, Config::default()).unwrap();
|
||||
assert_eq!((again.balance(ALEX), again.balance(STEVE)), (510, 310));
|
||||
assert_eq!(log_lines(&dir).len(), 3);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settle_pool_never_pays_more_than_the_pool_and_award_pays_the_stakes() {
|
||||
let (mut e, dir) = economy("settle");
|
||||
e.hold("bet", ALEX, 50).unwrap();
|
||||
// The pool held nothing: a 1,000 payout is capped at the stake.
|
||||
assert_eq!(
|
||||
e.settle_pool("roulette", ALEX, 50, 1000, "casino").unwrap(),
|
||||
50
|
||||
);
|
||||
assert_eq!((e.balance(ALEX), e.pool("casino")), (100, 0));
|
||||
e.hold("bet", ALEX, 40).unwrap();
|
||||
assert_eq!(e.settle_pool("roulette", ALEX, 40, 0, "casino").unwrap(), 0);
|
||||
assert_eq!((e.balance(ALEX), e.pool("casino")), (60, 40));
|
||||
assert!(
|
||||
e.settle_pool("roulette", ALEX, 10, 0, "casino").is_err(),
|
||||
"nothing held"
|
||||
);
|
||||
// A coinflip: the winner gets both stakes, no fee.
|
||||
e.hold("coinflip_stake", ALEX, 50).unwrap();
|
||||
e.hold("coinflip_stake", STEVE, 50).unwrap();
|
||||
e.award("coinflip_win", &[(ALEX, 50), (STEVE, 50)], STEVE)
|
||||
.unwrap();
|
||||
assert_eq!((e.balance(ALEX), e.balance(STEVE)), (10, 150));
|
||||
// Nothing made: 200 at the start.
|
||||
assert_eq!(e.balance(ALEX) + e.balance(STEVE) + e.pool("casino"), 200);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -659,12 +1231,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())
|
||||
|
||||
@@ -25,8 +25,6 @@ pub struct Config {
|
||||
pub cooldown_seconds: i64,
|
||||
/// How long an offer waits before its stake goes back.
|
||||
pub offer_seconds: i64,
|
||||
/// What the server keeps of the pot, in percent (0: the winner takes all).
|
||||
pub fee_percent: i64,
|
||||
/// The lottery: a ticket's price, the tickets one player may hold in a
|
||||
/// draw, and the time from a round's first ticket to its draw.
|
||||
pub ticket_price: i64,
|
||||
@@ -42,7 +40,6 @@ impl Default for Config {
|
||||
max_bet: 1000,
|
||||
cooldown_seconds: 30,
|
||||
offer_seconds: 60,
|
||||
fee_percent: 0,
|
||||
ticket_price: 10,
|
||||
max_tickets: 100,
|
||||
lottery_seconds: 3600,
|
||||
@@ -284,15 +281,14 @@ impl Games {
|
||||
return vec![Out::Error(e)];
|
||||
}
|
||||
let o = self.offers.remove(i);
|
||||
let pot = amount * 2;
|
||||
let prize = pot - pot * self.config.fee_percent.clamp(0, 100) / 100;
|
||||
let prize = amount * 2;
|
||||
let (winner, loser, winner_name, loser_name) = if heads() {
|
||||
(o.from, me.uuid, o.from_name.as_str(), me.name.as_str())
|
||||
} else {
|
||||
(me.uuid, o.from, me.name.as_str(), o.from_name.as_str())
|
||||
};
|
||||
let stakes = [(o.from, amount), (me.uuid, amount)];
|
||||
if let Err(e) = economy.award("coinflip_win", &stakes, winner, prize) {
|
||||
if let Err(e) = economy.award("coinflip_win", &stakes, winner) {
|
||||
// Not decided: both stakes go back (or, failing that, at the next start).
|
||||
for (u, a) in stakes {
|
||||
let _ = economy.release("coinflip_cancel", u, a);
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
// `/invsee <player>` and `/endersee <player>`: an operator of level 3 sees
|
||||
// another player's inventory (main, hotbar, armour, off hand) or ender chest
|
||||
// in a chest menu, on or off the server, and moves stacks in and out: a click
|
||||
// on one of theirs takes it into the operator's inventory, a click on one of
|
||||
// the operator's own puts it into theirs. One stack moves at a time, and only
|
||||
// if it is still exactly what the menu showed (the game checks it and moves
|
||||
// it whole, or nothing): no copy, no loss, whatever the player does
|
||||
// meanwhile (moving items, leaving, joining, dying). Each move is in the
|
||||
// audit log; closing the menu writes a one-line summary for the staff. The
|
||||
// menu is read again once a second while it is open; nothing runs when no
|
||||
// menu is open. No game types: the stacks are a type parameter, the game
|
||||
// lends its inventories as a `World`. Added to the game as
|
||||
// `game::smp_invsee`; also a module of this crate, for its tests.
|
||||
|
||||
use super::smp_economy::{Out, Player};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub const COMMANDS: [&str; 2] = ["invsee", "endersee"];
|
||||
/// The level that may look.
|
||||
pub const LEVEL: u8 = 3;
|
||||
pub const ROWS: u8 = 6;
|
||||
pub const SIZE: usize = 54;
|
||||
/// The bottom row's buttons.
|
||||
const INFO: usize = 49;
|
||||
const CLOSE: usize = 53;
|
||||
|
||||
/// Which inventory: the player's 41 slots (0..36 main, 36..40 armour feet to
|
||||
/// head, 40 off hand), or the ender chest's 27.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Player,
|
||||
Ender,
|
||||
}
|
||||
|
||||
/// A stack's item and count, for the log.
|
||||
pub trait Stack: Clone + PartialEq {
|
||||
fn id(&self) -> String;
|
||||
fn count(&self) -> u32;
|
||||
}
|
||||
|
||||
/// The game's inventories (the glue's, over the native helpers).
|
||||
pub trait World<S> {
|
||||
/// A player's inventory and whether it is live (they are on): Err while
|
||||
/// they log in, are dead, or never played here.
|
||||
fn read(&self, uuid: u128, kind: Kind) -> Result<(Vec<Option<S>>, bool), String>;
|
||||
/// One stack from `from`'s slot, still exactly `expected`, into `to`'s
|
||||
/// inventory where it fits whole, or nothing moves.
|
||||
fn move_stack(
|
||||
&mut self,
|
||||
from: (u128, Kind, usize),
|
||||
expected: &S,
|
||||
to: (u128, Kind),
|
||||
) -> Result<(), String>;
|
||||
}
|
||||
|
||||
/// One operator's open menu.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct View<S> {
|
||||
pub target: u128,
|
||||
pub name: String,
|
||||
pub kind: Kind,
|
||||
pub window: Option<i32>,
|
||||
/// What the menu shows, by inventory slot.
|
||||
pub shown: Vec<Option<S>>,
|
||||
pub online: bool,
|
||||
/// Why the inventory can't be read now (logging in, dead...).
|
||||
pub busy: Option<String>,
|
||||
/// What moved: (taken from the player, item, count).
|
||||
pub moved: Vec<(bool, String, u32)>,
|
||||
}
|
||||
|
||||
/// A cell of the menu.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Cell<S> {
|
||||
Empty,
|
||||
Stack(S),
|
||||
Filler,
|
||||
/// The player's name, on or off, and why it can't be read now.
|
||||
Info {
|
||||
name: String,
|
||||
online: bool,
|
||||
busy: Option<String>,
|
||||
},
|
||||
/// Above an armour or off-hand slot.
|
||||
Label(&'static str),
|
||||
Close,
|
||||
}
|
||||
|
||||
/// A move the glue logs before it asks the game for it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Move {
|
||||
pub op: u128,
|
||||
pub target: u128,
|
||||
pub kind: Kind,
|
||||
/// Taken from the player (true) or put into their inventory.
|
||||
pub take: bool,
|
||||
pub item: String,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
/// The menu slot of each inventory slot. Player: the main inventory in the
|
||||
/// first three rows, the hotbar in the fourth (as the player sees it), the
|
||||
/// armour head to feet then the off hand in the fifth.
|
||||
pub fn menu_slot(kind: Kind, slot: usize) -> Option<usize> {
|
||||
match (kind, slot) {
|
||||
(Kind::Player, 9..36) => Some(slot - 9),
|
||||
(Kind::Player, 0..9) => Some(27 + slot),
|
||||
(Kind::Player, 36..40) => Some(36 + (39 - slot)),
|
||||
(Kind::Player, 40) => Some(41),
|
||||
(Kind::Ender, 0..27) => Some(slot),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The inventory slot a menu slot shows.
|
||||
pub fn inventory_slot(kind: Kind, menu: usize) -> Option<usize> {
|
||||
let size = match kind {
|
||||
Kind::Player => 41,
|
||||
Kind::Ender => 27,
|
||||
};
|
||||
(0..size).find(|&s| menu_slot(kind, s) == Some(menu))
|
||||
}
|
||||
|
||||
pub struct Invsee<S> {
|
||||
pub views: HashMap<u128, View<S>>,
|
||||
}
|
||||
|
||||
impl<S> Default for Invsee<S> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
views: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a command asks to open: the player (UUID, real name) and which inventory.
|
||||
pub type Open = Option<(u128, String, Kind)>;
|
||||
|
||||
impl<S: Stack> Invsee<S> {
|
||||
/// Runs `/invsee` or `/endersee`: the target named, to open at the next
|
||||
/// tick (the game's inventories aren't reachable inside a command).
|
||||
/// `find`: a player who has played here by name (UUID, real name).
|
||||
pub fn command(
|
||||
&mut self,
|
||||
me: &Player,
|
||||
find: &dyn Fn(&str) -> Option<(u128, String)>,
|
||||
line: &str,
|
||||
) -> Option<(Vec<Out>, Open)> {
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
let (&name, args) = words.split_first()?;
|
||||
let kind = match name {
|
||||
"invsee" => Kind::Player,
|
||||
"endersee" => Kind::Ender,
|
||||
_ => return None,
|
||||
};
|
||||
if me.level < LEVEL {
|
||||
return Some((
|
||||
vec![Out::Error(format!("Only operators may use /{name}."))],
|
||||
None,
|
||||
));
|
||||
}
|
||||
let [who] = args else {
|
||||
return Some((vec![Out::Error(format!("Usage: /{name} <player>"))], None));
|
||||
};
|
||||
match find(who) {
|
||||
// Your own inventory would move stacks into itself; your own
|
||||
// ender chest is a second container, so an operator may open it.
|
||||
Some((uuid, _)) if uuid == me.uuid && kind == Kind::Player => {
|
||||
Some((vec![Out::Error("That is your own inventory.".into())], None))
|
||||
}
|
||||
Some((uuid, real)) => Some((Vec::new(), Some((uuid, real, kind)))),
|
||||
None => Some((
|
||||
vec![Out::Error(format!(
|
||||
"No player named {who} has played here."
|
||||
))],
|
||||
None,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The view for `op` on `target`, read now (the glue opens the menu).
|
||||
pub fn open(
|
||||
&mut self,
|
||||
op: u128,
|
||||
target: u128,
|
||||
name: &str,
|
||||
kind: Kind,
|
||||
world: &dyn World<S>,
|
||||
) -> Result<(), String> {
|
||||
let (shown, online) = world.read(target, kind)?;
|
||||
self.views.insert(
|
||||
op,
|
||||
View {
|
||||
target,
|
||||
name: name.to_owned(),
|
||||
kind,
|
||||
window: None,
|
||||
shown,
|
||||
online,
|
||||
busy: None,
|
||||
moved: Vec::new(),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The menu's 54 cells for `op`.
|
||||
pub fn layout(&self, op: u128) -> Vec<Cell<S>> {
|
||||
let Some(v) = self.views.get(&op) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut cells = vec![Cell::Empty; SIZE];
|
||||
let used = match v.kind {
|
||||
Kind::Player => 42,
|
||||
Kind::Ender => 27,
|
||||
};
|
||||
for c in cells.iter_mut().take(45).skip(used) {
|
||||
*c = Cell::Filler;
|
||||
}
|
||||
if v.kind == Kind::Player {
|
||||
cells[40] = Cell::Label("Off hand →");
|
||||
}
|
||||
for (slot, s) in v.shown.iter().enumerate() {
|
||||
if let (Some(m), Some(s)) = (menu_slot(v.kind, slot), s) {
|
||||
cells[m] = Cell::Stack(s.clone());
|
||||
}
|
||||
}
|
||||
for c in cells.iter_mut().skip(45) {
|
||||
*c = Cell::Filler;
|
||||
}
|
||||
cells[INFO] = Cell::Info {
|
||||
name: v.name.clone(),
|
||||
online: v.online,
|
||||
busy: v.busy.clone(),
|
||||
};
|
||||
cells[CLOSE] = Cell::Close;
|
||||
cells
|
||||
}
|
||||
|
||||
/// Reads the target's inventory again: whether the menu changed.
|
||||
pub fn refresh(&mut self, op: u128, world: &dyn World<S>) -> bool {
|
||||
let Some(v) = self.views.get_mut(&op) else {
|
||||
return false;
|
||||
};
|
||||
let (shown, online, busy) = match world.read(v.target, v.kind) {
|
||||
Ok((s, on)) => (s, on, None),
|
||||
Err(e) => (v.shown.clone(), v.online, Some(e)),
|
||||
};
|
||||
let changed = shown != v.shown || online != v.online || busy != v.busy;
|
||||
(v.shown, v.online, v.busy) = (shown, online, busy);
|
||||
changed
|
||||
}
|
||||
|
||||
/// A click on the menu's slot `menu`: the stack shown there into the
|
||||
/// operator's inventory. Ok(None): nothing to do; Ok(Some(close)) when
|
||||
/// the close button. `log` records the move first (the audit log); a
|
||||
/// move it refuses doesn't happen.
|
||||
pub fn click_menu(
|
||||
&mut self,
|
||||
op: u128,
|
||||
menu: usize,
|
||||
world: &mut dyn World<S>,
|
||||
log: &mut dyn FnMut(&Move) -> Result<(), String>,
|
||||
) -> Result<Option<bool>, String> {
|
||||
let Some(v) = self.views.get(&op) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if menu == CLOSE {
|
||||
return Ok(Some(true));
|
||||
}
|
||||
let Some(slot) = inventory_slot(v.kind, menu) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(stack) = v.shown.get(slot).cloned().flatten() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let m = Move {
|
||||
op,
|
||||
target: v.target,
|
||||
kind: v.kind,
|
||||
take: true,
|
||||
item: stack.id(),
|
||||
count: stack.count(),
|
||||
};
|
||||
let r = self.apply(op, &m, world, log, |w| {
|
||||
w.move_stack((m.target, m.kind, slot), &stack, (op, Kind::Player))
|
||||
});
|
||||
self.refresh(op, world);
|
||||
r.map(|_| Some(false))
|
||||
}
|
||||
|
||||
/// A click on the operator's own stack `stack` in inventory slot `slot`
|
||||
/// (0..36): into the player's inventory.
|
||||
pub fn click_own(
|
||||
&mut self,
|
||||
op: u128,
|
||||
slot: usize,
|
||||
stack: &S,
|
||||
world: &mut dyn World<S>,
|
||||
log: &mut dyn FnMut(&Move) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let Some(v) = self.views.get(&op) else {
|
||||
return Ok(());
|
||||
};
|
||||
let m = Move {
|
||||
op,
|
||||
target: v.target,
|
||||
kind: v.kind,
|
||||
take: false,
|
||||
item: stack.id(),
|
||||
count: stack.count(),
|
||||
};
|
||||
let r = self.apply(op, &m, world, log, |w| {
|
||||
w.move_stack((op, Kind::Player, slot), stack, (m.target, m.kind))
|
||||
});
|
||||
self.refresh(op, world);
|
||||
r
|
||||
}
|
||||
|
||||
fn apply(
|
||||
&mut self,
|
||||
op: u128,
|
||||
m: &Move,
|
||||
world: &mut dyn World<S>,
|
||||
log: &mut dyn FnMut(&Move) -> Result<(), String>,
|
||||
go: impl FnOnce(&mut dyn World<S>) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
log(m)?;
|
||||
go(world)?;
|
||||
if let Some(v) = self.views.get_mut(&op) {
|
||||
v.moved.push((m.take, m.item.clone(), m.count));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The operator's menu closed (or they left): the staff's one-line
|
||||
/// summary when something moved.
|
||||
pub fn close(&mut self, op: u128, op_name: &str) -> Option<String> {
|
||||
let v = self.views.remove(&op)?;
|
||||
if v.moved.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let list = |take: bool| {
|
||||
let mut sums: Vec<(String, u32)> = Vec::new();
|
||||
for (t, item, n) in &v.moved {
|
||||
if *t == take {
|
||||
let item = item.strip_prefix("minecraft:").unwrap_or(item).to_owned();
|
||||
match sums.iter_mut().find(|(i, _)| *i == item) {
|
||||
Some((_, c)) => *c += n,
|
||||
None => sums.push((item, *n)),
|
||||
}
|
||||
}
|
||||
}
|
||||
sums.iter()
|
||||
.map(|(i, n)| format!("{n} {i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
};
|
||||
let what = match v.kind {
|
||||
Kind::Player => "inventory",
|
||||
Kind::Ender => "ender chest",
|
||||
};
|
||||
let (took, put) = (list(true), list(false));
|
||||
let mut parts = Vec::new();
|
||||
if !took.is_empty() {
|
||||
parts.push(format!("took {took}"));
|
||||
}
|
||||
if !put.is_empty() {
|
||||
parts.push(format!("put {put}"));
|
||||
}
|
||||
Some(format!(
|
||||
"{op_name} edited {}'s {what}{}: {}",
|
||||
v.name,
|
||||
if v.online { "" } else { " (offline)" },
|
||||
parts.join("; ")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct S(&'static str, u32);
|
||||
impl Stack for S {
|
||||
fn id(&self) -> String {
|
||||
format!("minecraft:{}", self.0)
|
||||
}
|
||||
fn count(&self) -> u32 {
|
||||
self.1
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum State {
|
||||
On,
|
||||
Off,
|
||||
LoggingIn,
|
||||
Dead,
|
||||
}
|
||||
|
||||
/// Two players: the operator (1, on) and Steve (2), whose inventories
|
||||
/// are live or a file by `state`; moves as the game's helper makes them.
|
||||
struct Fake {
|
||||
inv: HashMap<(u128, bool), Vec<Option<S>>>,
|
||||
state: State,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
fn new() -> Self {
|
||||
let mut steve = vec![None; 41];
|
||||
steve[0] = Some(S("diamond", 5));
|
||||
steve[39] = Some(S("iron_helmet", 1));
|
||||
let mut ender = vec![None; 27];
|
||||
ender[3] = Some(S("emerald", 9));
|
||||
let mut inv = HashMap::new();
|
||||
inv.insert((1, false), vec![None; 41]);
|
||||
inv.insert((2, false), steve);
|
||||
inv.insert((2, true), ender);
|
||||
Self {
|
||||
inv,
|
||||
state: State::On,
|
||||
}
|
||||
}
|
||||
fn total(&self) -> u32 {
|
||||
self.inv.values().flatten().flatten().map(|s| s.1).sum()
|
||||
}
|
||||
}
|
||||
|
||||
impl World<S> for Fake {
|
||||
fn read(&self, uuid: u128, kind: Kind) -> Result<(Vec<Option<S>>, bool), String> {
|
||||
if uuid == 2 {
|
||||
match self.state {
|
||||
State::LoggingIn => return Err("the player is logging in".into()),
|
||||
State::Dead => return Err("the player is dead".into()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let online = uuid == 1 || self.state == State::On;
|
||||
Ok((self.inv[&(uuid, kind == Kind::Ender)].clone(), online))
|
||||
}
|
||||
fn move_stack(
|
||||
&mut self,
|
||||
from: (u128, Kind, usize),
|
||||
expected: &S,
|
||||
to: (u128, Kind),
|
||||
) -> Result<(), String> {
|
||||
self.read(from.0, from.1)?;
|
||||
self.read(to.0, to.1)?;
|
||||
let src = self.inv.get_mut(&(from.0, from.1 == Kind::Ender)).unwrap();
|
||||
if src[from.2].as_ref() != Some(expected) {
|
||||
return Err("that stack changed".into());
|
||||
}
|
||||
let s = src[from.2].take().unwrap();
|
||||
let dst = self.inv.get_mut(&(to.0, to.1 == Kind::Ender)).unwrap();
|
||||
let room = if to.1 == Kind::Player { 36 } else { 27 };
|
||||
match dst.iter_mut().take(room).find(|d| d.is_none()) {
|
||||
Some(d) => *d = Some(s),
|
||||
None => {
|
||||
self.inv.get_mut(&(from.0, from.1 == Kind::Ender)).unwrap()[from.2] = Some(s);
|
||||
return Err("no room".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn op() -> Player {
|
||||
Player {
|
||||
uuid: 1,
|
||||
name: "Boss".into(),
|
||||
level: 4,
|
||||
}
|
||||
}
|
||||
|
||||
fn opened(kind: Kind) -> (Invsee<S>, Fake, Vec<Move>) {
|
||||
let mut i = Invsee::default();
|
||||
let w = Fake::new();
|
||||
i.open(1, 2, "Steve", kind, &w).unwrap();
|
||||
(i, w, Vec::new())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_operators_and_a_known_other_player() {
|
||||
let mut i: Invsee<S> = Invsee::default();
|
||||
let find = |n: &str| {
|
||||
(n == "Steve")
|
||||
.then(|| (2, "Steve".to_owned()))
|
||||
.or_else(|| (n == "Boss").then(|| (1, "Boss".into())))
|
||||
};
|
||||
let pleb = Player { level: 0, ..op() };
|
||||
assert_eq!(
|
||||
i.command(&pleb, &find, "invsee Steve").unwrap().0,
|
||||
[Out::Error("Only operators may use /invsee.".into())]
|
||||
);
|
||||
assert_eq!(
|
||||
i.command(&op(), &find, "invsee Boss").unwrap().0,
|
||||
[Out::Error("That is your own inventory.".into())]
|
||||
);
|
||||
assert_eq!(
|
||||
i.command(&op(), &find, "endersee Boss").unwrap().1,
|
||||
Some((1, "Boss".into(), Kind::Ender))
|
||||
);
|
||||
assert_eq!(
|
||||
i.command(&op(), &find, "endersee Nobody").unwrap().0,
|
||||
[Out::Error("No player named Nobody has played here.".into())]
|
||||
);
|
||||
assert_eq!(
|
||||
i.command(&op(), &find, "endersee Steve").unwrap().1,
|
||||
Some((2, "Steve".into(), Kind::Ender))
|
||||
);
|
||||
assert!(i.command(&op(), &find, "balance").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_layout_shows_main_hotbar_armour_and_off_hand() {
|
||||
let (i, _, _) = opened(Kind::Player);
|
||||
let cells = i.layout(1);
|
||||
assert_eq!(
|
||||
cells[27],
|
||||
Cell::Stack(S("diamond", 5)),
|
||||
"the hotbar's first slot"
|
||||
);
|
||||
assert_eq!(
|
||||
cells[36],
|
||||
Cell::Stack(S("iron_helmet", 1)),
|
||||
"the head first"
|
||||
);
|
||||
assert_eq!(cells[44], Cell::Filler);
|
||||
assert!(matches!(&cells[INFO], Cell::Info { name, online: true, .. } if name == "Steve"));
|
||||
for s in 0..41 {
|
||||
assert_eq!(
|
||||
inventory_slot(Kind::Player, menu_slot(Kind::Player, s).unwrap()),
|
||||
Some(s)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taking_and_putting_move_one_stack_and_log_it_first() {
|
||||
let (mut i, mut w, mut log) = opened(Kind::Player);
|
||||
let before = w.total();
|
||||
let mut rec = |m: &Move| {
|
||||
log.push(m.clone());
|
||||
Ok(())
|
||||
};
|
||||
assert_eq!(i.click_menu(1, 27, &mut w, &mut rec), Ok(Some(false)));
|
||||
assert_eq!(w.inv[&(1, false)][0], Some(S("diamond", 5)));
|
||||
assert_eq!(i.layout(1)[27], Cell::Empty, "read again after the move");
|
||||
i.click_own(1, 0, &S("diamond", 5), &mut w, &mut rec)
|
||||
.unwrap();
|
||||
assert_eq!(w.inv[&(2, false)][0], Some(S("diamond", 5)));
|
||||
assert_eq!(w.total(), before);
|
||||
assert_eq!(log.len(), 2);
|
||||
assert!(
|
||||
log[0].take && !log[1].take && log[0].item == "minecraft:diamond" && log[0].count == 5
|
||||
);
|
||||
assert_eq!(
|
||||
i.close(1, "Boss").as_deref(),
|
||||
Some("Boss edited Steve's inventory: took 5 diamond; put 5 diamond")
|
||||
);
|
||||
assert!(i.views.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stack_the_player_moved_meanwhile_is_not_copied() {
|
||||
let (mut i, mut w, _) = opened(Kind::Player);
|
||||
// Steve moves his diamonds after the menu was read.
|
||||
let d = w.inv.get_mut(&(2, false)).unwrap()[0].take();
|
||||
w.inv.get_mut(&(2, false)).unwrap()[5] = d;
|
||||
let before = w.total();
|
||||
assert_eq!(
|
||||
i.click_menu(1, 27, &mut w, &mut |_| Ok(())),
|
||||
Err("that stack changed".into())
|
||||
);
|
||||
assert_eq!(w.total(), before);
|
||||
assert!(w.inv[&(1, false)].iter().all(Option::is_none));
|
||||
// The menu shows where they are now.
|
||||
assert_eq!(i.layout(1)[32], Cell::Stack(S("diamond", 5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_joining_and_dying_while_the_menu_is_open() {
|
||||
let (mut i, mut w, _) = opened(Kind::Ender);
|
||||
let before = w.total();
|
||||
// He leaves: his file, the same items, offline in the menu.
|
||||
w.state = State::Off;
|
||||
assert!(i.refresh(1, &w));
|
||||
assert!(matches!(
|
||||
&i.layout(1)[INFO],
|
||||
Cell::Info { online: false, .. }
|
||||
));
|
||||
assert_eq!(i.click_menu(1, 3, &mut w, &mut |_| Ok(())), Ok(Some(false)));
|
||||
assert_eq!(w.inv[&(1, false)][0], Some(S("emerald", 9)));
|
||||
// He is logging in: nothing moves, the menu says why.
|
||||
w.state = State::LoggingIn;
|
||||
assert_eq!(
|
||||
i.click_own(1, 0, &S("emerald", 9), &mut w, &mut |_| Ok(())),
|
||||
Err("the player is logging in".into())
|
||||
);
|
||||
assert!(
|
||||
matches!(&i.layout(1)[INFO], Cell::Info { busy: Some(b), .. } if b.contains("logging in"))
|
||||
);
|
||||
// Dead: the same.
|
||||
w.state = State::Dead;
|
||||
assert!(
|
||||
i.click_own(1, 0, &S("emerald", 9), &mut w, &mut |_| Ok(()))
|
||||
.is_err()
|
||||
);
|
||||
// Back on: the move goes through live.
|
||||
w.state = State::On;
|
||||
i.click_own(1, 0, &S("emerald", 9), &mut w, &mut |_| Ok(()))
|
||||
.unwrap();
|
||||
assert_eq!(w.total(), before);
|
||||
assert_eq!(
|
||||
i.close(1, "Boss").as_deref(),
|
||||
Some("Boss edited Steve's ender chest: took 9 emerald; put 9 emerald")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_move_the_log_refuses_does_not_happen_and_closing_with_nothing_moved_says_nothing() {
|
||||
let (mut i, mut w, _) = opened(Kind::Player);
|
||||
let before = w.inv.clone();
|
||||
assert_eq!(
|
||||
i.click_menu(1, 27, &mut w, &mut |_| Err("audit.log: disk full".into())),
|
||||
Err("audit.log: disk full".into())
|
||||
);
|
||||
assert_eq!(w.inv, before);
|
||||
assert_eq!(
|
||||
i.click_menu(1, CLOSE, &mut w, &mut |_| Ok(())),
|
||||
Ok(Some(true))
|
||||
);
|
||||
assert_eq!(i.close(1, "Boss"), None);
|
||||
}
|
||||
}
|
||||
@@ -133,9 +133,8 @@ impl Lottery {
|
||||
ticket < 0
|
||||
})
|
||||
.expect("the ticket is below the total");
|
||||
let pot = total * price;
|
||||
let prize = pot - pot * config.fee_percent.clamp(0, 100) / 100;
|
||||
match economy.award("lottery_win", &stakes, winner.0, prize) {
|
||||
let prize = total * price;
|
||||
match economy.award("lottery_win", &stakes, winner.0) {
|
||||
Ok(()) => vec![Out::All(format!(
|
||||
"Lottery: {} won {} with {} of {total} tickets!",
|
||||
winner.1,
|
||||
|
||||
+1545
-94
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,260 @@
|
||||
// A price chart drawn as a map's picture: 128 x 128 colours (`MapColor` id
|
||||
// times 4 plus the shade), the curve over a dark background with a grid, the
|
||||
// item's name, the span, the price now, the change and the low and high, in
|
||||
// a 3 x 5 pixel font. Green when the price rose over the span, red when it
|
||||
// fell. The glue sends it as map data (`native_send_map`) for a filled map
|
||||
// it gives the player, and again each minute while they carry it. No game
|
||||
// types. Added to the game as `game::smp_market_chart`; also a module of this
|
||||
// crate, for its tests.
|
||||
|
||||
pub const SIZE: usize = 128;
|
||||
|
||||
/// `MapColor` ids, with the brightest shade (2).
|
||||
const fn c(id: u8, shade: u8) -> u8 {
|
||||
id * 4 + shade
|
||||
}
|
||||
pub const BACKGROUND: u8 = c(29, 0); // COLOR_BLACK, dark
|
||||
const GRID: u8 = c(21, 3); // COLOR_GRAY, darkest
|
||||
const AXIS: u8 = c(22, 0); // COLOR_LIGHT_GRAY
|
||||
const TEXT: u8 = c(8, 2); // SNOW
|
||||
const DIM: u8 = c(22, 2); // COLOR_LIGHT_GRAY
|
||||
pub const UP: u8 = c(19, 2); // COLOR_LIGHT_GREEN
|
||||
pub const DOWN: u8 = c(28, 2); // COLOR_RED
|
||||
const FILL_UP: u8 = c(27, 3); // COLOR_GREEN, darkest
|
||||
const FILL_DOWN: u8 = c(28, 3);
|
||||
|
||||
/// 3 x 5 glyphs, rows top to bottom, 3 bits each (bit 2 = left).
|
||||
fn glyph(ch: char) -> [u8; 5] {
|
||||
match ch.to_ascii_uppercase() {
|
||||
'0' => [7, 5, 5, 5, 7],
|
||||
'1' => [2, 6, 2, 2, 7],
|
||||
'2' => [7, 1, 7, 4, 7],
|
||||
'3' => [7, 1, 3, 1, 7],
|
||||
'4' => [5, 5, 7, 1, 1],
|
||||
'5' => [7, 4, 7, 1, 7],
|
||||
'6' => [7, 4, 7, 5, 7],
|
||||
'7' => [7, 1, 1, 2, 2],
|
||||
'8' => [7, 5, 7, 5, 7],
|
||||
'9' => [7, 5, 7, 1, 7],
|
||||
'A' => [2, 5, 7, 5, 5],
|
||||
'B' => [6, 5, 6, 5, 6],
|
||||
'C' => [3, 4, 4, 4, 3],
|
||||
'D' => [6, 5, 5, 5, 6],
|
||||
'E' => [7, 4, 6, 4, 7],
|
||||
'F' => [7, 4, 6, 4, 4],
|
||||
'G' => [3, 4, 5, 5, 3],
|
||||
'H' => [5, 5, 7, 5, 5],
|
||||
'I' => [7, 2, 2, 2, 7],
|
||||
'J' => [1, 1, 1, 5, 2],
|
||||
'K' => [5, 5, 6, 5, 5],
|
||||
'L' => [4, 4, 4, 4, 7],
|
||||
'M' => [5, 7, 7, 5, 5],
|
||||
'N' => [6, 5, 5, 5, 5],
|
||||
'O' => [2, 5, 5, 5, 2],
|
||||
'P' => [6, 5, 6, 4, 4],
|
||||
'Q' => [2, 5, 5, 6, 3],
|
||||
'R' => [6, 5, 6, 5, 5],
|
||||
'S' => [3, 4, 2, 1, 6],
|
||||
'T' => [7, 2, 2, 2, 2],
|
||||
'U' => [5, 5, 5, 5, 7],
|
||||
'V' => [5, 5, 5, 5, 2],
|
||||
'W' => [5, 5, 7, 7, 5],
|
||||
'X' => [5, 5, 2, 5, 5],
|
||||
'Y' => [5, 5, 2, 2, 2],
|
||||
'Z' => [7, 1, 2, 4, 7],
|
||||
'.' => [0, 0, 0, 0, 2],
|
||||
',' => [0, 0, 0, 2, 4],
|
||||
':' => [0, 2, 0, 2, 0],
|
||||
'%' => [5, 1, 2, 4, 5],
|
||||
'+' => [0, 2, 7, 2, 0],
|
||||
'-' => [0, 0, 7, 0, 0],
|
||||
'/' => [1, 1, 2, 4, 4],
|
||||
'^' => [2, 7, 7, 0, 0],
|
||||
'v' => [0, 0, 7, 7, 2],
|
||||
_ => [0; 5],
|
||||
}
|
||||
}
|
||||
|
||||
/// A picture being drawn.
|
||||
pub struct Canvas(pub Vec<u8>);
|
||||
|
||||
impl Canvas {
|
||||
fn new() -> Self {
|
||||
Self(vec![BACKGROUND; SIZE * SIZE])
|
||||
}
|
||||
fn set(&mut self, x: i32, y: i32, color: u8) {
|
||||
if (0..SIZE as i32).contains(&x) && (0..SIZE as i32).contains(&y) {
|
||||
self.0[y as usize * SIZE + x as usize] = color;
|
||||
}
|
||||
}
|
||||
/// Text from `x`, `y` (top left), 4 pixels a character.
|
||||
pub fn text(&mut self, x: i32, y: i32, s: &str, color: u8) {
|
||||
for (i, ch) in s.chars().enumerate() {
|
||||
// `▲` `▼` as the font's arrows.
|
||||
let ch = match ch {
|
||||
'▲' => '^',
|
||||
'▼' => 'v',
|
||||
c => c,
|
||||
};
|
||||
for (row, bits) in glyph(ch).iter().enumerate() {
|
||||
for col in 0..3 {
|
||||
if bits & (4 >> col) != 0 {
|
||||
self.set(x + i as i32 * 4 + col, y + row as i32, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn line(&mut self, (x0, y0): (i32, i32), (x1, y1): (i32, i32), color: u8) {
|
||||
let (dx, dy) = ((x1 - x0).abs(), -(y1 - y0).abs());
|
||||
let (sx, sy) = (if x0 < x1 { 1 } else { -1 }, if y0 < y1 { 1 } else { -1 });
|
||||
let (mut x, mut y, mut err) = (x0, y0, dx + dy);
|
||||
loop {
|
||||
self.set(x, y, color);
|
||||
if x == x1 && y == y1 {
|
||||
break;
|
||||
}
|
||||
let e2 = 2 * err;
|
||||
if e2 >= dy {
|
||||
err += dy;
|
||||
x += sx;
|
||||
}
|
||||
if e2 <= dx {
|
||||
err += dx;
|
||||
y += sy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A short price: `412`, `41.2`, `1.2K`, `3.4M`.
|
||||
pub fn short(v: f64) -> String {
|
||||
if v >= 1e6 {
|
||||
format!("{:.1}M", v / 1e6)
|
||||
} else if v >= 1e4 {
|
||||
format!("{:.1}K", v / 1e3)
|
||||
} else if v >= 100.0 {
|
||||
format!("{v:.0}")
|
||||
} else {
|
||||
format!("{v:.1}")
|
||||
}
|
||||
}
|
||||
|
||||
/// The chart of `prices` (oldest first) for `title` over `span` ("24H",
|
||||
/// "7D"): the picture's colours.
|
||||
pub fn render(title: &str, span: &str, prices: &[f64]) -> Vec<u8> {
|
||||
let mut c = Canvas::new();
|
||||
let title: String = title.chars().take(24).collect();
|
||||
c.text(2, 2, &title, TEXT);
|
||||
let (plot_x, plot_y, plot_w, plot_h) = (2i32, 20i32, 124i32, 92i32);
|
||||
if prices.len() < 2 {
|
||||
c.text(2, 60, "NO TRADES YET", DIM);
|
||||
c.text(2, 120, span, DIM);
|
||||
return c.0;
|
||||
}
|
||||
let first = prices[0];
|
||||
let last = *prices.last().expect("two");
|
||||
let (mut lo, mut hi) = prices
|
||||
.iter()
|
||||
.fold((f64::MAX, f64::MIN), |(l, h), &p| (l.min(p), h.max(p)));
|
||||
if hi - lo < hi.abs() * 0.01 {
|
||||
// A flat line: a band of 1% around it.
|
||||
let m = (hi + lo) / 2.0;
|
||||
lo = m * 0.995;
|
||||
hi = m * 1.005;
|
||||
}
|
||||
let change = if first > 0.0 {
|
||||
(last / first - 1.0) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let (line, fill) = if change >= 0.0 {
|
||||
(UP, FILL_UP)
|
||||
} else {
|
||||
(DOWN, FILL_DOWN)
|
||||
};
|
||||
let arrow = if change >= 0.0 { '▲' } else { '▼' };
|
||||
c.text(2, 10, &short(last), TEXT);
|
||||
let ch = format!("{arrow}{:.1}%", change.abs());
|
||||
c.text(126 - ch.chars().count() as i32 * 4, 10, &ch, line);
|
||||
// The grid: four rows, the span's quarters.
|
||||
for i in 0..=4 {
|
||||
let y = plot_y + plot_h * i / 4;
|
||||
for x in (plot_x..plot_x + plot_w).step_by(2) {
|
||||
c.set(x, y, GRID);
|
||||
}
|
||||
let x = plot_x + plot_w * i / 4;
|
||||
for y in (plot_y..plot_y + plot_h).step_by(2) {
|
||||
c.set(x, y, GRID);
|
||||
}
|
||||
}
|
||||
let to_y = |p: f64| plot_y + plot_h - ((p - lo) / (hi - lo) * f64::from(plot_h)).round() as i32;
|
||||
let n = prices.len();
|
||||
let to_x =
|
||||
|i: usize| plot_x + (i as f64 / (n - 1) as f64 * f64::from(plot_w - 1)).round() as i32;
|
||||
// The area under the curve, then the curve.
|
||||
let mut prev = (to_x(0), to_y(prices[0]));
|
||||
for (i, &p) in prices.iter().enumerate().skip(1) {
|
||||
let pt = (to_x(i), to_y(p));
|
||||
for x in prev.0..=pt.0 {
|
||||
let t = if pt.0 == prev.0 {
|
||||
1.0
|
||||
} else {
|
||||
f64::from(x - prev.0) / f64::from(pt.0 - prev.0)
|
||||
};
|
||||
let y = (f64::from(prev.1) + t * f64::from(pt.1 - prev.1)).round() as i32;
|
||||
for yy in (y + 2..=plot_y + plot_h).step_by(2) {
|
||||
c.set(x, yy, fill);
|
||||
}
|
||||
}
|
||||
prev = pt;
|
||||
}
|
||||
let mut prev = (to_x(0), to_y(prices[0]));
|
||||
for (i, &p) in prices.iter().enumerate().skip(1) {
|
||||
let pt = (to_x(i), to_y(p));
|
||||
c.line(prev, pt, line);
|
||||
c.line((prev.0, prev.1 - 1), (pt.0, pt.1 - 1), line);
|
||||
prev = pt;
|
||||
}
|
||||
c.line(
|
||||
(plot_x, plot_y + plot_h),
|
||||
(plot_x + plot_w - 1, plot_y + plot_h),
|
||||
AXIS,
|
||||
);
|
||||
c.text(2, 116, &format!("L {}", short(lo)), DIM);
|
||||
let high = format!("H {}", short(hi));
|
||||
c.text(64, 116, &high, DIM);
|
||||
c.text(126 - span.chars().count() as i32 * 4, 122, span, DIM);
|
||||
c.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_rising_chart_is_green_and_a_falling_one_red() {
|
||||
let up: Vec<f64> = (0..100).map(|i| 100.0 + f64::from(i)).collect();
|
||||
let pic = render("Diamond", "24H", &up);
|
||||
assert_eq!(pic.len(), SIZE * SIZE);
|
||||
assert!(pic.contains(&UP) && !pic.contains(&DOWN));
|
||||
let down: Vec<f64> = up.iter().rev().copied().collect();
|
||||
let pic = render("Diamond", "24H", &down);
|
||||
assert!(pic.contains(&DOWN) && !pic.contains(&UP));
|
||||
// The curve's ends: low on the left of a rising one.
|
||||
let pic = render("X", "24H", &up);
|
||||
let col = |x: usize| (20..113).find(|&y| pic[y * SIZE + x] == UP);
|
||||
assert!(col(3).unwrap() > col(124).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_history_and_flat_prices_draw_without_dividing_by_zero() {
|
||||
let pic = render("Wheat", "7D", &[]);
|
||||
assert!(pic.contains(&TEXT) || pic.contains(&DIM));
|
||||
let pic = render("Wheat", "7D", &[2.0; 50]);
|
||||
assert!(pic.contains(&UP));
|
||||
assert_eq!(short(412.4), "412");
|
||||
assert_eq!(short(41.24), "41.2");
|
||||
assert_eq!(short(12_400.0), "12.4K");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// `/market`: the moving market as a chest menu. The first page shows the
|
||||
// categories (the shop's) and the day's movers; a category's page its items,
|
||||
// each with its buy and sell price, its change over 1 h and 24 h (green ▲,
|
||||
// red ▼) and its volume; an item's page buys or sells 1, 16 or 64, sells all
|
||||
// the player carries, and gives the price chart of the last 24 h or 7 days
|
||||
// as a map (smp_market_chart). The menus follow the prices at each minute.
|
||||
// Buying and selling go through the market's rules (smp_market). This file
|
||||
// lays the pages out and says what a click means. No game types. Added to
|
||||
// the game as `game::smp_market_menu`; also a module of this crate, for its
|
||||
// tests.
|
||||
|
||||
use super::smp_market::Config as MarketConfig;
|
||||
use super::smp_shop::Config as ShopConfig;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub const ROWS: u8 = 6;
|
||||
pub const SIZE: usize = 54;
|
||||
const PER_PAGE: usize = 45;
|
||||
const BACK: usize = 45;
|
||||
const NEWS: usize = 46;
|
||||
const PREV: usize = 47;
|
||||
const BALANCE: usize = 48;
|
||||
const NEXT: usize = 51;
|
||||
const CLOSE: usize = 53;
|
||||
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,
|
||||
];
|
||||
/// An item's page.
|
||||
const ICON: usize = 13;
|
||||
const BUYS: [(usize, u32); 3] = [(19, 1), (20, 16), (21, 64)];
|
||||
const SELLS: [(usize, u32); 3] = [(23, 1), (24, 16), (25, 64)];
|
||||
const SELL_ALL: usize = 31;
|
||||
const CHART_DAY: usize = 39;
|
||||
const CHART_WEEK: usize = 41;
|
||||
|
||||
/// Which chart.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Chart {
|
||||
Day,
|
||||
Week,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Page {
|
||||
Main,
|
||||
/// A category (index, or the categories' count for "Other"), its page.
|
||||
Category(usize, usize),
|
||||
/// An item, and the category page to go back to.
|
||||
Item(String, usize, usize),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Cell {
|
||||
Empty,
|
||||
Filler,
|
||||
/// Its icon, name, how many items.
|
||||
Category(String, String, usize),
|
||||
/// An item in a list (its prices and moves in the lore).
|
||||
Item(String),
|
||||
/// The item of the page, with every number.
|
||||
Detail(String),
|
||||
Buy(String, u32),
|
||||
Sell(String, u32),
|
||||
SellAll(String),
|
||||
Chart(String, Chart),
|
||||
News,
|
||||
Back,
|
||||
Prev,
|
||||
Next,
|
||||
Balance,
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Click {
|
||||
Nothing,
|
||||
/// The page changed: show it.
|
||||
Show,
|
||||
Buy(String, u32),
|
||||
Sell(String, u32),
|
||||
SellAll(String),
|
||||
Chart(String, Chart),
|
||||
Close,
|
||||
}
|
||||
|
||||
/// The categories: the shop's, then "Other" for the market's items in none.
|
||||
pub fn categories(shop: &ShopConfig, market: &MarketConfig) -> Vec<(String, String, Vec<String>)> {
|
||||
let mut out: Vec<(String, String, Vec<String>)> = shop
|
||||
.categories
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let items = c
|
||||
.items
|
||||
.iter()
|
||||
.filter(|i| market.items.contains_key(*i))
|
||||
.cloned()
|
||||
.collect();
|
||||
(c.icon.clone(), c.name.clone(), items)
|
||||
})
|
||||
.filter(|c: &(String, String, Vec<String>)| !c.2.is_empty())
|
||||
.collect();
|
||||
let other: Vec<String> = market
|
||||
.items
|
||||
.keys()
|
||||
.filter(|i| !shop.categories.iter().any(|c| c.items.contains(i)))
|
||||
.cloned()
|
||||
.collect();
|
||||
if !other.is_empty() {
|
||||
out.push(("minecraft:chest".into(), "Other".into(), other));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The open `/market` menus: window and page, by player.
|
||||
#[derive(Default)]
|
||||
pub struct Menus {
|
||||
pub views: HashMap<u128, (Option<i32>, Page)>,
|
||||
}
|
||||
|
||||
impl Menus {
|
||||
pub fn layout(&self, shop: &ShopConfig, market: &MarketConfig, uuid: u128) -> Vec<Cell> {
|
||||
let Some((_, page)) = self.views.get(&uuid) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let cats = categories(shop, market);
|
||||
let mut cells = vec![Cell::Empty; SIZE];
|
||||
for c in cells.iter_mut().skip(PER_PAGE) {
|
||||
*c = Cell::Filler;
|
||||
}
|
||||
cells[BALANCE] = Cell::Balance;
|
||||
cells[CLOSE] = Cell::Close;
|
||||
match page {
|
||||
Page::Main => {
|
||||
for (slot, (icon, name, items)) in CATEGORY_SLOTS.iter().zip(&cats) {
|
||||
cells[*slot] = Cell::Category(icon.clone(), name.clone(), items.len());
|
||||
}
|
||||
cells[NEWS] = Cell::News;
|
||||
}
|
||||
Page::Category(c, p) => {
|
||||
let items = cats.get(*c).map(|c| c.2.as_slice()).unwrap_or_default();
|
||||
for (i, id) in items.iter().skip(p * PER_PAGE).take(PER_PAGE).enumerate() {
|
||||
cells[i] = Cell::Item(id.clone());
|
||||
}
|
||||
cells[BACK] = Cell::Back;
|
||||
if *p > 0 {
|
||||
cells[PREV] = Cell::Prev;
|
||||
}
|
||||
if (p + 1) * PER_PAGE < items.len() {
|
||||
cells[NEXT] = Cell::Next;
|
||||
}
|
||||
}
|
||||
Page::Item(id, ..) => {
|
||||
for c in cells.iter_mut().take(PER_PAGE) {
|
||||
*c = Cell::Filler;
|
||||
}
|
||||
cells[ICON] = Cell::Detail(id.clone());
|
||||
let p = market.items.get(id).copied().unwrap_or_default();
|
||||
for (slot, n) in BUYS {
|
||||
cells[slot] = if p.buy > 0 {
|
||||
Cell::Buy(id.clone(), n)
|
||||
} else {
|
||||
Cell::Filler
|
||||
};
|
||||
}
|
||||
for (slot, n) in SELLS {
|
||||
cells[slot] = if p.sell > 0 {
|
||||
Cell::Sell(id.clone(), n)
|
||||
} else {
|
||||
Cell::Filler
|
||||
};
|
||||
}
|
||||
if p.sell > 0 {
|
||||
cells[SELL_ALL] = Cell::SellAll(id.clone());
|
||||
}
|
||||
cells[CHART_DAY] = Cell::Chart(id.clone(), Chart::Day);
|
||||
cells[CHART_WEEK] = Cell::Chart(id.clone(), Chart::Week);
|
||||
cells[BACK] = Cell::Back;
|
||||
}
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
/// A click on the menu's `slot`: the page moves here; a trade is the
|
||||
/// glue's to make.
|
||||
pub fn click(
|
||||
&mut self,
|
||||
shop: &ShopConfig,
|
||||
market: &MarketConfig,
|
||||
uuid: u128,
|
||||
slot: usize,
|
||||
) -> Click {
|
||||
let cells = self.layout(shop, market, uuid);
|
||||
let Some((_, page)) = self.views.get_mut(&uuid) else {
|
||||
return Click::Nothing;
|
||||
};
|
||||
let cats = categories(shop, market);
|
||||
match (cells.get(slot), page.clone()) {
|
||||
(Some(Cell::Close), _) => Click::Close,
|
||||
(Some(Cell::Category(..)), Page::Main) => {
|
||||
match CATEGORY_SLOTS.iter().position(|s| *s == slot) {
|
||||
Some(i) if i < cats.len() => {
|
||||
*page = Page::Category(i, 0);
|
||||
Click::Show
|
||||
}
|
||||
_ => Click::Nothing,
|
||||
}
|
||||
}
|
||||
(Some(Cell::Item(id)), Page::Category(c, p)) => {
|
||||
*page = Page::Item(id.clone(), c, p);
|
||||
Click::Show
|
||||
}
|
||||
(Some(Cell::Back), Page::Category(..)) => {
|
||||
*page = Page::Main;
|
||||
Click::Show
|
||||
}
|
||||
(Some(Cell::Back), Page::Item(_, c, p)) => {
|
||||
*page = Page::Category(c, p);
|
||||
Click::Show
|
||||
}
|
||||
(Some(Cell::Prev), Page::Category(c, p)) => {
|
||||
*page = Page::Category(c, p.saturating_sub(1));
|
||||
Click::Show
|
||||
}
|
||||
(Some(Cell::Next), Page::Category(c, p)) => {
|
||||
*page = Page::Category(c, p + 1);
|
||||
Click::Show
|
||||
}
|
||||
(Some(Cell::Buy(id, n)), _) => Click::Buy(id.clone(), *n),
|
||||
(Some(Cell::Sell(id, n)), _) => Click::Sell(id.clone(), *n),
|
||||
(Some(Cell::SellAll(id)), _) => Click::SellAll(id.clone()),
|
||||
(Some(Cell::Chart(id, c)), _) => Click::Chart(id.clone(), *c),
|
||||
_ => Click::Nothing,
|
||||
}
|
||||
}
|
||||
|
||||
/// `/market <item>`: straight to its page (back goes to its category).
|
||||
pub fn page_of(shop: &ShopConfig, market: &MarketConfig, id: &str) -> Option<Page> {
|
||||
if !market.items.contains_key(id) {
|
||||
return None;
|
||||
}
|
||||
let cats = categories(shop, market);
|
||||
let c = cats
|
||||
.iter()
|
||||
.position(|c| c.2.iter().any(|i| i == id))
|
||||
.unwrap_or(0);
|
||||
let p = cats
|
||||
.get(c)
|
||||
.and_then(|c| c.2.iter().position(|i| i == id))
|
||||
.unwrap_or(0)
|
||||
/ PER_PAGE;
|
||||
Some(Page::Item(id.to_owned(), c, p))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn configs() -> (ShopConfig, MarketConfig) {
|
||||
(ShopConfig::default(), MarketConfig::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn categories_then_items_then_an_items_page_and_back() {
|
||||
let (shop, market) = configs();
|
||||
let mut m = Menus::default();
|
||||
m.views.insert(1, (Some(3), Page::Main));
|
||||
let cells = m.layout(&shop, &market, 1);
|
||||
assert!(matches!(&cells[10], Cell::Category(_, name, _) if name == "Farming"));
|
||||
assert_eq!(cells[NEWS], Cell::News);
|
||||
// Mining is the second category.
|
||||
assert_eq!(m.click(&shop, &market, 1, 11), Click::Show);
|
||||
let cells = m.layout(&shop, &market, 1);
|
||||
let at = cells
|
||||
.iter()
|
||||
.position(|c| *c == Cell::Item("minecraft:diamond".into()))
|
||||
.unwrap();
|
||||
assert_eq!(m.click(&shop, &market, 1, at), Click::Show);
|
||||
let cells = m.layout(&shop, &market, 1);
|
||||
assert_eq!(cells[ICON], Cell::Detail("minecraft:diamond".into()));
|
||||
assert_eq!(
|
||||
m.click(&shop, &market, 1, 21),
|
||||
Click::Buy("minecraft:diamond".into(), 64)
|
||||
);
|
||||
assert_eq!(
|
||||
m.click(&shop, &market, 1, 23),
|
||||
Click::Sell("minecraft:diamond".into(), 1)
|
||||
);
|
||||
assert_eq!(
|
||||
m.click(&shop, &market, 1, SELL_ALL),
|
||||
Click::SellAll("minecraft:diamond".into())
|
||||
);
|
||||
assert_eq!(
|
||||
m.click(&shop, &market, 1, CHART_WEEK),
|
||||
Click::Chart("minecraft:diamond".into(), Chart::Week)
|
||||
);
|
||||
assert_eq!(m.click(&shop, &market, 1, BACK), Click::Show);
|
||||
assert_eq!(m.views[&1].1, Page::Category(1, 0));
|
||||
assert_eq!(m.click(&shop, &market, 1, BACK), Click::Show);
|
||||
assert_eq!(m.views[&1].1, Page::Main);
|
||||
assert_eq!(m.click(&shop, &market, 1, CLOSE), Click::Close);
|
||||
assert_eq!(m.click(&shop, &market, 1, 0), Click::Nothing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_side_the_market_doesnt_trade_has_no_buttons() {
|
||||
let (shop, market) = configs();
|
||||
let mut m = Menus::default();
|
||||
m.views.insert(
|
||||
1,
|
||||
(
|
||||
Some(3),
|
||||
Menus::page_of(&shop, &market, "minecraft:bread").unwrap(),
|
||||
),
|
||||
);
|
||||
let cells = m.layout(&shop, &market, 1);
|
||||
assert_eq!(cells[19], Cell::Buy("minecraft:bread".into(), 1));
|
||||
assert_eq!(cells[23], Cell::Filler, "the market doesn't buy bread");
|
||||
assert_eq!(cells[SELL_ALL], Cell::Filler);
|
||||
assert_eq!(
|
||||
Menus::page_of(&shop, &market, "minecraft:nether_star"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,868 @@
|
||||
// The market's moving prices (smp_market's `dynamic`): each item has one
|
||||
// base price (config.json's `buy`, the same as its `sell` when the market
|
||||
// trades it both ways), a factor that scales it, and a history. A purchase
|
||||
// pushes the factor up and a sale down, at once, by an elasticity per item
|
||||
// (rare, dear items move faster than common ones). A trade of n items costs
|
||||
// the area under the price along the way it moves the price (`fill`): the
|
||||
// same curve both ways, so a sale exactly retraces a purchase, and a round
|
||||
// trip earns nothing, whoever trades and however the price was
|
||||
// pushed. Within a minute the price moves at most `max_minute_move_percent`
|
||||
// from where the minute began: the rest of a trade waits for the next minute
|
||||
// (never priced flat: buying slowly while the price drifts back, then
|
||||
// dumping at a flat edge, would earn). At `min_factor`..`max_factor` the
|
||||
// price stops and a trade goes at that bound, the least the market ever pays
|
||||
// or the most it ever asks. Between trades the factor drifts back toward
|
||||
// 1 (mean reversion). Items made of one another (`smp_recipes`) share one
|
||||
// factor, with an elasticity by value, so crafting can't beat the market. The
|
||||
// history: one point a minute for 24 h, one an hour for 30 days
|
||||
// (`market_prices.json`), the day's movers and news (`summary`). No game types. Added
|
||||
// to the game as `game::smp_market_prices`; also a module of this crate, for
|
||||
// its tests.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const MINUTE_MS: i64 = 60_000;
|
||||
pub const HOUR_MS: i64 = 3_600_000;
|
||||
/// Points kept: a day of minutes, 30 days of hours.
|
||||
pub const MINUTES: usize = 1440;
|
||||
pub const HOURS: usize = 720;
|
||||
|
||||
/// `config.json`'s `market.dynamic`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Dynamic {
|
||||
pub enabled: bool,
|
||||
pub min_factor: f64,
|
||||
pub max_factor: f64,
|
||||
/// How long the factor takes to come halfway back to 1.
|
||||
pub half_life_hours: f64,
|
||||
/// Unused since 0.29 (trades move the price at once, along one curve):
|
||||
/// kept so older configs read the same.
|
||||
pub max_trade_impact_percent: f64,
|
||||
pub max_player_impact_percent: f64,
|
||||
/// How far the price may move within one minute (either way).
|
||||
pub max_minute_move_percent: f64,
|
||||
/// What a player bought from the market can't be sold back to it for
|
||||
/// this long (that many of that item).
|
||||
pub round_trip_hours: i64,
|
||||
/// Items that move the price 1%, by item (else from its price: dear
|
||||
/// items move faster).
|
||||
pub depth: BTreeMap<String, f64>,
|
||||
/// A day's supply change that makes news, and the least volume for it.
|
||||
pub news_percent: f64,
|
||||
pub news_min_volume: u64,
|
||||
}
|
||||
|
||||
impl Default for Dynamic {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
min_factor: 0.6,
|
||||
max_factor: 1.6,
|
||||
half_life_hours: 6.0,
|
||||
max_trade_impact_percent: 2.0,
|
||||
max_player_impact_percent: 2.0,
|
||||
max_minute_move_percent: 5.0,
|
||||
round_trip_hours: 24,
|
||||
depth: BTreeMap::new(),
|
||||
news_percent: 30.0,
|
||||
news_min_volume: 64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dynamic {
|
||||
/// Its numbers made sane: factors around 1, caps positive.
|
||||
pub fn validate(&mut self) {
|
||||
let ok = |v: f64, lo: f64, hi: f64, d: f64| if v.is_finite() { v.clamp(lo, hi) } else { d };
|
||||
let d = Dynamic::default();
|
||||
self.min_factor = ok(self.min_factor, 0.05, 1.0, d.min_factor);
|
||||
self.max_factor = ok(self.max_factor, 1.0, 20.0, d.max_factor);
|
||||
self.half_life_hours = ok(self.half_life_hours, 0.1, 24.0 * 30.0, d.half_life_hours);
|
||||
self.max_trade_impact_percent = ok(
|
||||
self.max_trade_impact_percent,
|
||||
0.0,
|
||||
50.0,
|
||||
d.max_trade_impact_percent,
|
||||
);
|
||||
self.max_player_impact_percent = ok(
|
||||
self.max_player_impact_percent,
|
||||
0.0,
|
||||
50.0,
|
||||
d.max_player_impact_percent,
|
||||
);
|
||||
self.max_minute_move_percent = ok(
|
||||
self.max_minute_move_percent,
|
||||
0.0,
|
||||
50.0,
|
||||
d.max_minute_move_percent,
|
||||
);
|
||||
self.round_trip_hours = self.round_trip_hours.clamp(0, 24 * 30);
|
||||
self.depth.retain(|_, v| v.is_finite() && *v > 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// One point of history: its start (ms), the mid price then, the items
|
||||
/// traded (both sides) and the items players sold.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(from = "(i64, f64, u32, u32)", into = "(i64, f64, u32, u32)")]
|
||||
pub struct Point {
|
||||
pub t: i64,
|
||||
pub price: f64,
|
||||
pub volume: u32,
|
||||
pub sold: u32,
|
||||
}
|
||||
|
||||
impl From<(i64, f64, u32, u32)> for Point {
|
||||
fn from((t, price, volume, sold): (i64, f64, u32, u32)) -> Self {
|
||||
Self {
|
||||
t,
|
||||
price,
|
||||
volume,
|
||||
sold,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Point> for (i64, f64, u32, u32) {
|
||||
fn from(p: Point) -> Self {
|
||||
(p.t, (p.price * 100.0).round() / 100.0, p.volume, p.sold)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which history.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Span {
|
||||
Minute,
|
||||
Hour,
|
||||
}
|
||||
|
||||
/// The price of one now (`mid`), and what one costs to buy and one brings
|
||||
/// when sold, in whole coins (the price moves a little as a trade goes, and
|
||||
/// coins round against the trader).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Quote {
|
||||
pub mid: f64,
|
||||
pub buy: i64,
|
||||
pub sell: i64,
|
||||
}
|
||||
|
||||
/// One item's moving price.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Track {
|
||||
pub factor: f64,
|
||||
pub minutes: Vec<Point>,
|
||||
pub hours: Vec<Point>,
|
||||
/// The log of the factor when this minute began (the minute's band is
|
||||
/// around it).
|
||||
pub open: f64,
|
||||
/// This minute's volume and sales.
|
||||
pub volume: u32,
|
||||
pub sold: u32,
|
||||
/// The hour being summed.
|
||||
pub hour_volume: u32,
|
||||
pub hour_sold: u32,
|
||||
}
|
||||
|
||||
/// `market_prices.json`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Book {
|
||||
pub items: BTreeMap<String, Track>,
|
||||
/// The start of the minute the flows are for.
|
||||
pub minute: i64,
|
||||
/// The UTC day the last summary was for.
|
||||
pub summary_day: i64,
|
||||
/// What each player bought (item, when, how many), for the round-trip
|
||||
/// rule.
|
||||
pub bought: BTreeMap<String, Vec<(String, i64, u32)>>,
|
||||
/// The last daily summary's lines (for the web page and Kira).
|
||||
pub last_summary: Vec<String>,
|
||||
/// The base price each item's history is quoted in (since 0.29).
|
||||
pub bases: BTreeMap<String, f64>,
|
||||
}
|
||||
|
||||
/// What `value` coins of a purchase cost in whole coins (rounded up; a
|
||||
/// hundredth of a coin forgiven, so a whole price stays whole).
|
||||
pub fn to_pay(value: f64) -> i64 {
|
||||
(value - 0.01).ceil().max(0.0) as i64
|
||||
}
|
||||
|
||||
/// What `value` coins of a sale bring in whole coins (rounded down).
|
||||
pub fn to_get(value: f64) -> i64 {
|
||||
(value + 0.01).floor().max(0.0) as i64
|
||||
}
|
||||
|
||||
/// One price for an item the market both buys and sells at `buy` and `sell`
|
||||
/// (before 0.29 they differed): their geometric mean, whole, at least 1.
|
||||
pub fn one_price(buy: i64, sell: i64) -> i64 {
|
||||
match (buy > 0, sell > 0) {
|
||||
(true, true) => ((buy as f64 * sell as f64).sqrt().round() as i64).max(1),
|
||||
(true, false) => buy,
|
||||
_ => sell.max(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The base mid price of an item priced `buy` and `sell`.
|
||||
pub fn base_mid(buy: i64, sell: i64) -> f64 {
|
||||
match (buy > 0, sell > 0) {
|
||||
(true, true) => (buy + sell) as f64 / 2.0,
|
||||
(true, false) => buy as f64,
|
||||
_ => sell as f64,
|
||||
}
|
||||
}
|
||||
|
||||
impl Book {
|
||||
fn track(&mut self, id: &str) -> &mut Track {
|
||||
self.items.entry(id.to_owned()).or_insert_with(|| Track {
|
||||
factor: 1.0,
|
||||
..Track::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// The factor now (1 for an item never traded).
|
||||
pub fn factor(&self, id: &str) -> f64 {
|
||||
self.items
|
||||
.get(id)
|
||||
.map_or(1.0, |t| if t.factor > 0.0 { t.factor } else { 1.0 })
|
||||
}
|
||||
|
||||
/// The price now of an item based at `mid` (one unit moving its log
|
||||
/// factor `delta`): one, and what one costs and brings.
|
||||
pub fn quote(&self, config: &Dynamic, id: &str, mid: f64, delta: f64) -> Quote {
|
||||
Quote {
|
||||
mid: mid * self.factor(id),
|
||||
buy: to_pay(self.one(config, id, mid, delta, true)),
|
||||
sell: to_get(self.one(config, id, mid, delta, false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// One unit's value now along the curve (within the bounds; the
|
||||
/// minute's band aside, so a side the band has closed still shows what
|
||||
/// its next one goes for).
|
||||
fn one(&self, config: &Dynamic, id: &str, mid: f64, delta: f64, buy: bool) -> f64 {
|
||||
let s0 = self.factor(id).ln();
|
||||
let s1 = if buy {
|
||||
(s0 + delta).min(config.max_factor.ln().max(s0))
|
||||
} else {
|
||||
(s0 - delta).max(config.min_factor.ln().min(s0))
|
||||
};
|
||||
if delta <= 0.0 || s1 == s0 {
|
||||
return mid * s0.exp();
|
||||
}
|
||||
mid / delta * (s1.exp() - s0.exp()).abs()
|
||||
+ (1.0 - (s1 - s0).abs() / delta).max(0.0) * mid * s1.exp()
|
||||
}
|
||||
|
||||
/// Items that move `id` (based at `mid`) by 1%.
|
||||
pub fn depth(config: &Dynamic, id: &str, mid: f64) -> f64 {
|
||||
config
|
||||
.depth
|
||||
.get(id)
|
||||
.copied()
|
||||
.unwrap_or_else(|| (400.0 / mid.max(0.01)).clamp(2.0, 400.0))
|
||||
}
|
||||
|
||||
/// How far one of `id` (based at `mid`) moves the log of its factor.
|
||||
pub fn unit_move(config: &Dynamic, id: &str, mid: f64) -> f64 {
|
||||
0.01 / Self::depth(config, id, mid)
|
||||
}
|
||||
|
||||
/// What `n` of `id` (based at `mid`, each moving the log factor by
|
||||
/// `delta`) are worth bought (`buy`) or sold now: the area under the
|
||||
/// price as the trade moves it, exactly, so the same items sold back
|
||||
/// retrace it. At a bound the price stops and the rest goes at it; past
|
||||
/// this minute's band the rest isn't traded. The value, the log factor
|
||||
/// after, and how many of the `n` go.
|
||||
pub fn fill(
|
||||
&self,
|
||||
config: &Dynamic,
|
||||
id: &str,
|
||||
mid: f64,
|
||||
delta: f64,
|
||||
n: u32,
|
||||
buy: bool,
|
||||
) -> (f64, f64, u32) {
|
||||
let s0 = self.factor(id).ln();
|
||||
let open = self.items.get(id).map_or(0.0, |t| t.open);
|
||||
let band = config.max_minute_move_percent / 100.0;
|
||||
let sign = if buy { 1.0 } else { -1.0 };
|
||||
// How far this minute still lets it go, and where the bound is.
|
||||
let edge = sign * (open + sign * band - s0);
|
||||
let bound = if buy {
|
||||
config.max_factor.ln() - s0
|
||||
} else {
|
||||
s0 - config.min_factor.ln()
|
||||
};
|
||||
if delta <= 0.0 {
|
||||
return (f64::from(n) * mid * s0.exp(), s0, n);
|
||||
}
|
||||
let mut done = n;
|
||||
if edge < bound {
|
||||
// The band comes first: as many as fit in it.
|
||||
let fit = (edge.max(0.0) / delta + 1e-9).floor();
|
||||
if fit < f64::from(n) {
|
||||
done = fit as u32;
|
||||
}
|
||||
}
|
||||
let reach = (f64::from(done) * delta).min(bound.max(0.0));
|
||||
let s1 = s0 + sign * reach;
|
||||
let curve = mid / delta * (s1.exp() - s0.exp()).abs();
|
||||
let flat = (f64::from(done) - reach / delta).max(0.0) * mid * s1.exp();
|
||||
(curve + flat, s1, done)
|
||||
}
|
||||
|
||||
/// A trade of `n` of `id` (based at `mid`, `delta` a unit) now: its
|
||||
/// value (`fill`), the factor moved at once for `id` and every item in
|
||||
/// `family` (the items it is made of or into), the volume counted.
|
||||
#[allow(
|
||||
clippy::too_many_arguments,
|
||||
reason = "one trade's facts, read in one place"
|
||||
)]
|
||||
pub fn trade(
|
||||
&mut self,
|
||||
config: &Dynamic,
|
||||
id: &str,
|
||||
family: &[String],
|
||||
mid: f64,
|
||||
delta: f64,
|
||||
n: u32,
|
||||
buy: bool,
|
||||
) -> f64 {
|
||||
let (value, s1, n) = self.fill(config, id, mid, delta, n, buy);
|
||||
let factor = s1.exp();
|
||||
self.track(id).factor = factor;
|
||||
for other in family {
|
||||
self.track(other).factor = factor;
|
||||
}
|
||||
let t = self.track(id);
|
||||
t.volume = t.volume.saturating_add(n);
|
||||
t.hour_volume = t.hour_volume.saturating_add(n);
|
||||
if !buy {
|
||||
t.sold = t.sold.saturating_add(n);
|
||||
t.hour_sold = t.hour_sold.saturating_add(n);
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
/// Items made of one another share one factor: each family's takes the
|
||||
/// first member's that has one (after a config adds a family).
|
||||
/// At load: each minute's band opens where its price is, and a book
|
||||
/// from before 0.29 (no `bases`), whose history is quoted in the old
|
||||
/// mid of buy and sell (`old`), gets it scaled to the one price of now
|
||||
/// (`bases`), so the charts and the moves see no
|
||||
/// cliff. The items it scaled, by how much.
|
||||
pub fn settle(
|
||||
&mut self,
|
||||
old: &BTreeMap<String, f64>,
|
||||
bases: &BTreeMap<String, f64>,
|
||||
) -> BTreeMap<String, f64> {
|
||||
let mut scaled = BTreeMap::new();
|
||||
if self.bases.is_empty() {
|
||||
for (id, t) in self.items.iter_mut() {
|
||||
let (Some(&was), Some(&now)) = (old.get(id), bases.get(id)) else {
|
||||
continue;
|
||||
};
|
||||
if was > 0.0 && now > 0.0 && (now / was - 1.0).abs() > 1e-9 {
|
||||
let k = now / was;
|
||||
for p in t.minutes.iter_mut().chain(t.hours.iter_mut()) {
|
||||
p.price *= k;
|
||||
}
|
||||
scaled.insert(id.clone(), k);
|
||||
}
|
||||
}
|
||||
}
|
||||
for t in self.items.values_mut() {
|
||||
t.open = if t.factor > 0.0 { t.factor.ln() } else { 0.0 };
|
||||
}
|
||||
self.bases = bases.clone();
|
||||
scaled
|
||||
}
|
||||
|
||||
pub fn link(&mut self, families: &[Vec<String>]) {
|
||||
for f in families {
|
||||
let Some((factor, open)) = f
|
||||
.iter()
|
||||
.find_map(|id| self.items.get(id).map(|t| (t.factor, t.open)))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for id in f {
|
||||
let t = self.track(id);
|
||||
t.factor = factor;
|
||||
t.open = open;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The minute timer: every minute since the last one, each item's
|
||||
/// factor drawn back toward 1 and kept in bounds, the minute's band set,
|
||||
/// a point written (and an hour's at each hour). `mids`: the items
|
||||
/// traded, with their base mid. Whether a minute passed.
|
||||
pub fn tick(&mut self, config: &Dynamic, mids: &BTreeMap<String, f64>, now: i64) -> bool {
|
||||
let minute = now.div_euclid(MINUTE_MS) * MINUTE_MS;
|
||||
if self.minute == 0 {
|
||||
self.minute = minute;
|
||||
return false;
|
||||
}
|
||||
if minute <= self.minute {
|
||||
return false;
|
||||
}
|
||||
// After a long stop, one step for the whole gap (the reversion
|
||||
// covers it), its point at the last minute.
|
||||
let gap = ((minute - self.minute) / MINUTE_MS).max(1) as f64;
|
||||
let decay = 0.5f64.powf(gap / (config.half_life_hours * 60.0));
|
||||
let (lo, hi) = (config.min_factor.ln(), config.max_factor.ln());
|
||||
let closed = self.minute;
|
||||
for (id, mid) in mids {
|
||||
let t = self.track(id);
|
||||
let log = (t.factor.max(1e-9).ln() * decay).clamp(lo, hi);
|
||||
t.factor = log.exp();
|
||||
t.open = log;
|
||||
let p = Point {
|
||||
t: closed,
|
||||
price: mid * t.factor,
|
||||
volume: std::mem::take(&mut t.volume),
|
||||
sold: std::mem::take(&mut t.sold),
|
||||
};
|
||||
t.minutes.push(p);
|
||||
if t.minutes.len() > MINUTES {
|
||||
let extra = t.minutes.len() - MINUTES;
|
||||
t.minutes.drain(..extra);
|
||||
}
|
||||
if minute.div_euclid(HOUR_MS) != closed.div_euclid(HOUR_MS) {
|
||||
t.hours.push(Point {
|
||||
t: closed.div_euclid(HOUR_MS) * HOUR_MS,
|
||||
price: p.price,
|
||||
volume: std::mem::take(&mut t.hour_volume),
|
||||
sold: std::mem::take(&mut t.hour_sold),
|
||||
});
|
||||
if t.hours.len() > HOURS {
|
||||
let extra = t.hours.len() - HOURS;
|
||||
t.hours.drain(..extra);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Items no longer traded go.
|
||||
self.items.retain(|id, _| mids.contains_key(id));
|
||||
self.minute = minute;
|
||||
// Purchases past the round-trip window go.
|
||||
let since = now - config.round_trip_hours * HOUR_MS;
|
||||
for v in self.bought.values_mut() {
|
||||
v.retain(|(_, t, _)| *t > since);
|
||||
}
|
||||
self.bought.retain(|_, v| !v.is_empty());
|
||||
true
|
||||
}
|
||||
|
||||
/// `uuid` bought `n` of `id` at `now`.
|
||||
pub fn bought(&mut self, uuid: &str, id: &str, n: u32, now: i64) {
|
||||
self.bought
|
||||
.entry(uuid.to_owned())
|
||||
.or_default()
|
||||
.push((id.to_owned(), now, n));
|
||||
}
|
||||
|
||||
/// How many of `id` `uuid` bought within the round-trip window.
|
||||
pub fn recently_bought(&self, config: &Dynamic, uuid: &str, id: &str, now: i64) -> u32 {
|
||||
let since = now - config.round_trip_hours * HOUR_MS;
|
||||
self.bought.get(uuid).map_or(0, |v| {
|
||||
v.iter()
|
||||
.filter(|(i, t, _)| i == id && *t > since)
|
||||
.map(|e| e.2)
|
||||
.sum()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn history(&self, id: &str, span: Span) -> &[Point] {
|
||||
match self.items.get(id) {
|
||||
Some(t) => match span {
|
||||
Span::Minute => &t.minutes,
|
||||
Span::Hour => &t.hours,
|
||||
},
|
||||
None => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Items of `id` traded in the last `hours` (minutes for a day, hours
|
||||
/// beyond).
|
||||
pub fn volume(&self, id: &str, now: i64, hours: u32) -> u64 {
|
||||
let Some(t) = self.items.get(id) else {
|
||||
return 0;
|
||||
};
|
||||
let since = now - i64::from(hours) * HOUR_MS;
|
||||
let live = u64::from(t.volume);
|
||||
if hours <= 24 {
|
||||
return live
|
||||
+ t.minutes
|
||||
.iter()
|
||||
.rev()
|
||||
.take_while(|p| p.t >= since)
|
||||
.map(|p| u64::from(p.volume))
|
||||
.sum::<u64>();
|
||||
}
|
||||
live + u64::from(t.hour_volume)
|
||||
+ t.hours
|
||||
.iter()
|
||||
.rev()
|
||||
.take_while(|p| p.t >= since)
|
||||
.map(|p| u64::from(p.volume))
|
||||
.sum::<u64>()
|
||||
}
|
||||
|
||||
/// Items players sold of `id` between `from` and `to` (hours, then
|
||||
/// minutes not yet in an hour).
|
||||
fn sold_between(&self, id: &str, from: i64, to: i64) -> u64 {
|
||||
let Some(t) = self.items.get(id) else {
|
||||
return 0;
|
||||
};
|
||||
let hours: u64 = t
|
||||
.hours
|
||||
.iter()
|
||||
.filter(|p| p.t >= from && p.t < to)
|
||||
.map(|p| u64::from(p.sold))
|
||||
.sum();
|
||||
let last_hour = t.hours.last().map_or(i64::MIN, |p| p.t + HOUR_MS);
|
||||
let minutes: u64 = t
|
||||
.minutes
|
||||
.iter()
|
||||
.filter(|p| p.t >= from.max(last_hour) && p.t < to)
|
||||
.map(|p| u64::from(p.sold))
|
||||
.sum();
|
||||
hours + minutes
|
||||
}
|
||||
|
||||
/// The change of `id`'s mid over the last `minutes`, in percent (None
|
||||
/// without a point that old).
|
||||
pub fn change(&self, id: &str, now: i64, minutes: u32, mid_now: f64) -> Option<f64> {
|
||||
let t = self.items.get(id)?;
|
||||
let at = now - i64::from(minutes) * MINUTE_MS;
|
||||
let then = if minutes <= MINUTES as u32 {
|
||||
t.minutes.iter().find(|p| p.t >= at)?
|
||||
} else {
|
||||
t.hours.iter().find(|p| p.t >= at)?
|
||||
};
|
||||
(then.price > 0.0).then(|| (mid_now / then.price - 1.0) * 100.0)
|
||||
}
|
||||
|
||||
/// The day's movers and news, for the chat and Discord: the three
|
||||
/// biggest risers and fallers over 24 h (at least 1%), then supply
|
||||
/// changes of at least `news_percent` (today's sales against the day
|
||||
/// before's, both busy enough). `names`: each item's display name and
|
||||
/// mid now.
|
||||
pub fn summary(
|
||||
&self,
|
||||
config: &Dynamic,
|
||||
items: &[(String, String, f64)],
|
||||
now: i64,
|
||||
) -> Vec<String> {
|
||||
let mut moves: Vec<(f64, &str)> = items
|
||||
.iter()
|
||||
.filter_map(|(id, name, mid)| {
|
||||
Some((self.change(id, now, 24 * 60, *mid)?, name.as_str()))
|
||||
})
|
||||
.filter(|(c, _)| c.abs() >= 1.0)
|
||||
.collect();
|
||||
moves.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
let mut lines = Vec::new();
|
||||
let fmt = |v: &[(f64, &str)]| {
|
||||
v.iter()
|
||||
.map(|(c, n)| format!("{n} {}", arrow(*c)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
};
|
||||
let up: Vec<_> = moves
|
||||
.iter()
|
||||
.filter(|m| m.0 > 0.0)
|
||||
.take(3)
|
||||
.copied()
|
||||
.collect();
|
||||
let down: Vec<_> = moves
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|m| m.0 < 0.0)
|
||||
.take(3)
|
||||
.copied()
|
||||
.collect();
|
||||
if !up.is_empty() {
|
||||
lines.push(format!("Market today: up {}", fmt(&up)));
|
||||
}
|
||||
if !down.is_empty() {
|
||||
lines.push(format!("Market today: down {}", fmt(&down)));
|
||||
}
|
||||
let day = 24 * HOUR_MS;
|
||||
for (id, name, _) in items {
|
||||
let today = self.sold_between(id, now - day, now + 1);
|
||||
let before = self.sold_between(id, now - 2 * day, now - day);
|
||||
if today.max(before) < config.news_min_volume || before == 0 {
|
||||
continue;
|
||||
}
|
||||
let c = (today as f64 / before as f64 - 1.0) * 100.0;
|
||||
if c.abs() >= config.news_percent {
|
||||
let how = if c > 0.0 { "up" } else { "down" };
|
||||
lines.push(format!(
|
||||
"News: {name} supply {how} {:.0}% today ({today} sold, {before} the day before).",
|
||||
c.abs()
|
||||
));
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// `▲ 2.4%` or `▼ 1.0%`.
|
||||
pub fn arrow(change: f64) -> String {
|
||||
if change >= 0.0 {
|
||||
format!("▲ {change:.1}%")
|
||||
} else {
|
||||
format!("▼ {:.1}%", -change)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const T0: i64 = 1_800_000_000_000;
|
||||
|
||||
fn mids() -> BTreeMap<String, f64> {
|
||||
[("minecraft:diamond", 250.0), ("minecraft:wheat", 2.0)]
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), *v))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn book() -> Book {
|
||||
let mut b = Book::default();
|
||||
b.tick(&Dynamic::default(), &mids(), T0);
|
||||
b
|
||||
}
|
||||
|
||||
const D: &str = "minecraft:diamond";
|
||||
|
||||
/// One diamond's move (based at 250: 0.5%).
|
||||
fn delta() -> f64 {
|
||||
Book::unit_move(&Dynamic::default(), D, 250.0)
|
||||
}
|
||||
|
||||
/// Buys or sells `n` diamonds (as many as the minute allows): the coins
|
||||
/// paid (negative) or received, and how many.
|
||||
fn deal(b: &mut Book, n: u32, buy: bool) -> (i64, u32) {
|
||||
let done = b.fill(&Dynamic::default(), D, 250.0, delta(), n, buy).2;
|
||||
let v = b.trade(&Dynamic::default(), D, &[], 250.0, delta(), n, buy);
|
||||
(if buy { -to_pay(v) } else { to_get(v) }, done)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purchases_raise_and_sales_lower_the_price_at_once_along_one_curve() {
|
||||
let c = Dynamic::default();
|
||||
let mut b = book();
|
||||
let q = b.quote(&c, D, 250.0, delta());
|
||||
// One price; one diamond moves it 0.5%, so one costs and brings the
|
||||
// price give or take a quarter of a percent.
|
||||
assert_eq!((q.mid, q.buy, q.sell), (250.0, 251, 249));
|
||||
assert_eq!(deal(&mut b, 1, false), (249, 1));
|
||||
assert!((b.factor(D) - (-0.005f64).exp()).abs() < 1e-12, "at once");
|
||||
assert_eq!(deal(&mut b, 1, true), (-250, 1));
|
||||
assert!((b.factor(D) - 1.0).abs() < 1e-12, "and back");
|
||||
// Wheat is deep: 64 move it far less than one diamond.
|
||||
let w = Book::unit_move(&c, "minecraft:wheat", 2.0);
|
||||
b.trade(&c, "minecraft:wheat", &[], 2.0, w, 64, false);
|
||||
assert!(1.0 - b.factor("minecraft:wheat") < 0.004);
|
||||
// Its family moves with it.
|
||||
let fam = vec!["minecraft:hay_block".to_string()];
|
||||
b.trade(&c, "minecraft:wheat", &fam, 2.0, w, 64, true);
|
||||
assert_eq!(b.factor("minecraft:hay_block"), b.factor("minecraft:wheat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_round_trip_earns_nothing_for_1_64_and_past_the_band() {
|
||||
// 1, 64, and 2,000 (far past the minute's 5% band: only what fits
|
||||
// goes, and the same count comes back).
|
||||
for n in [1u32, 64, 2000] {
|
||||
for first_buy in [true, false] {
|
||||
let mut b = book();
|
||||
let (a, done) = deal(&mut b, n, first_buy);
|
||||
let (z, back) = deal(&mut b, done, !first_buy);
|
||||
assert_eq!(back, done);
|
||||
assert!(a + z <= 0, "{n} {first_buy}: {a} {z}");
|
||||
// Within the band the curve is retraced exactly: only the
|
||||
// rounding of whole coins (at most 2) remains.
|
||||
if n <= 8 {
|
||||
assert!(a + z >= -2, "{n} {first_buy}: {a} {z}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Two players: one pumps, the other sells into it: together they
|
||||
// lose, whatever the split.
|
||||
let mut b = book();
|
||||
let paid = -deal(&mut b, 9, true).0;
|
||||
let got = deal(&mut b, 9, false).0;
|
||||
assert!(got <= paid, "{got} {paid}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatever_the_trades_and_the_drift_items_back_where_they_were_cost_money() {
|
||||
let mut b = book();
|
||||
let mut seed = 7u64;
|
||||
let mut rand = |m: u64| {
|
||||
seed = seed
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
(seed >> 33) % m
|
||||
};
|
||||
let (mut coins, mut held, mut now) = (0i64, 0i64, T0);
|
||||
for step in 0..5_000 {
|
||||
let n = 1 + rand(200) as u32;
|
||||
let buy = held < i64::from(n) || (held < 2_000 && rand(2) == 0);
|
||||
let (c, n) = deal(&mut b, n, buy);
|
||||
coins += c;
|
||||
held += if buy { i64::from(n) } else { -i64::from(n) };
|
||||
if step % 7 == 0 {
|
||||
now += MINUTE_MS * (1 + rand(30) as i64);
|
||||
b.tick(&Dynamic::default(), &mids(), now);
|
||||
}
|
||||
}
|
||||
// Back to no items: sold at the end, a minute at a time.
|
||||
while held > 0 {
|
||||
let (c, n) = deal(&mut b, held as u32, false);
|
||||
coins += c;
|
||||
held -= i64::from(n);
|
||||
now += MINUTE_MS;
|
||||
b.tick(&Dynamic::default(), &mids(), now);
|
||||
}
|
||||
assert!(coins <= 0, "{coins}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_minute_moves_the_price_at_most_five_percent_and_the_rest_waits() {
|
||||
let c = Dynamic::default();
|
||||
let mut b = book();
|
||||
// 0.5% a diamond: 10 fit in the minute's 5%, the other 630 wait.
|
||||
let (v, s1, done) = b.fill(&c, D, 250.0, delta(), 640, false);
|
||||
assert_eq!(done, 10);
|
||||
assert!((s1 + 0.05).abs() < 1e-9, "{s1}");
|
||||
let curve = 250.0 / delta() * (1.0 - (-0.05f64).exp());
|
||||
assert!((v - curve).abs() < 1e-6, "{v}");
|
||||
b.trade(&c, D, &[], 250.0, delta(), 640, false);
|
||||
assert_eq!(
|
||||
b.fill(&c, D, 250.0, delta(), 5, false).2,
|
||||
0,
|
||||
"no more this minute"
|
||||
);
|
||||
assert!(
|
||||
b.fill(&c, D, 250.0, delta(), 5, true).2 == 5,
|
||||
"buying back is fine"
|
||||
);
|
||||
// The next minute's band starts where this one ended (drift aside).
|
||||
b.tick(&c, &mids(), T0 + MINUTE_MS);
|
||||
b.trade(&c, D, &[], 250.0, delta(), 640, false);
|
||||
assert!(b.factor(D) < (-0.099f64).exp());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_price_drifts_back_and_stays_in_bounds() {
|
||||
let c = Dynamic::default();
|
||||
let mut b = book();
|
||||
for m in 1..=600 {
|
||||
b.trade(&c, D, &[], 250.0, delta(), 640, true);
|
||||
// At the bound the rest goes at it: the most it ever asks.
|
||||
b.tick(&c, &mids(), T0 + m * MINUTE_MS);
|
||||
assert!(b.factor("minecraft:diamond") <= c.max_factor + 1e-9);
|
||||
}
|
||||
// At the bound (less a minute's drift).
|
||||
let top = b.factor("minecraft:diamond");
|
||||
assert!((top - c.max_factor).abs() < 0.01, "{top}");
|
||||
// Six hours of quiet: halfway back (in the log).
|
||||
b.tick(&c, &mids(), T0 + 600 * MINUTE_MS + 6 * HOUR_MS);
|
||||
let f = b.factor("minecraft:diamond");
|
||||
assert!((f.ln() - top.ln() / 2.0).abs() < 1e-6, "{f}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_and_history() {
|
||||
let c = Dynamic::default();
|
||||
let mut b = book();
|
||||
for m in 1..=12 {
|
||||
b.trade(&c, D, &[], 250.0, delta(), 2, true);
|
||||
b.tick(&c, &mids(), T0 + m * MINUTE_MS);
|
||||
}
|
||||
let now = T0 + 12 * MINUTE_MS;
|
||||
assert_eq!(b.volume("minecraft:diamond", now, 24), 24);
|
||||
assert_eq!(b.history("minecraft:diamond", Span::Minute).len(), 12);
|
||||
let h = b.history("minecraft:diamond", Span::Minute);
|
||||
assert!(h.windows(2).all(|w| w[1].t == w[0].t + MINUTE_MS));
|
||||
// A point written and read back keeps its numbers.
|
||||
let json = serde_json::to_string(&b).unwrap();
|
||||
let back: Book = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.history("minecraft:diamond", Span::Minute).len(), 12);
|
||||
assert!((back.factor("minecraft:diamond") - b.factor("minecraft:diamond")).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hours_are_summed_and_the_day_makes_news() {
|
||||
let c = Dynamic::default();
|
||||
let mut b = book();
|
||||
let mut now = T0;
|
||||
// Yesterday 100 wheat an hour; today 200.
|
||||
for h in 0..48 {
|
||||
let n = if h < 24 { 100 } else { 200 };
|
||||
for m in 0..60 {
|
||||
if m == 0 {
|
||||
let w = Book::unit_move(&c, "minecraft:wheat", 2.0);
|
||||
b.trade(&c, "minecraft:wheat", &[], 2.0, w, n, false);
|
||||
}
|
||||
now += MINUTE_MS;
|
||||
b.tick(&c, &mids(), now);
|
||||
}
|
||||
}
|
||||
assert!(b.history("minecraft:wheat", Span::Hour).len() >= 47);
|
||||
let lines = b.summary(
|
||||
&c,
|
||||
&[(
|
||||
"minecraft:wheat".into(),
|
||||
"Wheat".into(),
|
||||
2.0 * b.factor("minecraft:wheat"),
|
||||
)],
|
||||
now,
|
||||
);
|
||||
assert!(
|
||||
lines
|
||||
.iter()
|
||||
.any(|l| l.starts_with("News: Wheat supply up 100% today")),
|
||||
"{lines:?}"
|
||||
);
|
||||
assert!(
|
||||
lines
|
||||
.iter()
|
||||
.any(|l| l.starts_with("Market today: down Wheat ▼")),
|
||||
"{lines:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_round_trip_window_and_one_price() {
|
||||
let c = Dynamic::default();
|
||||
let mut b = book();
|
||||
b.bought("u", "minecraft:diamond", 5, T0);
|
||||
assert_eq!(
|
||||
b.recently_bought(&c, "u", "minecraft:diamond", T0 + HOUR_MS),
|
||||
5
|
||||
);
|
||||
b.tick(&c, &mids(), T0 + 25 * HOUR_MS);
|
||||
assert_eq!(
|
||||
b.recently_bought(&c, "u", "minecraft:diamond", T0 + 25 * HOUR_MS),
|
||||
0
|
||||
);
|
||||
assert!(b.bought.is_empty());
|
||||
// One price from the old two: their geometric mean, whole.
|
||||
assert_eq!(one_price(400, 100), 200);
|
||||
assert_eq!(one_price(30, 8), 15);
|
||||
assert_eq!(one_price(2, 1), 1);
|
||||
assert_eq!((one_price(5, 0), one_price(0, 1)), (5, 1));
|
||||
assert_eq!((to_pay(250.004), to_get(249.996)), (250, 250));
|
||||
assert_eq!((to_pay(1.94), to_get(1.94)), (2, 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,875 @@
|
||||
// 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>`. With `ops_only`
|
||||
// (the default since SMP 0.22) only operators of level 3 pick nicknames,
|
||||
// for themselves or for a player; a player may still take off the one an
|
||||
// operator gave them, and the nicknames players picked themselves are taken
|
||||
// off when the server starts. 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,
|
||||
/// Only operators of level 3 set nicknames (their own, or a player's).
|
||||
pub ops_only: 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,
|
||||
ops_only: 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, when they last changed it (ms), and whether an
|
||||
/// operator gave it to them (it stays with `ops_only`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Nick {
|
||||
pub nick: Option<String>,
|
||||
pub changed: i64,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub given: bool,
|
||||
}
|
||||
|
||||
/// The level that sets nicknames with `ops_only`, and others' at any time.
|
||||
pub const OP_LEVEL: u8 = 3;
|
||||
|
||||
/// `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>;
|
||||
/// A player's operator level (`ops.json`; 0 when not an operator).
|
||||
fn level(&self, uuid: u128) -> u8;
|
||||
}
|
||||
|
||||
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. With
|
||||
/// `ops_only`, those a player who isn't an operator picked themselves.
|
||||
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();
|
||||
let given = self
|
||||
.file
|
||||
.nicks
|
||||
.get(&uuid_string(uuid))
|
||||
.is_some_and(|n| n.given);
|
||||
let not_op = (self.config.ops_only && names.level(uuid) < OP_LEVEL && !given)
|
||||
.then(|| "nicknames are for operators only".to_owned());
|
||||
if let Some(why) = not_op.or_else(|| 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", []) => {
|
||||
let picks = !self.config.ops_only || me.level >= OP_LEVEL;
|
||||
vec![Out::Reply(match (self.nick_of(me.uuid), picks) {
|
||||
(Some(n), true) => {
|
||||
format!(
|
||||
"You go by ~{n}. /nick off takes it off; /nick <name> picks another."
|
||||
)
|
||||
}
|
||||
(Some(n), false) => format!("You go by ~{n}. /nick off takes it off."),
|
||||
(None, true) => {
|
||||
"/nick <name> picks a nickname (shown as ~name); /nick off takes it off."
|
||||
.into()
|
||||
}
|
||||
(None, false) => "Only operators set nicknames on this server.".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])
|
||||
if self.config.ops_only
|
||||
&& me.level < OP_LEVEL
|
||||
&& !new.eq_ignore_ascii_case("off") =>
|
||||
{
|
||||
vec![Out::Error(
|
||||
"Only operators set nicknames on this server.".into(),
|
||||
)]
|
||||
}
|
||||
("nick", [new]) => {
|
||||
let need = self.config.min_playtime_minutes;
|
||||
if me.level < OP_LEVEL
|
||||
&& 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 >= OP_LEVEL => 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 < OP_LEVEL {
|
||||
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();
|
||||
// An operator's gift to another player; one's own pick isn't.
|
||||
entry.given = change.nick.is_some() && !change.own;
|
||||
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 level(&self, uuid: u128) -> u8 {
|
||||
if uuid == OP { 4 } else { 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// An operator in `ops.json` (for `Known::level`).
|
||||
const OP: u128 = 9;
|
||||
|
||||
/// Nicknames for everyone (the rules before `ops_only`).
|
||||
fn open_to_all() -> Config {
|
||||
Config {
|
||||
ops_only: false,
|
||||
..Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn setup(name: &str) -> (Nicks, std::path::PathBuf) {
|
||||
setup_with(name, open_to_all())
|
||||
}
|
||||
|
||||
fn setup_with(name: &str, config: Config) -> (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).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, open_to_all()).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,
|
||||
given: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_operators_set_nicknames_and_players_own_ones_go_on_load() {
|
||||
let (mut n, dir) = setup_with("ops", Config::default());
|
||||
let k = known();
|
||||
let alex = player(ALEX, "Alex", 0);
|
||||
let op = player(OP, "Boss", 4);
|
||||
assert_eq!(
|
||||
n.command(&alex, &k, 10 * HOUR, 0, "nick Sunny").unwrap(),
|
||||
[Out::Error(
|
||||
"Only operators set nicknames on this server.".into()
|
||||
)]
|
||||
);
|
||||
assert!(
|
||||
matches!(&n.command(&alex, &k, 0, 0, "nick").unwrap()[0], Out::Reply(r) if r == "Only operators set nicknames on this server.")
|
||||
);
|
||||
// An operator's own, and one an operator gives a player.
|
||||
assert_eq!(n.command(&op, &k, 0, 0, "nick Chief").unwrap(), []);
|
||||
assert_eq!(n.command(&op, &k, 0, 0, "nick Steve Rocky").unwrap(), []);
|
||||
for c in std::mem::take(&mut n.pending) {
|
||||
n.apply(&c, "x", 1);
|
||||
}
|
||||
// A nickname picked before the rule (not given), which goes.
|
||||
n.file.nicks.insert(
|
||||
uuid_string(ALEX),
|
||||
Nick {
|
||||
nick: Some("Sunny".into()),
|
||||
changed: 5,
|
||||
given: false,
|
||||
},
|
||||
);
|
||||
// The player may take off the one given to them.
|
||||
let steve = player(STEVE, "Steve", 0);
|
||||
assert!(
|
||||
matches!(&n.command(&steve, &k, 0, 9, "nick").unwrap()[0], Out::Reply(r) if r == "You go by ~Rocky. /nick off takes it off.")
|
||||
);
|
||||
n.save().unwrap();
|
||||
let mut again = Nicks::open(&dir, Config::default()).unwrap();
|
||||
let gone = again.purge(&k);
|
||||
assert_eq!(gone.len(), 1);
|
||||
assert_eq!((gone[0].0, gone[0].1.as_str()), (ALEX, "Sunny"));
|
||||
assert_eq!(gone[0].2, "nicknames are for operators only");
|
||||
let mut left = again.all();
|
||||
left.sort();
|
||||
assert_eq!(
|
||||
left,
|
||||
[(STEVE, "Rocky".to_owned()), (OP, "Chief".to_owned())]
|
||||
);
|
||||
assert_eq!(
|
||||
again
|
||||
.command(&steve, &k, 0, 10_000_000, "nick off")
|
||||
.unwrap(),
|
||||
[]
|
||||
);
|
||||
fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
// 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,
|
||||
/// Where to, in words (`where you died`).
|
||||
pub what: String,
|
||||
/// The cooldown and `/back` before it: `call_off` puts them back.
|
||||
undo: (Option<i64>, Option<(Place, bool)>),
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// A teleport the game couldn't make: no
|
||||
/// cooldown, and `/back` goes where it went before.
|
||||
pub fn call_off(&mut self, t: &Teleport) {
|
||||
match t.undo.0 {
|
||||
Some(at) => self.last.insert(t.uuid, at),
|
||||
None => self.last.remove(&t.uuid),
|
||||
};
|
||||
match &t.undo.1 {
|
||||
Some(b) => self.back.insert(t.uuid, b.clone()),
|
||||
None => self.back.remove(&t.uuid),
|
||||
};
|
||||
}
|
||||
|
||||
/// 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) => {
|
||||
let undo = (
|
||||
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}."),
|
||||
what,
|
||||
undo,
|
||||
});
|
||||
}
|
||||
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 a_called_off_teleport_keeps_back_and_no_cooldown() {
|
||||
let d = dir();
|
||||
let mut p = Places::open(&d, Config::default()).unwrap();
|
||||
let mut dead = body(A, 40.0);
|
||||
dead.health = 0.0;
|
||||
p.tick(&[dead], 0);
|
||||
let alive = [body(A, 0.0)];
|
||||
let out = p.command(&player(A, 0), &alive, 1_000, "back").unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![Out::Reply("Teleporting in 3 s: don't move.".into())]
|
||||
);
|
||||
let (_, go) = p.tick(&alive, 4_000);
|
||||
assert_eq!(go[0].what, "where you died");
|
||||
// The game couldn't make it: /back still goes where they died, at once.
|
||||
p.call_off(&go[0]);
|
||||
p.command(&player(A, 0), &alive, 4_000, "back").unwrap();
|
||||
let (_, go) = p.tick(&alive, 7_000);
|
||||
assert_eq!(go[0].to, place(40.0));
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[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,438 @@
|
||||
// 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 of play (moving, not AFK) a day before the reward: an alt
|
||||
/// that only logs in gets nothing.
|
||||
pub reward_after_minutes: 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,
|
||||
reward_after_minutes: 10,
|
||||
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>>,
|
||||
/// Seconds each player was active today (UTC day), toward the reward;
|
||||
/// not kept over a restart.
|
||||
played_today: HashMap<u128, (String, 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(),
|
||||
played_today: 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));
|
||||
}
|
||||
}
|
||||
// Once a player has been active long enough today (moved or ran a
|
||||
// command in the last minute, each second counts).
|
||||
if self.config.daily_reward > 0 {
|
||||
let need = self.config.reward_after_minutes.max(0) * 60;
|
||||
for p in players {
|
||||
let key = smp_store::uuid_string(p.uuid);
|
||||
if self.data.rewarded.get(&key).map(String::as_str) == Some(today) {
|
||||
continue;
|
||||
}
|
||||
let active = self.on.get(&p.uuid).is_some_and(|(_, t)| now - t < 60_000);
|
||||
let day = self
|
||||
.played_today
|
||||
.entry(p.uuid)
|
||||
.or_insert_with(|| (today.to_owned(), 0));
|
||||
if day.0 != today {
|
||||
*day = (today.to_owned(), 0);
|
||||
}
|
||||
if active {
|
||||
day.1 += 1;
|
||||
}
|
||||
if day.1 >= need {
|
||||
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![]);
|
||||
p.config.reward_after_minutes = 0;
|
||||
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 the_daily_reward_needs_ten_minutes_of_play_so_logging_in_alts_get_nothing() {
|
||||
let (mut p, _) = open(vec![A, B]);
|
||||
let mut x = 0.0;
|
||||
let mut rewarded = Vec::new();
|
||||
for s in 0..700 {
|
||||
x += 1.0;
|
||||
// Alex walks; Steve (an alt) stands still after logging in.
|
||||
let (_, acts) = p.tick(&[seen(A, x), seen(B, 0.0)], "2026-10-31", s * 1000, &[]);
|
||||
rewarded.extend(acts.into_iter().filter_map(|a| match a {
|
||||
Act::Reward(u) => Some((u, s)),
|
||||
_ => None,
|
||||
}));
|
||||
}
|
||||
// Alex: rewarded on his 600th second of play. Steve's first minute
|
||||
// counts, then nothing.
|
||||
assert_eq!(rewarded, vec![(A, 599)]);
|
||||
// Leaving and coming back the same day: the count goes on.
|
||||
let (mut q, _) = open(vec![A]);
|
||||
for s in 0..300 {
|
||||
q.tick(&[seen(A, s as f64)], "2026-10-31", s * 1000, &[]);
|
||||
}
|
||||
q.tick(&[], "2026-10-31", 400_000, &[]);
|
||||
let mut got = false;
|
||||
for s in 0..302 {
|
||||
let (_, acts) = q.tick(&[seen(A, s as f64)], "2026-10-31", 500_000 + s * 1000, &[]);
|
||||
got |= acts.contains(&Act::Reward(A));
|
||||
}
|
||||
assert!(got);
|
||||
}
|
||||
|
||||
#[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,503 @@
|
||||
// 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()), []);
|
||||
// One price an item (spread 0), or one side closed.
|
||||
for (id, p) in &config.items {
|
||||
assert!(
|
||||
p.buy == 0 || p.sell == 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), []);
|
||||
// Nor once prices have moved as far as they may (0.6 to 1.6 times
|
||||
// their base): an item sold at the top while what makes it is
|
||||
// bought at the bottom, unless both share one price (a family).
|
||||
let families = super::super::smp_market::families(&config.items);
|
||||
let (lo, hi) = (config.dynamic.min_factor, config.dynamic.max_factor);
|
||||
let worst = |items: &BTreeMap<String, Price>, families: &[Vec<String>]| {
|
||||
let mut worst = Vec::new();
|
||||
for (id, p) in items.iter().filter(|(_, p)| p.sell > 0) {
|
||||
let kin = |other: &String| {
|
||||
other == id || families.iter().any(|f| f.contains(id) && f.contains(other))
|
||||
};
|
||||
let moved: BTreeMap<String, Price> = items
|
||||
.iter()
|
||||
.map(|(other, q)| {
|
||||
let f = if kin(other) { hi } else { lo };
|
||||
// In hundredths, so rounding hides nothing.
|
||||
let scale = |v: i64| (v as f64 * f * 100.0).round() as i64;
|
||||
(
|
||||
other.clone(),
|
||||
Price {
|
||||
buy: scale(q.buy),
|
||||
sell: scale(q.sell),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if let Some(c) = costs(&moved, &recipes).get(id)
|
||||
&& p.sell as f64 * hi * 100.0 > c + 1e-6
|
||||
{
|
||||
worst.push((id.clone(), p.sell as f64 * hi, *c / 100.0));
|
||||
}
|
||||
}
|
||||
worst
|
||||
};
|
||||
assert_eq!(
|
||||
worst(&config.items, &families),
|
||||
[],
|
||||
"loops once prices move"
|
||||
);
|
||||
// A gold block sold for its 9 ingots: no loop while they share one
|
||||
// price; one once the ingots drift down and the block up.
|
||||
let mut blocks = config.items.clone();
|
||||
blocks.insert("minecraft:gold_block".into(), Price { buy: 0, sell: 180 });
|
||||
assert_eq!(
|
||||
worst(&blocks, &super::super::smp_market::families(&blocks)),
|
||||
[]
|
||||
);
|
||||
assert_eq!(worst(&blocks, &[])[0].0, "minecraft:gold_block");
|
||||
// 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,410 @@
|
||||
// The economy in the open: `/eco stats` (the money supply and where it
|
||||
// sits, what the server made and destroyed today, how evenly money is
|
||||
// spread), a report to everyone at 00:00 UTC on the day
|
||||
// before, and `web/economy.json` for the site's /hub (written once a minute,
|
||||
// off the tick thread, by the glue). One snapshot a day is kept (60 days) in
|
||||
// `economy.json` (`ext.report`) for the curves. Reading only: this part
|
||||
// moves no money. No game types. Added to the game as `game::smp_report`;
|
||||
// also a module of this crate, for its tests.
|
||||
|
||||
use super::smp_economy::{DAY_MS, Economy, Out};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// `config.json`'s `report`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
/// The 00:00 UTC report in everyone's chat.
|
||||
pub daily_report: bool,
|
||||
/// `web/economy.json` once a minute.
|
||||
pub web_json: bool,
|
||||
pub history_days: usize,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
daily_report: true,
|
||||
web_json: true,
|
||||
history_days: 60,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One day, as the report keeps it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Day {
|
||||
/// Days since 1970 (UTC).
|
||||
pub day: i64,
|
||||
/// All the money there is, at the end of the day.
|
||||
pub supply: i64,
|
||||
/// Players' money: wallets, money held (bids, stakes).
|
||||
pub players: i64,
|
||||
/// Made and destroyed by the server that day.
|
||||
pub minted: i64,
|
||||
pub burned: i64,
|
||||
/// Over players with money: Gini (0 to 1, in thousandths) and the
|
||||
/// richest ten's share (percent).
|
||||
pub gini_milli: i64,
|
||||
pub top10_percent: i64,
|
||||
}
|
||||
|
||||
/// `ext.report`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct State {
|
||||
pub days: Vec<Day>,
|
||||
/// The last day reported.
|
||||
pub day: i64,
|
||||
}
|
||||
|
||||
pub struct Report {
|
||||
pub config: Config,
|
||||
pub state: State,
|
||||
dirty: bool,
|
||||
minute: i64,
|
||||
}
|
||||
|
||||
/// Pools that aren't players' money: the casino's pot.
|
||||
const HOUSE_POOLS: [&str; 1] = ["casino"];
|
||||
|
||||
/// Gini (thousandths) and the top ten's share (percent) of `wealth`.
|
||||
pub fn spread(mut wealth: Vec<i64>) -> (i64, i64) {
|
||||
wealth.retain(|w| *w > 0);
|
||||
wealth.sort_unstable();
|
||||
let n = wealth.len() as i128;
|
||||
let total: i128 = wealth.iter().map(|w| i128::from(*w)).sum();
|
||||
if total == 0 {
|
||||
return (0, 0);
|
||||
}
|
||||
let weighted: i128 = wealth
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, w)| (2 * i as i128 - n + 1) * i128::from(*w))
|
||||
.sum();
|
||||
let top: i128 = wealth.iter().rev().take(10).map(|w| i128::from(*w)).sum();
|
||||
(
|
||||
(weighted * 1000 / (n * total)) as i64,
|
||||
(top * 100 / total) as i64,
|
||||
)
|
||||
}
|
||||
|
||||
fn sum(m: &BTreeMap<String, i64>) -> i64 {
|
||||
m.values().sum()
|
||||
}
|
||||
|
||||
/// `market sale 60,000 coins, daily reward 20,000 coins` (largest first).
|
||||
fn list(e: &Economy, m: &BTreeMap<String, i64>, n: usize) -> String {
|
||||
let mut v: Vec<(&String, &i64)> = m.iter().filter(|(_, a)| **a > 0).collect();
|
||||
v.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
|
||||
let parts: Vec<String> = v
|
||||
.iter()
|
||||
.take(n)
|
||||
.map(|(k, a)| format!("{} {}", label(k), e.money(**a)))
|
||||
.collect();
|
||||
if parts.is_empty() {
|
||||
"nothing".into()
|
||||
} else {
|
||||
parts.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
fn label(kind: &str) -> String {
|
||||
match kind {
|
||||
"sell" => "market sales".into(),
|
||||
"buy" => "market purchases".into(),
|
||||
k => k.replace('_', " "),
|
||||
}
|
||||
}
|
||||
|
||||
impl Report {
|
||||
pub fn open(economy: &Economy, config: Config) -> Self {
|
||||
let mut state = economy
|
||||
.ext("report")
|
||||
.and_then(|v| serde_json::from_value::<State>(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
if state.day == 0 {
|
||||
state.day = economy.now.div_euclid(DAY_MS);
|
||||
}
|
||||
Self {
|
||||
config,
|
||||
state,
|
||||
dirty: false,
|
||||
minute: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn store(&mut self, economy: &mut Economy) {
|
||||
if self.dirty {
|
||||
economy.set_ext(
|
||||
"report",
|
||||
serde_json::to_value(&self.state).unwrap_or_default(),
|
||||
);
|
||||
self.dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The day's numbers now.
|
||||
pub fn snapshot(economy: &mut Economy, day: i64) -> Day {
|
||||
let accounts = economy.accounts();
|
||||
let wallets: i64 = accounts.iter().map(|a| a.2).sum();
|
||||
let pools: i64 = economy.pools().values().sum();
|
||||
let house: i64 = HOUSE_POOLS.iter().map(|p| economy.pool(p)).sum();
|
||||
let supply = wallets + economy.held_total() + pools;
|
||||
let (gini_milli, top10_percent) = spread(accounts.iter().map(|a| a.2).collect());
|
||||
let today = economy.now.div_euclid(DAY_MS);
|
||||
let f = economy.flows();
|
||||
let (minted, burned) = if day == today {
|
||||
(sum(&f.minted), sum(&f.burned))
|
||||
} else if day + 1 == today {
|
||||
(sum(&f.minted_yesterday), sum(&f.burned_yesterday))
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
Day {
|
||||
day,
|
||||
supply,
|
||||
players: supply - house,
|
||||
minted,
|
||||
burned,
|
||||
gini_milli,
|
||||
top10_percent,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `/eco stats` (or `/money stats`); None for another command.
|
||||
pub fn command(&self, economy: &mut Economy, line: &str) -> Option<Vec<Out>> {
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
if !matches!(words.as_slice(), ["eco" | "money", "stats"]) {
|
||||
return None;
|
||||
}
|
||||
let today = economy.now.div_euclid(DAY_MS);
|
||||
let d = Self::snapshot(economy, today);
|
||||
let since = self.state.days.last().map(|y| d.supply - y.supply);
|
||||
let pools: Vec<String> = economy
|
||||
.pools()
|
||||
.iter()
|
||||
.filter(|(k, v)| **v > 0 && !HOUSE_POOLS.contains(&k.as_str()))
|
||||
.map(|(k, v)| format!("{} {}", k.replace('_', " "), economy.money(*v)))
|
||||
.collect();
|
||||
let flows = economy.flows().clone();
|
||||
Some(vec![
|
||||
Out::Reply(format!(
|
||||
"Money supply: {}{}. Players hold {} (of it in pools: {}), the casino's pot {}.",
|
||||
economy.money(d.supply),
|
||||
since.map_or(String::new(), |s| format!(
|
||||
" ({}{} since midnight)",
|
||||
if s >= 0 { "+" } else { "-" },
|
||||
economy.money(s.abs())
|
||||
)),
|
||||
economy.money(d.players),
|
||||
if pools.is_empty() {
|
||||
"none".into()
|
||||
} else {
|
||||
pools.join(", ")
|
||||
},
|
||||
economy.money(d.supply - d.players)
|
||||
)),
|
||||
Out::Reply(format!(
|
||||
"Today the server made {} ({}) and destroyed {} ({}).",
|
||||
economy.money(sum(&flows.minted)),
|
||||
list(economy, &flows.minted, 3),
|
||||
economy.money(sum(&flows.burned)),
|
||||
list(economy, &flows.burned, 3)
|
||||
)),
|
||||
Out::Reply(format!(
|
||||
"Spread: Gini {}.{:03} (0 equal, 1 one player has all), the richest ten hold {}%.",
|
||||
d.gini_milli / 1000,
|
||||
d.gini_milli % 1000,
|
||||
d.top10_percent
|
||||
)),
|
||||
])
|
||||
}
|
||||
|
||||
/// Whether `tick` has work at `now` (a new minute or day).
|
||||
pub fn due(&self, now: i64) -> bool {
|
||||
now.div_euclid(60_000) != self.minute || now.div_euclid(DAY_MS) > self.state.day
|
||||
}
|
||||
|
||||
/// Once a second from the glue. At the day's turn: the day before is
|
||||
/// kept and, if on, told to everyone. Returns the lines, and whether
|
||||
/// `web/economy.json` is due (once a minute).
|
||||
pub fn tick(&mut self, economy: &mut Economy, now: i64) -> (Vec<Out>, bool) {
|
||||
let minute = now.div_euclid(60_000);
|
||||
let web = self.config.web_json && minute != self.minute;
|
||||
self.minute = minute;
|
||||
let today = now.div_euclid(DAY_MS);
|
||||
if today <= self.state.day {
|
||||
return (Vec::new(), web);
|
||||
}
|
||||
let yesterday = today - 1;
|
||||
self.state.day = today;
|
||||
self.dirty = true;
|
||||
// The supply now is yesterday's end, give or take a second.
|
||||
let d = Self::snapshot(economy, yesterday);
|
||||
let before = self
|
||||
.state
|
||||
.days
|
||||
.last()
|
||||
.filter(|p| p.day + 1 == yesterday)
|
||||
.map(|p| p.supply);
|
||||
self.state.days.push(d.clone());
|
||||
let keep = self.config.history_days.max(1);
|
||||
if self.state.days.len() > keep {
|
||||
let extra = self.state.days.len() - keep;
|
||||
self.state.days.drain(..extra);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if self.config.daily_report {
|
||||
out.push(Out::All(self.daily_line(economy, &d, before)));
|
||||
}
|
||||
(out, web)
|
||||
}
|
||||
|
||||
fn daily_line(&self, economy: &Economy, d: &Day, before: Option<i64>) -> String {
|
||||
format!(
|
||||
"Economy yesterday: money supply {}{}; the server made {} and destroyed {}. /eco stats",
|
||||
economy.money(d.supply),
|
||||
before.map_or(String::new(), |b| {
|
||||
let c = d.supply - b;
|
||||
format!(
|
||||
" ({}{})",
|
||||
if c >= 0 { "+" } else { "-" },
|
||||
economy.money(c.abs())
|
||||
)
|
||||
}),
|
||||
economy.money(d.minted),
|
||||
economy.money(d.burned)
|
||||
)
|
||||
}
|
||||
|
||||
/// `web/economy.json`: aggregates and the days kept.
|
||||
pub fn web(&self, economy: &mut Economy, now: i64) -> Value {
|
||||
let today = now.div_euclid(DAY_MS);
|
||||
let d = Self::snapshot(economy, today);
|
||||
let flows = economy.flows().clone();
|
||||
let pools: BTreeMap<String, i64> = economy.pools().clone();
|
||||
let daily = self.state.days.last().map(|y| {
|
||||
self.daily_line(
|
||||
economy,
|
||||
y,
|
||||
self.state
|
||||
.days
|
||||
.iter()
|
||||
.rev()
|
||||
.nth(1)
|
||||
.filter(|p| p.day + 1 == y.day)
|
||||
.map(|p| p.supply),
|
||||
)
|
||||
});
|
||||
json!({
|
||||
"schema": 1,
|
||||
"generated": now,
|
||||
"currency": economy.config.currency,
|
||||
"today": d,
|
||||
"pools": pools,
|
||||
"minted_today": flows.minted,
|
||||
"burned_today": flows.burned,
|
||||
"days": self.state.days,
|
||||
"daily_report": daily,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::smp_economy::Config as EcoConfig;
|
||||
|
||||
const A: u128 = 1;
|
||||
const B: u128 = 2;
|
||||
const T0: i64 = 1_793_577_600_000; // a midnight, UTC
|
||||
|
||||
fn setup(name: &str) -> (Economy, Report) {
|
||||
let dir = std::env::temp_dir().join(format!("smp-report-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut e = Economy::open(&dir, EcoConfig::default()).unwrap();
|
||||
e.now = T0 + 1000;
|
||||
e.seen(A, "Alex");
|
||||
e.seen(B, "Bo");
|
||||
let r = Report::open(&e, Config::default());
|
||||
(e, r)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spread_measures_inequality() {
|
||||
assert_eq!(spread(vec![10, 10, 10, 10]), (0, 100));
|
||||
let (g, _) = spread(vec![0, 0, 0, 100]);
|
||||
assert_eq!(g, 0, "only players with money count");
|
||||
let (g, top) = spread((1..=20).map(|i| if i == 20 { 1_000 } else { 1 }).collect());
|
||||
assert!(g > 900, "{g}");
|
||||
assert_eq!(top, 99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_show_what_was_made_and_destroyed_today() {
|
||||
let (mut e, r) = setup("stats");
|
||||
e.adjust("sell 20 minecraft:wheat", A, 600, "").unwrap();
|
||||
e.adjust("daily_reward", B, 50, "").unwrap();
|
||||
e.adjust("buy 5 minecraft:torch", A, -100, "").unwrap();
|
||||
e.to_pool("ah_bid", A, "auction", 25, "").unwrap();
|
||||
let out = r.command(&mut e, "eco stats").unwrap();
|
||||
let text: Vec<String> = out
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
Out::Reply(s) => s.clone(),
|
||||
o => panic!("{o:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
text[0],
|
||||
"Money supply: 550 coins. Players hold 550 coins (of it in pools: auction 25 coins), the casino's pot 0 coins."
|
||||
);
|
||||
assert_eq!(
|
||||
text[1],
|
||||
"Today the server made 650 coins (market sales 600 coins, daily reward 50 coins) and destroyed 100 coins (market purchases 100 coins)."
|
||||
);
|
||||
assert!(!text.iter().any(|l| l.contains("Treasury")), "{text:?}");
|
||||
assert!(r.command(&mut e, "eco give A 5").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midnight_reports_the_day_before_once_and_keeps_it_through_a_restart() {
|
||||
let (mut e, mut r) = setup("daily");
|
||||
e.adjust("sell 1 minecraft:diamond", A, 100, "").unwrap();
|
||||
let (out, web) = r.tick(&mut e, T0 + 2000);
|
||||
assert!(out.is_empty() && web, "same day; the JSON's first minute");
|
||||
assert!(!r.tick(&mut e, T0 + 3000).1, "once a minute");
|
||||
e.now = T0 + DAY_MS + 500;
|
||||
let now = e.now;
|
||||
let (out, _) = r.tick(&mut e, now);
|
||||
assert_eq!(
|
||||
out,
|
||||
[Out::All(
|
||||
"Economy yesterday: money supply 100 coins; the server made 100 coins and destroyed 0 coins. /eco stats"
|
||||
.into()
|
||||
)]
|
||||
);
|
||||
assert!(r.tick(&mut e, now + 1000).0.is_empty(), "once");
|
||||
let json = r.web(&mut e, now);
|
||||
assert_eq!(json["schema"], 1);
|
||||
assert_eq!(json["days"][0]["minted"], 100);
|
||||
assert!(json.get("treasury").is_none());
|
||||
assert!(
|
||||
json["daily_report"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("Economy yesterday")
|
||||
);
|
||||
r.store(&mut e);
|
||||
e.save().unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("smp-report-daily-{}", std::process::id()));
|
||||
let e2 = Economy::open(&dir, EcoConfig::default()).unwrap();
|
||||
let r2 = Report::open(&e2, Config::default());
|
||||
assert_eq!(r2.state.days.len(), 1);
|
||||
assert_eq!(r2.state.day, (T0 + DAY_MS).div_euclid(DAY_MS));
|
||||
}
|
||||
}
|
||||
@@ -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: 4, sell: 4 })
|
||||
);
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,14 @@ pub fn write_json<T: serde::Serialize>(path: &Path, value: &T) -> Result<(), Str
|
||||
write_atomic(path, &bytes).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// `value` as compact JSON in `path`, atomically (large files: the
|
||||
/// market's prices and the web pages).
|
||||
pub fn write_json_compact<T: serde::Serialize>(path: &Path, value: &T) -> Result<(), String> {
|
||||
let mut bytes = serde_json::to_vec(value).map_err(|e| e.to_string())?;
|
||||
bytes.push(b'\n');
|
||||
write_atomic(path, &bytes).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// The audit log: `audit.log`, one JSON object a line.
|
||||
pub struct Audit {
|
||||
path: PathBuf,
|
||||
|
||||
@@ -8,5 +8,6 @@ fn smp_server_tick(
|
||||
crate::game::native_lifecycle::ServerTickPhase::ServerTickEnd { .. }
|
||||
) {
|
||||
crate::game::smp_glue::tick(game);
|
||||
crate::game::smp_invsee_glue::tick(game);
|
||||
}
|
||||
}
|
||||
+660
-33
@@ -2,8 +2,10 @@
|
||||
Steve, `horizon-ctl --offscreen`) join it. Checked from the clients' own chat and from the files the server wrote:
|
||||
|
||||
- the economy opens an account for each player (config.json written with its defaults, then economy.json);
|
||||
- /balance, /pay (the payer's answer and the payee's line), refused payments (too much, to oneself, unknown name);
|
||||
- /baltop; /eco is refused below level 3 and works for an operator (made one from the server's console);
|
||||
- /balance, /pay (the payer's answer and the payee's line), refused payments (too much, past what the payee may receive today, to oneself, unknown name);
|
||||
- money from an operator (made one from the server's console): /eco give with a reason everyone reads; a
|
||||
non-operator can't; /treasury, /bank, /jobs, /futures and /eco taxes are gone, and the old Treasury pool is dropped;
|
||||
- /baltop;
|
||||
- the commands are in each player's command tree (the chat box's suggestions);
|
||||
- the market (Steve in survival, items given from the console): /worth, /sell <item>, /sell hand, /buy, with the
|
||||
items gone from or in his inventory as his client sees it; a creative player can't sell;
|
||||
@@ -15,12 +17,31 @@ 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;
|
||||
- an operator's /eco give, then the /shop menu: a category, one item bought by a click and a stack by
|
||||
a shift-click, a stack of the player's own sold by clicking it, "Sell all" stopped by the daily limit (the rest
|
||||
kept, the limit shown in the menu and in /worth, the day's sales in market.json), the close button;
|
||||
- after the restart, prices moving: a purchase and a sale with no tax (receipts), an item bought can't be sold
|
||||
back while one found can, one price for diamond, moved down by a sale and up by a purchase as they go, /market's item
|
||||
page buying, the 24 h chart given as a map and drawn in the player's hand (market-chart.png), web/market.json, and
|
||||
market_prices.json written when the world closes;
|
||||
- the web pages (web/market.json, web/economy.json) sent gzipped with the token to a stand-in for Kira;
|
||||
- /invsee: an operator takes a stack from an offline player's saved inventory, logged before the move and summed up in
|
||||
staff.log;
|
||||
- nicknames, for operators only: a player can't pick one; an operator's refused ones (a player's name, too short, a
|
||||
bad character), the one given in the other's chat and Tab list, /realname, someone else's taken, the player taking
|
||||
it off.
|
||||
|
||||
The casino has its own proof (proof_casino.py): `SMP_PROOF=casino` runs it instead of this one.
|
||||
|
||||
Everything runs in fresh temporary folders (the server's, each client's home, launcher and XDG folders), no audio.
|
||||
"""
|
||||
import gzip
|
||||
import http.server
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
@@ -36,6 +57,29 @@ PROPERTIES = {
|
||||
}
|
||||
|
||||
|
||||
class Receiver:
|
||||
"""Stands in for Kira's POST /smp/ingest/<name>: keeps what the server sends (its path, bearer, unzipped body)."""
|
||||
|
||||
def __init__(self):
|
||||
got = self.got = []
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
||||
if self.headers.get("Content-Encoding") == "gzip":
|
||||
body = gzip.decompress(body)
|
||||
got.append((self.path, self.headers.get("Authorization"), json.loads(body)))
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
self.url = f"http://127.0.0.1:{self.server.server_address[1]}/smp/ingest"
|
||||
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
||||
|
||||
|
||||
def free_port():
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
@@ -98,6 +142,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 +177,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 +272,483 @@ 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):
|
||||
"""Nicknames are for operators: Steve can't pick one; Alex (an operator) gives him one, refused ones first, then
|
||||
~Rocky in Alex's chat and the Tab list, /realname, /pay by nickname, and Steve takes it off himself. The one an
|
||||
older version let through (a player's own pick) is gone."""
|
||||
r = {}
|
||||
r["purged"] = [l for l in server.lines if "taken off" in l]
|
||||
assert any("nickname N0tch of" in l and "for operators only" in l for l in r["purged"]), r
|
||||
time.sleep(6) # the spam guard's window from the step before
|
||||
for line in ("/nick", "/nick Rocky"):
|
||||
r[line] = steve.say(line)
|
||||
assert any("Only operators set nicknames on this server." in l for l in r[line]), r
|
||||
for line, error in (("/nick Steve Alex", "Alex looks like the name of another player."),
|
||||
("/nick Steve A1ex_", "A1ex_ looks like the name of another player."),
|
||||
("/nick Steve N0tch", "N0tch reads as \"notch\": that word is kept for the server."),
|
||||
("/nick Steve ab", "A nickname is 3 to 16 letters, digits or _.")):
|
||||
r[line] = alex.say(line)
|
||||
assert any(error in l for l in r[line]), r
|
||||
time.sleep(6)
|
||||
alex.run("chat", "/nick Steve 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["another"] = steve.say("/nick Pebble")
|
||||
assert any("Only operators set nicknames on this server." in l for l in r["another"]), r
|
||||
time.sleep(6)
|
||||
steve.run("chat", "/nick 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: given money (/eco give), then the /shop menu: a category, buying one and a stack,
|
||||
selling a stack of hers, "Sell all" up to the daily limit, closing. Returns the coins it added, and the lines."""
|
||||
results = {}
|
||||
# Only what the step gives her: "Sell all" would sell the rest too.
|
||||
server.console("clear Alex")
|
||||
time.sleep(1)
|
||||
results["money"] = alex.say("/eco give Alex 2010 proof: shop funds")
|
||||
assert any("Alex gave 2,010 coins to Alex: proof: shop funds." in l for l in results["money"]), results
|
||||
assert alex.count("minecraft:iron_ingot") == 0
|
||||
alex.say("/shop")
|
||||
menu = alex.menu()
|
||||
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()[-3:]
|
||||
# One price, 15.
|
||||
assert any("Bought 1 iron ingot at 15 coins each: 15 coins." in l for l in results["bought"]), results
|
||||
alex.click(300 + 45) # back
|
||||
alex.click(300 + 14) # Building
|
||||
alex.run("click-slot", 300 + 0, "shift") # a stack of cobblestone
|
||||
time.sleep(1)
|
||||
assert alex.count("minecraft:cobblestone") == 64, alex.run("inventory")
|
||||
# Along the price as the stack moves it: 64 at 1 coin come to 64.05, 65 whole coins.
|
||||
results["stack"] = alex.chat()[-4:] # an advancement may come after
|
||||
assert any("Bought 64 cobblestone at 1.02 coins each: 65 coins." in l for l in results["stack"]), results
|
||||
assert not any("Tax" in l for l in results["stack"]), results
|
||||
# Her own stack of iron, clicked: the ingot she just bought stays (no buying to sell back), one she found sells.
|
||||
alex.click(alex.slot_of("minecraft:iron_ingot"))
|
||||
results["round_trip"] = alex.chat()[-1:]
|
||||
assert any("You bought 1 iron ingot from the market in the last 24 h" in l for l in results["round_trip"]), results
|
||||
server.console("give Alex minecraft:iron_ingot 1")
|
||||
time.sleep(2)
|
||||
alex.click(alex.slot_of("minecraft:iron_ingot"))
|
||||
results["sold"] = alex.chat()[-4:]
|
||||
# Sold at the price she bought at: one price.
|
||||
assert any("Sold 1 iron ingot at 15 coins each: 15 coins." in l for l in results["sold"]), results
|
||||
assert any("1 iron ingot kept: bought from the market in the last 24 h." in l for l in results["sold"]), results
|
||||
assert alex.count("minecraft:iron_ingot") == 1
|
||||
# Sell all, up to the 2,000 coins a day: 1,984 left (1 from Steve's /pay in the nickname step: money received
|
||||
# counts too; 15 for the iron), 9 diamonds at 200. Each one sold lowers the price: 40,000 * (1 - e^-0.045)
|
||||
# = 1,760.1 for the 9.
|
||||
server.console("give Alex minecraft:diamond 30")
|
||||
time.sleep(2)
|
||||
alex.click(300 + 49)
|
||||
results["sell_all"] = alex.chat()[-8:]
|
||||
assert any("Sold 9 diamond at 196 coins each: 1,760 coins." in l for l in results["sell_all"]), results
|
||||
assert not any("Tax" in l for l in results["sell_all"]), results
|
||||
assert any("21 diamond kept: today's selling limit." in l for l in results["sell_all"]), results
|
||||
assert alex.count("minecraft:diamond") == 21, alex.run("inventory")
|
||||
limits = alex.menu()[50]
|
||||
results["limits"] = limits
|
||||
# 1,984 - 1,760.
|
||||
assert "224 coins left of your 2,000 coins a day" in json.dumps(limits), limits
|
||||
alex.click(300 + 53) # close
|
||||
assert not alex.menu()
|
||||
results["worth"] = alex.say("/worth diamond")
|
||||
assert any("You may sell 631 more diamond today." in l for l in results["worth"]), results
|
||||
return 2000 + 10 - 15 - 65 + 15 + 1760, results
|
||||
|
||||
|
||||
def invsee(alex, data):
|
||||
"""Alex, an operator, opens offline Steve's inventory (his saved file): his 5 torches are there; a click takes
|
||||
them whole into hers, logged before the move (audit.log) and summed up in staff.log when she closes it."""
|
||||
results = {}
|
||||
before = alex.count("minecraft:torch")
|
||||
alex.say("/invsee Steve")
|
||||
time.sleep(2)
|
||||
menu = alex.menu()
|
||||
results["info"] = menu.get(49)
|
||||
assert menu.get(49, {}).get("item") == "minecraft:player_head" and "offline" in json.dumps(menu[49]), menu
|
||||
torch = next(slot for slot, e in menu.items() if e["item"] == "minecraft:torch" and slot < 36)
|
||||
assert menu[torch]["count"] == 5, menu
|
||||
alex.click(300 + torch)
|
||||
time.sleep(2)
|
||||
assert alex.count("minecraft:torch") == before + 5, alex.run("inventory")
|
||||
assert all(e["item"] != "minecraft:torch" for slot, e in alex.menu().items() if slot < 41), alex.menu()
|
||||
alex.click(300 + 53) # close
|
||||
time.sleep(1)
|
||||
staff = (data / "staff.log").read_text()
|
||||
results["staff"] = staff.strip().splitlines()[-1]
|
||||
assert "Steve" in staff and "5 torch" in staff, staff
|
||||
return results
|
||||
|
||||
|
||||
def market(server, alex, out, data):
|
||||
"""After the shop, prices moving: her sale moved diamond's one price down, /market's item page buys
|
||||
one and the price climbs back the next minute, the 24 h chart comes as a map drawn in her hand, and
|
||||
web/market.json has it all. Returns what she spent, and the lines."""
|
||||
results = {}
|
||||
|
||||
def worth():
|
||||
lines = alex.say("/worth diamond")
|
||||
line = next(l for l in lines if "diamond: " in l and "each, to buy or sell." in l)
|
||||
return int(re.search(r"diamond: ([\d,]+) coins each", line)[1].replace(",", "")), lines
|
||||
|
||||
# Her 9 diamonds lowered the price as they sold: 200 * e^-0.045 = 191.2 (it drifts back slowly, 6 h half-life).
|
||||
price, lines = worth()
|
||||
results["after_sale"] = lines
|
||||
assert price == 191, lines
|
||||
# The move itself is checked above; "Moves" compares with the oldest point of the last hour, which on a history
|
||||
# this young can be the first minute's own (0.0%).
|
||||
assert any("Moves: " in l and "traded in 24 h: 9" in l for l in lines), lines
|
||||
alex.say("/market diamond")
|
||||
page = alex.menu()
|
||||
results["page"] = page[13]
|
||||
assert page[13]["item"] == "minecraft:diamond" and page[48]["item"] == "minecraft:gold_ingot", page
|
||||
assert page[19]["item"] == "minecraft:lime_concrete" and page[21]["count"] == 64, page
|
||||
assert page[39]["item"] == "minecraft:map", page
|
||||
shown = json.dumps(page[13], ensure_ascii=False)
|
||||
# Its 24 h move is shown (▼, or "unchanged" on a minute-old history: see "Moves" above).
|
||||
assert f"Price: {price} coins each, to buy or sell" in shown and "24 h " in shown, shown
|
||||
alex.click(300 + 19) # buy one
|
||||
results["bought"] = alex.chat()[-4:]
|
||||
# One diamond moves the price 0.5% as it is bought: 191.7, 192 whole coins.
|
||||
paid = 192
|
||||
assert any(f"Bought 1 diamond at {paid} coins each: {paid} coins." in l for l in results["bought"]), results
|
||||
assert not any("Tax" in l for l in results["bought"]), results
|
||||
alex.click(300 + 39) # the 24 h chart
|
||||
assert alex.count("minecraft:filled_map") == 1, alex.run("inventory")
|
||||
alex.click(300 + 53) # close
|
||||
alex.run("select", "minecraft:filled_map")
|
||||
time.sleep(2)
|
||||
alex.run("screenshot", str(Path(out).resolve() / "market-chart.png"))
|
||||
# Her purchase pushed it back up at once.
|
||||
price2, lines = worth()
|
||||
results["after_buy"] = lines
|
||||
assert price2 > price, (price, lines)
|
||||
# web/market.json is written every minute: wait for the one after her purchase.
|
||||
deadline = time.time() + 90
|
||||
while True:
|
||||
doc = json.loads((data / "web" / "market.json").read_text())
|
||||
diamond = next(i for i in doc["items"] if i["id"] == "minecraft:diamond")
|
||||
if diamond["volume_24h"] == 10 or time.time() > deadline:
|
||||
break
|
||||
time.sleep(5)
|
||||
results["web"] = {k: diamond[k] for k in ("price", "buy", "sell", "change_1h", "volume_24h", "category")}
|
||||
assert doc["schema"] == 1 and doc["moving"] and diamond["category"] == "Mining", results["web"]
|
||||
# One price, which buying and selling both use.
|
||||
assert 190 < diamond["price"] < 200 and diamond["buy"] == diamond["sell"] == diamond["price"], results["web"]
|
||||
assert diamond["volume_24h"] == 10 and diamond["day"]["price"], results["web"]
|
||||
return paid, 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 = {}
|
||||
@@ -298,6 +828,13 @@ def trades(server, alex, steve, port, ctl, resources, root):
|
||||
|
||||
|
||||
def run(ctl, resources, out):
|
||||
if os.environ.get("SMP_PROOF") == "casino":
|
||||
# The casino's own proof (proof_casino.py), apart: it plays its games for a few minutes.
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("smp_proof_casino", Path(__file__).with_name("proof_casino.py"))
|
||||
casino = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(casino)
|
||||
return casino.run(ctl, resources, out)
|
||||
out = Path(out)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
game = Path(ctl).with_name("horizon-client")
|
||||
@@ -311,48 +848,86 @@ 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},
|
||||
# Quick teleports and an instant reward (the reward's
|
||||
# delay has unit tests).
|
||||
"places": {"warmup_seconds": 2, "cooldown_seconds": 0},
|
||||
# Paid once, after the restart, not to change the sums.
|
||||
"presence": {"daily_reward": 0, "reward_after_minutes": 0},
|
||||
# Steve plays a few minutes before his nickname.
|
||||
"nicks": {"min_playtime_minutes": 1},
|
||||
# Fixed prices for the first run's sums; the
|
||||
# moving market is on after the restart.
|
||||
"market": {"dynamic": {"enabled": False}},
|
||||
# The casino's pot gets no float here (its own proof,
|
||||
# proof_casino.py, covers it): the sums stay.
|
||||
"casino": {"float": 0}}))
|
||||
# A world from before 0.30, with a Treasury: its pool is dropped at the first start.
|
||||
(data / "economy.json").write_text(json.dumps({"accounts": {}, "pools": {"treasury": 5000}}))
|
||||
# A nickname an older version let through, taken off at the first start.
|
||||
(data / "nicks.json").write_text(json.dumps({"nicks": {"00000000-0000-0000-0000-00000000abcd": {
|
||||
"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"] == ["auction", "board", "casino", "claims", "currency", "gambling", "market", "nicks", "places", "presence", "report", "shop",
|
||||
"starting_balance", "web"], config
|
||||
results["welcome"] = steve.chat()
|
||||
assert any("Welcome to the server, Steve!" in l for l in results["welcome"]), results["welcome"]
|
||||
assert any("1. Be respectful to everyone." in l for l in results["welcome"]), results["welcome"]
|
||||
assert any("Steve joined for the first time" in l for l in alex.chat()), alex.chat()
|
||||
assert config["gambling"] == {"enabled": True, "min_bet": 10, "max_bet": 1000, "cooldown_seconds": 20,
|
||||
"offer_seconds": 60, "fee_percent": 0, "ticket_price": 10,
|
||||
"offer_seconds": 60, "ticket_price": 10,
|
||||
"max_tickets": 100, "lottery_seconds": 30}, config
|
||||
assert config["market"]["items"]["minecraft:diamond"] == {"buy": 400, "sell": 100}, config
|
||||
# One price, to buy and to sell.
|
||||
assert config["market"]["items"]["minecraft:diamond"] == {"buy": 200, "sell": 200}, config
|
||||
server.console("op Alex")
|
||||
time.sleep(1)
|
||||
alex.say("/eco give Alex 500")
|
||||
results["grant"] = alex.say("/eco give Alex 500 proof funding")
|
||||
assert any("Alex gave 500 coins to Alex: proof funding." in l for l in results["grant"]), results
|
||||
assert any("Alex gave 500 coins to Alex: proof funding." in l for l in steve.chat()[-3:]), "everyone reads it"
|
||||
results["balance"] = alex.say("/balance")
|
||||
assert any("Balance: 500 coins" in l for l in results["balance"]), results
|
||||
results["pay"] = alex.say("/pay Steve 120")
|
||||
assert any("You paid 120 coins to Steve. Balance: 380 coins" in l for l in results["pay"]), results
|
||||
results["paid"] = steve.chat()[-3:]
|
||||
assert any("Alex paid you 120 coins." in l for l in results["paid"]), results
|
||||
results["refused"] = [alex.say(l) for l in ("/pay Steve 9999", "/pay Alex 1", "/pay Nobody 1")]
|
||||
for lines, error in zip(results["refused"], ("You only have 380 coins.", "You can't pay yourself.",
|
||||
# 9999 is past what Steve may still receive today (2,000 a day, 120 received): money paid counts toward the
|
||||
# same daily limit as selling, so alts can't sell for a main past it.
|
||||
results["refused"] = [alex.say(l) for l in ("/pay Steve 1500", "/pay Steve 9999", "/pay Alex 1",
|
||||
"/pay Nobody 1")]
|
||||
for lines, error in zip(results["refused"], ("You only have 380 coins.",
|
||||
"Steve can only receive 1,880 coins more today",
|
||||
"You can't pay yourself.",
|
||||
"No player named Nobody has an account.")):
|
||||
assert any(error in l for l in lines), results["refused"]
|
||||
results["eco_refused"] = steve.say("/eco give Steve 1000")
|
||||
assert not any("now has" in l for l in results["eco_refused"]), results
|
||||
results["eco_refused"] = steve.say("/eco give Steve 1000 because")
|
||||
assert not any("gave" in l for l in results["eco_refused"]), results
|
||||
# The parts taken out: their commands are gone.
|
||||
for line in ("/treasury", "/bank", "/jobs", "/futures", "/eco taxes"):
|
||||
results[line] = steve.say(line)
|
||||
refused = ("Unknown or incomplete command", "Incorrect argument", "Usage", "Only operators")
|
||||
assert any(any(r in l for r in refused) for l in results[line]), results
|
||||
results["baltop"] = steve.say("/baltop")
|
||||
assert any("1. Alex: 380 coins" in l for l in results["baltop"]), results
|
||||
assert any("2. Steve: 120 coins" in l for l in results["baltop"]), results
|
||||
server.console("give Steve minecraft:diamond 3")
|
||||
server.console("give Steve minecraft:wheat 20")
|
||||
server.console("give Steve minecraft:diamond 1")
|
||||
server.console("give Steve minecraft:wheat 60")
|
||||
time.sleep(2)
|
||||
results["worth"] = steve.say("/worth diamond")
|
||||
assert any("diamond: the market pays 100 coins each, sells it for 400 coins each." in l
|
||||
for l in results["worth"]), results
|
||||
assert any("diamond: 200 coins each, to buy or sell." in l for l in results["worth"]), results
|
||||
results["sell_wheat"] = steve.say("/sell wheat")
|
||||
assert any("Sold 20 wheat for 20 coins." in l for l in results["sell_wheat"]), results
|
||||
assert any("Sold 60 wheat at 2 coins each: 120 coins." in l for l in results["sell_wheat"]), results
|
||||
results["sell_hand"] = steve.say("/sell hand")
|
||||
assert any("Sold 3 diamond for 300 coins." in l for l in results["sell_hand"]), results
|
||||
assert any("Sold 1 diamond at 200 coins each: 200 coins." in l for l in results["sell_hand"]), results
|
||||
results["buy"] = steve.say("/buy torch 5")
|
||||
assert any("Bought 5 torch for 5 coins." in l for l in results["buy"]), results
|
||||
assert any("Bought 5 torch at 1 coins each: 5 coins." in l for l in results["buy"]), results
|
||||
time.sleep(1)
|
||||
counts = steve.run("inventory").get("counts", {})
|
||||
results["steve_items"] = counts
|
||||
@@ -367,6 +942,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 +957,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
|
||||
@@ -382,28 +971,66 @@ def run(ctl, resources, out):
|
||||
assert balances["Alex"] == alex_after and sum(balances.values()) + held == 815, economy
|
||||
audit = [json.loads(l) for l in (data / "audit.log").read_text().splitlines()]
|
||||
results["audit"] = [a["kind"] for a in audit]
|
||||
assert results["audit"] == ["open", "open", "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",
|
||||
assert results["audit"] == ["open", "open", "eco_give", "pay", "sell 60 minecraft:wheat",
|
||||
"sell 1 minecraft:diamond", "buy 5 minecraft:torch", "trade", "trade_failed",
|
||||
"trade", "trade_money", "pay", "pay", "coinflip_stake", "coinflip_stake", "coinflip_win",
|
||||
"coinflip_stake", "lottery_ticket", "lottery_ticket", "lottery_win"] + (
|
||||
["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
|
||||
config["market"]["dynamic"]["enabled"] = True
|
||||
# The web pages go to the website through Kira: here to a stand-in, with a token.
|
||||
receiver = Receiver()
|
||||
config["web"] = {"url": receiver.url, "token": "proof-token"}
|
||||
(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)
|
||||
spent, results["market"] = market(server, alex, out, data)
|
||||
results["invsee"] = invsee(alex, data)
|
||||
sent = {path: (auth, doc) for path, auth, doc in receiver.got}
|
||||
results["pushed"] = sorted(sent)
|
||||
assert {"/smp/ingest/market.json", "/smp/ingest/economy.json"} <= set(sent), receiver.got[:2]
|
||||
assert all(auth == "Bearer proof-token" for auth, _ in sent.values()), [a for a, _ in sent.values()]
|
||||
assert sent["/smp/ingest/market.json"][1]["schema"] == 1 and sent["/smp/ingest/market.json"][1]["items"]
|
||||
receiver.server.shutdown()
|
||||
added -= spent
|
||||
alex.quit()
|
||||
server.stop()
|
||||
book = json.loads((data / "market_prices.json").read_text())
|
||||
assert book["items"]["minecraft:diamond"]["minutes"], "the prices' history is written when the world closes"
|
||||
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
|
||||
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]
|
||||
total = sum(a["balance"] for a in economy["accounts"].values())
|
||||
assert "held" not in economy and total == 815 + 7 + added, economy
|
||||
# The old Treasury pool is gone; no fee or tax took anything.
|
||||
assert "treasury" not in economy.get("pools", {}), economy.get("pools")
|
||||
audit = [json.loads(l) for l in (data / "audit.log").read_text().splitlines()]
|
||||
took = [a for a in audit if a["kind"] == "invsee_take"]
|
||||
assert [(a["item"], a["count"], a["inventory"]) for a in took] == [("minecraft:torch", 5, "inventory")], took
|
||||
kinds = [a["kind"] for a in audit if not a["kind"].startswith("invsee_")]
|
||||
shop_kinds = ["eco_give", "buy 1 minecraft:iron_ingot", "buy 64 minecraft:cobblestone",
|
||||
"sell 1 minecraft:iron_ingot", "sell 9 minecraft:diamond", "buy 1 minecraft:diamond"]
|
||||
assert kinds[-len(shop_kinds):] == shop_kinds, kinds[-8:]
|
||||
kinds = kinds[:-len(shop_kinds)]
|
||||
assert kinds[-2] in ("held_returned", "coinflip_cancel") and kinds[-1] == "daily_reward", kinds[-3:]
|
||||
results["stake_back"] = kinds[-2]
|
||||
sold = json.loads((data / "market.json").read_text())["sold"]
|
||||
# Steve's sales of the first run's day, and Alex's; coins received by /pay count too (#139): Steve got 120 + 1
|
||||
# from Alex, Alex 1 from Steve.
|
||||
assert sorted((s["coins"], s["items"]) for s in sold.values()) == [
|
||||
(441, {"minecraft:diamond": 1, "minecraft:wheat": 60}),
|
||||
(1776, {"minecraft:diamond": 9, "minecraft:iron_ingot": 1})], sold
|
||||
print(json.dumps({"proof": "smp", "ok": True, **results}, indent=2))
|
||||
finally:
|
||||
(out / "server.log").write_text("\n".join(server.lines))
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
"""The casino on a real dedicated server: the built game runs `--dedicated` with the mod, two real clients (Alex and
|
||||
Steve, `horizon-ctl`) join it and play every game through the server's chest menus, as a stock client would.
|
||||
Run it with `SMP_PROOF=casino python3 native/tools/native.py proof --resources PATH smp` (proof.py hands over to it);
|
||||
`SMP_PROOF_HEADLESS=1` starts the clients without a GPU (no screenshots).
|
||||
|
||||
Checked from the clients' own menus, chat, titles and action bar, and from the files the server wrote:
|
||||
|
||||
- the pot gets its float at the first start (made once), and `/casino help` shows every game's odds;
|
||||
- `/casino` opens the lobby (its title, the four games, the pot); a click opens a game;
|
||||
- roulette: a bet on Red, the stake raised, the wheel turns (several different pictures while it spins), the round
|
||||
is paid and the table shows the result; a second spin with the menu closed mid-spin is still paid, once;
|
||||
- slots: a double click pulls once; the reels scroll; closing the menu doesn't stop the pull;
|
||||
- crash: both players bet in the same round; Steve leaves mid-flight (cashed out or crashed, his bet settled once),
|
||||
Alex cashes out or crashes;
|
||||
- blackjack: a hand dealt and stood (or settled at once on a blackjack);
|
||||
- the bet limits: the most is `max_bet`;
|
||||
- after the server stops: every bet held is settled exactly once in audit.log, nothing is left held, and the money
|
||||
in the game (balances + pools) is the float and the operator's two `/eco give`s; casino.json holds the day's totals; no
|
||||
console command the casino ran was refused.
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("smp_proof_harness", Path(__file__).with_name("proof.py"))
|
||||
harness = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(harness)
|
||||
|
||||
FLOAT = 20_000
|
||||
GIVEN = 2 * 3000
|
||||
NAME = re.compile(r'"?text"?\s*:\s*"((?:[^"\\]|\\.)*)"')
|
||||
GAMES = ("casino_roulette", "casino_slots", "casino_crash", "casino_blackjack")
|
||||
|
||||
|
||||
class Client(harness.Client):
|
||||
def join(self, port):
|
||||
mode = "--headless" if os.environ.get("SMP_PROOF_HEADLESS") else "--offscreen"
|
||||
self.run("launch", mode, "--resources", self.resources)
|
||||
self.run("connect", f"127.0.0.1:{port}", self.name)
|
||||
self.run("wait-for", "loaded", "--timeout", 300)
|
||||
time.sleep(2)
|
||||
|
||||
def cells(self):
|
||||
"""The open casino menu: slot -> (item, name)."""
|
||||
out = {}
|
||||
for slot, e in self.menu().items():
|
||||
m = NAME.search(e.get("components", ""))
|
||||
out[slot] = (e["item"], m.group(1) if m else "")
|
||||
return out
|
||||
|
||||
def name_at(self, slot):
|
||||
return self.cells().get(slot, ("", ""))[1]
|
||||
|
||||
def title(self):
|
||||
return "".join(s["text"] for s in self.screen().get("title", []))
|
||||
|
||||
def quick(self, slot):
|
||||
self.run("click-slot", 300 + slot)
|
||||
|
||||
def shot(self, out, name):
|
||||
if not os.environ.get("SMP_PROOF_HEADLESS"):
|
||||
self.run("screenshot", str(Path(out).resolve() / name), check=False)
|
||||
|
||||
def wait_name(self, slot, text, timeout=30):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
name = self.name_at(slot)
|
||||
if text in name:
|
||||
return name
|
||||
time.sleep(0.25)
|
||||
raise AssertionError(f"{self.name}: slot {slot} never showed {text!r}: {self.cells()}")
|
||||
|
||||
|
||||
def shown(c, seconds=3):
|
||||
"""The titles and action bar lines `c` sees over the next `seconds`."""
|
||||
seen = []
|
||||
end = time.time() + seconds
|
||||
while time.time() < end:
|
||||
m = c.run("messages", 1)
|
||||
for line in (m.get("title"), m.get("subtitle"), m.get("actionbar")):
|
||||
if line and line not in seen:
|
||||
seen.append(line)
|
||||
time.sleep(0.2)
|
||||
return seen
|
||||
|
||||
|
||||
def audit(data):
|
||||
return [json.loads(l) for l in (data / "audit.log").read_text().splitlines()]
|
||||
|
||||
|
||||
def settled(data):
|
||||
return [a for a in audit(data) if a["kind"] in GAMES]
|
||||
|
||||
|
||||
def roulette(alex, out):
|
||||
r = {}
|
||||
alex.say("/casino roulette")
|
||||
assert alex.title() == "Casino · Roulette", alex.title()
|
||||
alex.click(300 + 18) # Red
|
||||
assert "Red" in alex.name_at(40), alex.cells()
|
||||
for _ in range(3):
|
||||
alex.click(300 + 49) # more
|
||||
r["bet"] = alex.name_at(48)
|
||||
pictures = []
|
||||
alex.quick(53)
|
||||
alex.wait_name(53, "turns", 10)
|
||||
shot, started = False, time.time()
|
||||
end = time.time() + 30
|
||||
# Until the wheel stops: each picture the menu shows, and the result's title or action bar.
|
||||
while time.time() < end:
|
||||
cells = alex.cells()
|
||||
if "SPIN" in cells.get(53, ("", ""))[1]:
|
||||
break
|
||||
pictures.append(tuple(cells.get(s) for s in range(18)))
|
||||
if not shot:
|
||||
alex.shot(out, "roulette-spin.png")
|
||||
shot = True
|
||||
r["pictures"] = len(set(pictures))
|
||||
r["samples"], r["spin_seconds"] = len(pictures), round(time.time() - started, 1)
|
||||
assert r["pictures"] >= 3, "the wheel didn't turn"
|
||||
r["result"] = alex.name_at(40)
|
||||
r["shown"] = shown(alex)
|
||||
assert r["shown"], "no title or action bar for the result"
|
||||
alex.shot(out, "roulette-result.png")
|
||||
# A second spin, the menu closed at once: paid all the same.
|
||||
time.sleep(2.5)
|
||||
alex.quick(53)
|
||||
time.sleep(0.4)
|
||||
alex.run("close")
|
||||
time.sleep(6)
|
||||
return r
|
||||
|
||||
|
||||
def slots(alex, out):
|
||||
r = {}
|
||||
alex.say("/casino slots")
|
||||
assert alex.title() == "Casino · Slots", alex.title()
|
||||
paytable = [alex.name_at(s) for s in range(36, 44)]
|
||||
assert "3 Nether Star: 500x" in paytable and "94.2% paid back on average" in paytable, paytable
|
||||
alex.quick(53)
|
||||
alex.quick(53) # the double click
|
||||
time.sleep(0.8)
|
||||
alex.shot(out, "slots-spin.png")
|
||||
r["turning"] = alex.name_at(53)
|
||||
assert "turn" in r["turning"], r
|
||||
alex.wait_name(53, "PULL")
|
||||
r["line"] = alex.name_at(4)
|
||||
assert "won" in r["line"] or "no luck" in r["line"], r
|
||||
time.sleep(2.5)
|
||||
alex.quick(53)
|
||||
time.sleep(0.3)
|
||||
alex.run("close")
|
||||
time.sleep(4)
|
||||
return r
|
||||
|
||||
|
||||
def crash(alex, steve, out):
|
||||
r = {}
|
||||
for c in (alex, steve):
|
||||
c.say("/casino crash")
|
||||
assert c.title() == "Casino · Crash", c.title()
|
||||
alex.quick(53)
|
||||
time.sleep(0.5)
|
||||
steve.quick(53)
|
||||
r["in"] = [c.wait_name(53, "Your bet is in", 8) for c in (alex, steve)]
|
||||
end = time.time() + 20
|
||||
while time.time() < end and not alex.name_at(38).startswith(("Flying", "Crashed")):
|
||||
time.sleep(0.25)
|
||||
r["status"] = alex.name_at(38)
|
||||
alex.shot(out, "crash-flight.png")
|
||||
steve.quit() # leaves mid-flight
|
||||
time.sleep(1.5)
|
||||
button = alex.name_at(53)
|
||||
if button.startswith("CASH OUT"):
|
||||
alex.quick(53)
|
||||
r["alex"] = "cashed out"
|
||||
else:
|
||||
r["alex"] = button
|
||||
r["shown"] = shown(alex)
|
||||
# The round ends (a crash, at most 100x: wait for it to show).
|
||||
end = time.time() + 120
|
||||
while time.time() < end and not alex.name_at(38).startswith(("Crashed", "No round")):
|
||||
time.sleep(1)
|
||||
r["end"] = alex.name_at(38)
|
||||
alex.run("close")
|
||||
return r
|
||||
|
||||
|
||||
def blackjack(alex, out):
|
||||
r = {}
|
||||
alex.say("/casino blackjack")
|
||||
assert alex.title() == "Casino · Blackjack", alex.title()
|
||||
alex.click(300 + 53)
|
||||
alex.shot(out, "blackjack-hand.png")
|
||||
r["dealt"] = alex.cells().get(40)
|
||||
if r["dealt"] and r["dealt"][1] == "STAND":
|
||||
alex.click(300 + 40)
|
||||
time.sleep(3)
|
||||
r["end"] = alex.name_at(4)
|
||||
# The most a bet may be.
|
||||
alex.run("click-slot", 300 + 49, "shift")
|
||||
time.sleep(1)
|
||||
r["max"] = alex.name_at(48)
|
||||
assert r["max"] == "Bet: 1,000 coins", r
|
||||
alex.run("close")
|
||||
return r
|
||||
|
||||
|
||||
def run(ctl, resources, out):
|
||||
out = Path(out)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
game = Path(ctl).with_name("horizon-client")
|
||||
root = Path(tempfile.mkdtemp(prefix="smp-casino-proof-"))
|
||||
port = harness.free_port()
|
||||
server = harness.Server(game, resources, root / "server", port)
|
||||
alex = Client(ctl, resources, "Alex", root)
|
||||
steve = Client(ctl, resources, "Steve", root)
|
||||
results = {}
|
||||
try:
|
||||
data = server.folder / "world" / "horizon-smp"
|
||||
data.mkdir(parents=True)
|
||||
(data / "config.json").write_text(json.dumps({
|
||||
"gambling": {"enabled": True}, "starting_balance": 0, "presence": {"daily_reward": 0},
|
||||
"casino": {"cooldown_ms": 500, "float": FLOAT}}))
|
||||
server.start()
|
||||
for line in ("difficulty peaceful", "gamerule spawn_monsters false"):
|
||||
server.console(line)
|
||||
alex.join(port)
|
||||
steve.join(port)
|
||||
server.console("op Alex")
|
||||
time.sleep(2)
|
||||
for who in ("Alex", "Steve"):
|
||||
assert any(f"Alex gave 3,000 coins to {who}: casino proof." in l for l in alex.say(f"/eco give {who} 3000 casino proof"))
|
||||
results["help"] = alex.say("/casino help")
|
||||
text = "\n".join(results["help"])
|
||||
for want in ("bets 10 coins to 1,000 coins", "pot (20,000 coins now)", "European", "94.2%", "96%", "3 to 2"):
|
||||
assert want in text, (want, results["help"])
|
||||
alex.say("/casino")
|
||||
assert alex.title() == "Casino", alex.title()
|
||||
lobby = alex.cells()
|
||||
results["lobby"] = {s: lobby.get(s) for s in (19, 21, 23, 25, 42)}
|
||||
assert lobby[19][0] == "minecraft:red_concrete" and "Roulette" in lobby[19][1], lobby
|
||||
assert lobby[42][1] == "The casino's pot: 20,000 coins", lobby
|
||||
alex.shot(out, "lobby.png")
|
||||
alex.click(300 + 21)
|
||||
assert alex.title() == "Casino · Slots", alex.title()
|
||||
alex.run("close")
|
||||
results["roulette"] = roulette(alex, out)
|
||||
results["slots"] = slots(alex, out)
|
||||
results["blackjack"] = blackjack(alex, out)
|
||||
results["crash"] = crash(alex, steve, out)
|
||||
time.sleep(3)
|
||||
lines = server.stop()
|
||||
alex.quit()
|
||||
refused = [l for l in lines if "Unknown or incomplete command" in l or "Incorrect argument" in l
|
||||
or "panicked" in l]
|
||||
assert not refused, refused
|
||||
log = audit(data)
|
||||
bets = [a for a in log if a["kind"] == "casino_bet"]
|
||||
ends = [a for a in log if a["kind"] in GAMES or a["kind"] == "casino_void"]
|
||||
results["audit"] = [a["kind"] for a in log if a["kind"].startswith("casino")]
|
||||
# Roulette 2, slots 2 (the double click pulled once), blackjack 1, crash 2; a double adds a second hold.
|
||||
counts = {g: sum(a["kind"] == g for a in ends) for g in GAMES}
|
||||
results["rounds"] = counts
|
||||
assert counts == {"casino_roulette": 2, "casino_slots": 2, "casino_blackjack": 1, "casino_crash": 2}, counts
|
||||
assert len(bets) >= 7, bets
|
||||
economy = json.loads((data / "economy.json").read_text())
|
||||
results["economy"] = economy
|
||||
held = sum(economy.get("held", {}).values())
|
||||
money = sum(a["balance"] for a in economy["accounts"].values()) + held + sum(economy["pools"].values())
|
||||
assert held == 0 and money == FLOAT + GIVEN, economy
|
||||
ledger = json.loads((data / "casino.json").read_text())
|
||||
results["casino.json"] = ledger
|
||||
assert sum(t["rounds"] for t in ledger["totals"].values()) == 7, ledger
|
||||
print(json.dumps({"proof": "smp-casino", "ok": True, **results}, indent=2, ensure_ascii=False))
|
||||
except BaseException:
|
||||
print(json.dumps({"proof": "smp-casino", "ok": False, **results}, indent=2, ensure_ascii=False, default=str))
|
||||
if (server.folder / "world" / "horizon-smp" / "audit.log").exists():
|
||||
print("audit:", [a["kind"] for a in audit(server.folder / "world" / "horizon-smp")])
|
||||
raise
|
||||
finally:
|
||||
(out / "server.log").write_text("\n".join(server.lines))
|
||||
for c in (alex, steve):
|
||||
c.quit()
|
||||
if server.__dict__.get("process") and server.process.poll() is None:
|
||||
server.process.kill()
|
||||
if not os.environ.get("SMP_PROOF_KEEP"):
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user