From 0e7ed706b6556f4d25c450832bd8d786ed0b70b5 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:18:15 +0100 Subject: [PATCH 01/18] Add shulker_peek 0.1.0 Submitted by @vstxx through the form. Co-authored-by: vstxx <106024432+vstxx@users.noreply.github.com> --- mods/shulker_peek.toml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 mods/shulker_peek.toml diff --git a/mods/shulker_peek.toml b/mods/shulker_peek.toml new file mode 100644 index 0000000..ecd571b --- /dev/null +++ b/mods/shulker_peek.toml @@ -0,0 +1,31 @@ +id = "shulker_peek" +kind = "native-mod" +name = "ShulkerPeek" +summary = "A shulker box's contents as a 9 x 3 grid in its tooltip." +description = """ +Hover a shulker box to see what's inside: its 27 slots appear as a 9 x 3 grid right in the tooltip, laid out exactly like the box's own screen, with item icons and stack counts. + +- Works with the plain shulker box and all 16 dyed ones, whether empty, partly filled or full. +- Works on every screen with item tooltips: your inventory, chests and other containers, the creative inventory, the item panel, chat item links and trading. +- Keeps the tooltip's usual lines (name, lore, "64 x Diamond"...); the grid goes underneath. +- Respects hidden tooltips: a box whose `minecraft:tooltip_display` hides its contents stays hidden. +- Settings in Options > Mods > ShulkerPeek > Settings: show the grid always or only while holding Shift, show a grid for empty boxes, and tint the grid with the box's dye colour. +- Uses your resource pack's own shulker box texture. + +Works in single player and on LAN worlds you host. Horizon currently leaves a box's contents out of what its built-in server sends to the game window, which is why even the "64 x Diamond" lines are missing there. ShulkerPeek sends the contents the way a vanilla server does. On vanilla servers the grid works too; on other Horizon servers without the mod, boxes look empty. + +It never changes items, inventories or worlds. Turn it off any time in Options > Mods. + +This is a native mod built for Horizon Nightly 2026-10-29 (`5e391eb`). It builds together with Horizon's official native mods (FPS Counter, Zoom, Minimap, Recipes and others).""" +authors = ["vstxx"] +owners = ["vstxx"] +license = "MIT" +source = "https://github.com/vstxx/shulkerpeek" +icon = "https://media.discordapp.net/attachments/1429508088528109770/1565474829585350717/vast-vastbrowser.png?ex=6ae5098c&is=6ae3b80c&hm=8b033c71b5412fa84ba8c3ba130eaafdee82dd8a72219ddb8966c5d73fa3a510&=&format=webp&quality=lossless&width=1024&height=1024" + +[[versions]] +version = "0.1.0" +published = "2026-10-29" +game = "26.3" +file = "shulker_peek-0.1.0.hnmod" +sha256 = "e4e33cb698d91042b2ed2f93a1c935f279d16d83988115d53c0f33530cabeab5" From c3669e54129105601c4a1f9b04441248894bf881 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:54:51 +0100 Subject: [PATCH 02/18] Add distant_horizons 0.1.0 Submitted by @denizsahin7744 through the form. Co-authored-by: denizsahin7744 <144704661+denizsahin7744@users.noreply.github.com> --- mods/distant_horizons.toml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 mods/distant_horizons.toml diff --git a/mods/distant_horizons.toml b/mods/distant_horizons.toml new file mode 100644 index 0000000..e4c0b9e --- /dev/null +++ b/mods/distant_horizons.toml @@ -0,0 +1,33 @@ +id = "distant_horizons" +kind = "native-mod" +name = "Distant Horizons for Horizon Client" +summary = "See far past your render distance: Distant Horizons' level-of-detail terrain, ported to Rust as a native mod." +description = """ +An unofficial Rust port of [Distant Horizons](https://gitlab.com/distant-horizons-team/distant-horizons) by James Seibel and contributors: the world past your render distance, drawn at lower detail the farther it is. + +- Far terrain built from the chunks you visit, and in single player from the world generator, out to 64–1024 chunks. +- Detail drops with distance by Distant Horizons' own rule, with its five quality presets. +- LODs are kept on disk per world, so they load at once next time. +- Drawn by Horizon's far-terrain renderer with block textures and biome tints. + +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. + +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. + +Known limits: heavy at 256+ chunks (about 30–40 fps on an Apple M5 MacBook Pro); water at LOD edges can show see-through walls. + +LGPL-3.0, as Distant Horizons; its copyright notices are kept. Not made or endorsed by the Distant Horizons team. No Minecraft code or assets are included. Ported by Deniz Şahin with Claude (Anthropic).""" +authors = ["denizsahin7744", "Deniz7744"] +owners = ["denizsahin7744"] +license = "LGPL-3.0-only" +source = "https://github.com/denizsahin7744/distant-horizons-for-horizon-client" + +[[versions]] +version = "0.1.0" +published = "2026-10-29" +game = "26.3" +file = "distant_horizons-0.1.0.hnmod" +sha256 = "c53a2501f14efb624839329aac6999ddf8c64a399d41bec42a0c878902feea81" +changelog = "First release: pinned to Horizon nightly d009ebd and Horizon Mixins 0d6b103." From 1910ab87099d311f1ae4d08f678d28a2d1886d44 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:00:11 +0100 Subject: [PATCH 03/18] Add appleskin 1.0.0 Submitted by @soufied through the form. Co-authored-by: soufied <47915535+soufied@users.noreply.github.com> --- mods/appleskin.toml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 mods/appleskin.toml diff --git a/mods/appleskin.toml b/mods/appleskin.toml new file mode 100644 index 0000000..ea115ab --- /dev/null +++ b/mods/appleskin.toml @@ -0,0 +1,28 @@ +id = "appleskin" +kind = "native-mod" +name = "AppleSkin" +summary = "Shows saturation, exhaustion and the food in your hand over the food bar." +description = """ +AppleSkin for Horizon, a native mod. +Draws saturation, exhaustion and the food in your hand over the food bar, +as the original AppleSkin does. +Based on AppleSkin by squeek502. See the LICENSE in the source for the +original copyright and licence notices.""" +authors = ["soufi"] +owners = ["soufied"] +license = "MIT" +source = "https://github.com/soufied/AppleSkin-Horizon" +key = "RWT6eNq4e5WfVDA2lzOOi6XCm/+jJgilqT1+HUUM33ie5MCjcRfSg760" + +[[versions]] +version = "1.0.0" +published = "2026-10-30" +game = "26.3" +file = "appleskin-1.0.0.hnmod" +sha256 = "3c25b916f74f47faf40070a8654c546d15cb79ac3947f140e257f4cb041aeb96" +signature = """ +untrusted comment: signature from minisign secret key +RUT6eNq4e5WfVEMHvcSGm73mmamWosNGGcdt7Qn05wqgRuAlDj/DyzH66lS2u8nvB43GnZJ7xCmgoM5xArHJuN9kD30iIKj0zws= +trusted comment: timestamp:1793368049\tfile:appleskin-1.0.0.hnmod\thashed +gjEf247x8XwHpOI2Ra3ngk6umC/mFZiHOCfZyzxg5IFJ7CNJVpofnw/l7ITtePPeyi4r3+0D6FvmmhtuiqVAAg==""" +changelog = "First release. AppleSkin for Horizon as a native mod: saturation, exhaustion and the food in your hand, drawn over the food bar." From 395b1422e06da0c622525b218ada8142c19b3de8 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:38:18 +0100 Subject: [PATCH 04/18] Add chatheads 0.1.0 Submitted by @Domanffe through the form. Co-authored-by: Domanffe <94391234+Domanffe@users.noreply.github.com> --- mods/chatheads.toml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 mods/chatheads.toml diff --git a/mods/chatheads.toml b/mods/chatheads.toml new file mode 100644 index 0000000..26d0e70 --- /dev/null +++ b/mods/chatheads.toml @@ -0,0 +1,23 @@ +id = "chatheads" +kind = "native-mod" +name = "ChatHeads" +summary = "A player's head left of every chat line they wrote." +description = """ +Each chat line from a player gets their 8x8 skin face in the strip to its left, like the Chat Heads Java mod. Lines without a player (system messages, /me, wrapped continuations) stay as they are; the face fades with its line. + +Settings: Options > Mods > ChatHeads > Settings (on or off). + +Known limit: the chat's hit-test does not know about the 10 px indent, so clicking a link in chat lands 10 px left of its pixels.""" +authors = ["Domanffe"] +owners = ["Domanffe"] +license = "MIT" +source = "https://github.com/Domanffe/chatheads" +icon = "https://horizon-rust.github.io/horizon-mods/icons/chatheads.png" + +[[versions]] +version = "0.1.0" +published = "2026-10-30" +game = "26.3" +file = "chatheads-0.1.0.hnmod" +sha256 = "c20605c24e29a762b9aea85cba1b0cc7955955a40a031efae70a5b01e4e79df4" +changelog = "First release: a native mod for Horizon 26.3. Every chat line from a player gets their 8x8 skin face in the strip to its left; system lines, /me and wrapped continuations get none. Settings in Options > Mods." From b1ad307e93269704ba7cfb713fd11c06c581efb2 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:39:29 +0100 Subject: [PATCH 05/18] Add rightharvest 0.1.0 Submitted by @Domanffe through the form. Co-authored-by: Domanffe <94391234+Domanffe@users.noreply.github.com> --- mods/rightharvest.toml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 mods/rightharvest.toml diff --git a/mods/rightharvest.toml b/mods/rightharvest.toml new file mode 100644 index 0000000..b83d428 --- /dev/null +++ b/mods/rightharvest.toml @@ -0,0 +1,21 @@ +id = "rightharvest" +kind = "native-mod" +name = "RightClickHarvest" +summary = "Right-click a ripe crop to pick it and sow it again." +description = """ +With an empty hand, right-click a ripe wheat, carrot, potato, beetroot or nether wart: the crop is harvested with the game's own drops, sound and statistics, and sown again from a seed in your inventory. + +No settings: turn it off in Options > Mods.""" +authors = ["Domanffe"] +owners = ["Domanffe"] +license = "MIT" +source = "https://github.com/Domanffe/rightharvest" +icon = "https://horizon-rust.github.io/horizon-mods/icons/rightharvest.png" + +[[versions]] +version = "0.1.0" +published = "2026-10-30" +game = "26.3" +file = "rightharvest-0.1.0.hnmod" +sha256 = "a43870a89827c1d0fa861a663aa1781819b236f994191abf971c98125a3a4653" +changelog = "First release: a native mod for Horizon 26.3. With an empty hand, right-click a ripe crop to harvest it with the game's own drops and sow it again from a seed you carry." From ebcadb8dd6f10450af2aca13096d6d647dfd33f9 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:53:17 +0100 Subject: [PATCH 06/18] Add eatinganimation 0.1.0 Submitted by @soufied through the form. Co-authored-by: soufied <47915535+soufied@users.noreply.github.com> --- mods/eatinganimation.toml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 mods/eatinganimation.toml diff --git a/mods/eatinganimation.toml b/mods/eatinganimation.toml new file mode 100644 index 0000000..667ae8b --- /dev/null +++ b/mods/eatinganimation.toml @@ -0,0 +1,26 @@ +id = "eatinganimation" +kind = "native-mod" +name = "Eating Animation" +summary = "Shows bitten frames for the food, milk and potion in your hand while you eat or drink them." +description = """ +Eating Animation for Horizon, a native mod, a port of Eating Animation by Theoness1. +While you eat food or milk, or drink a potion, the item in your hand shows its bitten frames. +The mod has no textures of its own. Build the frame pack from your own copy of the EatingAnimation download with `tools/make_frame_pack.py` in the source, and put it in Horizon's resource packs.""" +authors = ["soufi"] +owners = ["soufied"] +license = "MIT" +source = "https://github.com/soufied/EatingAnimation-Horizon" +key = "RWT6eNq4e5WfVDA2lzOOi6XCm/+jJgilqT1+HUUM33ie5MCjcRfSg760" + +[[versions]] +version = "0.1.0" +published = "2026-10-30" +game = "26.3" +file = "eatinganimation-0.1.0.hnmod" +sha256 = "07ef4fde3ebbc4f132aab593b12187bce15e1a46689ab40bd3067c685aca85b9" +signature = """ +untrusted comment: signature from minisign secret key +RUT6eNq4e5WfVJPSxJOh730W1FMzydyVRO2S58rux9xbEtEU+mDgsqWX65rscWaUH3zqOkLan6quW/u9nEZDgXrNz1n6IHnT5Aw= +trusted comment: timestamp:1793371727\tfile:eatinganimation-0.1.0.hnmod\thashed +qCCGAWAc39kDujbX9CsdXu/URjpNybAOxkomTi18GnzJhMLtsbj0ZfVJaVCSxnh0VvznGQvoH9/DEdRAxBAVBg==""" +changelog = "First release. Eating Animation for Horizon as a native mod: the food, milk and potion in your hand show bitten frames while you eat or drink them." From b498e0a5fc3c3d20ffa0a76fc4ed2dc4fb78b092 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:53:17 +0100 Subject: [PATCH 07/18] Add xtoxray 2026.29.0 Submitted by @RealCat1st through the form. Co-authored-by: RealCat1st <260922535+RealCat1st@users.noreply.github.com> --- mods/xtoxray.toml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 mods/xtoxray.toml diff --git a/mods/xtoxray.toml b/mods/xtoxray.toml new file mode 100644 index 0000000..b703346 --- /dev/null +++ b/mods/xtoxray.toml @@ -0,0 +1,33 @@ +id = "xtoxray" +kind = "native-mod" +name = "X To Xray" +summary = "Opensource Xraymod" +description = """ +Press X To Xray! +Does Not Work On Servers. + +Instalation: +1. Download Horizon +2. Get Horizon Working +3. Add The Mod Trough The Horizon Launcher +4. Start Xraying +""" +authors = ["1_st_Cat"] +owners = ["RealCat1st"] +license = "Apache 2.0" +source = "https://github.com/RealCat1st/XToXrayRust" +icon = "https://github.com/RealCat1st/XToXrayRust/blob/main/icon.png" +key = "RWQVF+EFGGnX6eUNms+cP5AceLvx9b0rFYYXCty7so4MVbJ1SNpi9zNK" + +[[versions]] +version = "2026.29.0" +published = "2026-10-30" +game = "26.3" +file = "xtoxray-2026.29.0.hnmod" +sha256 = "3446a4a70a2b87ef6e9de00ce8a255aa83c2e44ccd5acc9f14887662ed55f27c" +signature = """ +untrusted comment: signature from rsign secret key +RUQVF+EFGGnX6cZkn/iGZ9FUHXC45MU+8uuj7l7eRkZ+bR/x7S8s2U34F7nv/f6jKXdzeLZi3QoSA5g5oXqP99/KlGgd/19ygwY= +trusted comment: timestamp:1793375231\tfile:xtoxray-2026.29.0.hnmod\tprehashed +2h6QOc/dnek1KMCGL2jerhCObc9pECmShr0kKYJhEEFz52Hhb4C3vmjc9vuwE3CftUHs+StHkalHQVLVQy8eCg==""" +changelog = "Release" From 666479ad0580f0ed7d304acc6b735c98e73248c2 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:36:37 +0100 Subject: [PATCH 08/18] SMP native server mod: the economy (/balance, /pay, /baltop, /eco) The official server's SMP, opt-in: not listed in the registry, built only into the official server's engine. Balances by UUID in world/horizon-smp/economy.json (written atomically, at most once a tick), an audit line per change written before it, config.json with defaults. Hooks only Horizon's hook points (API 8: server commands and players). Co-Authored-By: Claude Opus 5.5 --- native/smp/Cargo.lock | 107 +++++ native/smp/Cargo.toml | 16 + native/smp/LICENSE | 21 + native/smp/README.md | 63 +++ native/smp/compatibility.json | 8 + native/smp/mixins/smp/command.rs | 11 + native/smp/mixins/smp/glue.rs | 179 +++++++ native/smp/mixins/smp/horizon-mixin.toml | 67 +++ native/smp/mixins/smp/lifecycle.rs | 10 + native/smp/mixins/smp/register.rs | 4 + native/smp/mixins/smp/smp_economy.rs | 571 +++++++++++++++++++++++ native/smp/mixins/smp/smp_store.rs | 177 +++++++ native/smp/mixins/smp/tick.rs | 12 + native/smp/proof.py | 193 ++++++++ native/smp/src/lib.rs | 7 + 15 files changed, 1446 insertions(+) create mode 100644 native/smp/Cargo.lock create mode 100644 native/smp/Cargo.toml create mode 100644 native/smp/LICENSE create mode 100644 native/smp/README.md create mode 100644 native/smp/compatibility.json create mode 100644 native/smp/mixins/smp/command.rs create mode 100644 native/smp/mixins/smp/glue.rs create mode 100644 native/smp/mixins/smp/horizon-mixin.toml create mode 100644 native/smp/mixins/smp/lifecycle.rs create mode 100644 native/smp/mixins/smp/register.rs create mode 100644 native/smp/mixins/smp/smp_economy.rs create mode 100644 native/smp/mixins/smp/smp_store.rs create mode 100644 native/smp/mixins/smp/tick.rs create mode 100644 native/smp/proof.py create mode 100644 native/smp/src/lib.rs diff --git a/native/smp/Cargo.lock b/native/smp/Cargo.lock new file mode 100644 index 0000000..7191b60 --- /dev/null +++ b/native/smp/Cargo.lock @@ -0,0 +1,107 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "horizon-mod-smp" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/native/smp/Cargo.toml b/native/smp/Cargo.toml new file mode 100644 index 0000000..c11154a --- /dev/null +++ b/native/smp/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "horizon-mod-smp" +version = "0.1.0" +edition = "2024" +license = "MIT" +publish = false +description = "The official Horizon server's SMP, a native server mod: economy (and next, market, trades, claims)." + +# The mod's rules and their tests: the same files the bundle adds to the game +# (as game::smp_store, game::smp_economy), under the same names. +[lib] +path = "src/lib.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/native/smp/LICENSE b/native/smp/LICENSE new file mode 100644 index 0000000..e959a07 --- /dev/null +++ b/native/smp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ludovic Marie and the Horizon contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/native/smp/README.md b/native/smp/README.md new file mode 100644 index 0000000..4b806e5 --- /dev/null +++ b/native/smp/README.md @@ -0,0 +1,63 @@ +# SMP (Horizon native server mod) + +The official Horizon server's survival layer ([design](https://github.com/horizon-rust/horizon/blob/main/docs/smp.md)). +It is **not** part of Horizon's own server: only a server that builds this mod into its engine has it. It isn't listed +in the registry. + +This version: the economy. + +| Command | Does | +|---|---| +| `/balance [player]`, `/bal` | your balance, or another player's | +| `/pay ` | 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 ` | operators of level 3: corrections, logged | + +The currency is earned in game only: it can't be bought with real money or cashed out (Mojang's usage guidelines). + +## Data + +Everything is in the world's folder, `world/horizon-smp/`, so a backup of the world carries it. A world without that +folder is untouched until the mod first runs on it; it changes no block and no player. + +| File | Holds | +|---|---| +| `config.json` | `currency` (default `coins`) and `starting_balance` (default 0), written with the defaults the first time | +| `economy.json` | balances by player UUID, with the name last seen | +| `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 | + +`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 +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 +server tick and the world lifecycle. `compatibility.json` names `"hooks": 8`. + +| File | What it is | +|---|---| +| `mixins/smp/horizon-mixin.toml` | The bundle: three modules and the HEAD hooks | +| `mixins/smp/smp_store.rs`, `smp_economy.rs` | The rules, no game types: added to the game as `game::smp_store`, `game::smp_economy`; 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}.rs` | One small helper per hook | +| `proof.py` | A dedicated server built with the mod and two real clients, on fresh temporary data | + +## Build and prove + +```sh +(cd native/smp && cargo test) # the rules +python3 native/tools/native.py check smp # apply on the tested Horizon and cargo check the game +python3 native/tools/native.py proof smp --resources /path/to/.local/horizon-client/26.3 +``` + +## The official server's engine + +The server runs `horizon-server` as usual, with `[engine] path` pointing at a game built with this mod: + +```sh +NATIVE_NO_DEFAULT_FEATURES=1 python3 native/tools/native.py build smp --horizon +``` + +prints the built `horizon-client` (`--no-default-features`: the server build, without audio and controllers). When +to deploy it on the official server is the owners' decision. diff --git a/native/smp/compatibility.json b/native/smp/compatibility.json new file mode 100644 index 0000000..dc321c0 --- /dev/null +++ b/native/smp/compatibility.json @@ -0,0 +1,8 @@ +{ + "game": "26.3", + "hooks": 8, + "horizon_repository": "https://github.com/horizon-rust/horizon", + "horizon_revision": "768d20d9980c2e04cc76951d18ec6c72ccef0176", + "horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins", + "horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06" +} diff --git a/native/smp/mixins/smp/command.rs b/native/smp/mixins/smp/command.rs new file mode 100644 index 0000000..a6dd58b --- /dev/null +++ b/native/smp/mixins/smp/command.rs @@ -0,0 +1,11 @@ +// A player's command on the server: the SMP's own, when it is one. +fn smp_perform_command( + game: &mut crate::game::Game<'_>, + player: &crate::game::native_server::NativeServerPlayer, + command: &str, + ran: &mut bool, +) { + if !*ran { + crate::game::smp_glue::perform(game, player, command, ran); + } +} diff --git a/native/smp/mixins/smp/glue.rs b/native/smp/mixins/smp/glue.rs new file mode 100644 index 0000000..afd43a0 --- /dev/null +++ b/native/smp/mixins/smp/glue.rs @@ -0,0 +1,179 @@ +// The SMP in the server's game: its state for the open world, and the edges +// between the game's types and the rules (smp_economy). Added to the game as +// `game::smp_glue`; the helpers (register.rs, command.rs, tick.rs, +// lifecycle.rs) call it from the hook points. + +use crate::game::{ + Game, + native_server::{NativeCommand, NativeServerPlayer}, + smp_economy::{self, Economy, Out, Player}, + smp_store, +}; +use horizon_client::text::{self, Text}; +use std::{path::PathBuf, sync::Mutex}; + +/// The mod's id, name and version. +pub const ID: &str = "smp"; +pub const NAME: &str = "SMP"; +pub const VERSION: &str = "0.1.0"; + +/// `config.json` in `world/horizon-smp/`, written with its defaults the +/// first time. +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct Config { + #[serde(flatten)] + pub economy: smp_economy::Config, +} + +/// The SMP of the open world. +pub struct Smp { + dir: PathBuf, + /// An error when `economy.json` can't be read: the commands say so. + economy: Result, +} + +static STATE: Mutex> = Mutex::new(None); +static TICKS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +fn open(dir: PathBuf) -> Smp { + let config_path = dir.join("config.json"); + let config: Config = match smp_store::read_json(&config_path) { + Ok(c) => c, + Err(e) => { + log::error!("[smp] {e}: the defaults are used"); + Config::default() + } + }; + if !config_path.exists() + && let Err(e) = smp_store::write_json(&config_path, &config) + { + log::error!("[smp] {e}"); + } + log::info!("[smp] {NAME} {VERSION} on {}", dir.display()); + let economy = Economy::open(&dir, config.economy); + if let Err(e) = &economy { + log::error!("[smp] the economy is off: {e}"); + } + Smp { dir, economy } +} + +/// Runs `f` on the SMP of the game's open world (opened on first use). +fn with(game: &Game<'_>, f: impl FnOnce(&mut Smp) -> R) -> Option { + let dir = game.native_world_dir()?.join(smp_store::DIR); + let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner()); + if state.as_ref().is_none_or(|s| s.dir != dir) { + if let Some(old) = state.as_mut() { + save(old); + } + *state = Some(open(dir)); + } + let smp = state.as_mut().expect("opened above"); + if let Ok(e) = &mut smp.economy { + e.now = chrono::Utc::now().timestamp_millis(); + } + Some(f(smp)) +} + +fn save(smp: &mut Smp) { + if let Ok(e) = &mut smp.economy + && let Err(err) = e.save() + { + log::error!("[smp] {err}"); + } +} + +/// The commands, for every player's command tree. +pub fn commands(commands: &mut Vec) { + for usage in [ + "balance [player:word]", + "bal [player:word]", + "pay ", + "baltop [page:integer]", + ] { + commands.push(NativeCommand::new(usage)); + } + for verb in ["give", "take", "set"] { + commands.push(NativeCommand::op( + &format!("eco {verb} "), + 3, + )); + } +} + +/// A player's command. +pub(in crate::game) fn perform( + game: &mut Game<'_>, + player: &NativeServerPlayer, + line: &str, + ran: &mut bool, +) { + if !game.native_on(ID) { + return; + } + let me = Player { + uuid: player.uuid, + name: player.name.clone(), + level: player.permission, + }; + let Some(out) = with(game, |smp| match &mut smp.economy { + Ok(e) => { + e.seen(me.uuid, &me.name); + e.command(&me, line) + } + Err(_) => matches!( + line.split(' ').next(), + Some("balance" | "bal" | "pay" | "baltop" | "eco") + ) + .then(|| { + vec![Out::Error( + "The economy is off: its file can't be read (see the server log).".into(), + )] + }), + }) + .flatten() else { + return; + }; + *ran = true; + show(game, out); +} + +fn show(game: &mut Game<'_>, out: Vec) { + for o in out { + match o { + Out::Reply(s) => game.native_feedback(Text::literal(s)), + Out::Error(s) => game.native_feedback(Text::literal(s).with_color(text::RED)), + Out::Tell(uuid, s) => { + game.native_tell(uuid, Text::literal(s).with_color(text::GOLD)); + } + } + } +} + +/// The end of each server tick: accounts for the players on (once a second) +/// and the files that changed. +pub(in crate::game) fn tick(game: &mut Game<'_>) { + let n = TICKS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let players = if n % 20 == 0 { + game.native_server_players() + } else { + Vec::new() + }; + with(game, |smp| { + if let Ok(e) = &mut smp.economy { + for p in &players { + e.seen(p.uuid, &p.name); + } + } + save(smp); + }); +} + +/// The world closed: its files are written and its state let go. +pub fn closed() { + let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(smp) = state.as_mut() { + save(smp); + } + *state = None; +} diff --git a/native/smp/mixins/smp/horizon-mixin.toml b/native/smp/mixins/smp/horizon-mixin.toml new file mode 100644 index 0000000..bb1c183 --- /dev/null +++ b/native/smp/mixins/smp/horizon-mixin.toml @@ -0,0 +1,67 @@ +[mod] +id = "smp" +version = "0.1.0" +priority = 100 + +# The commit it was tested on. It uses only the native hook points (hooks 8 in +# compatibility.json): it builds on any Horizon that has them. +[compatibility] +horizon_revision = "768d20d9980c2e04cc76951d18ec6c72ccef0176" + +[[module]] +id = "store" +file = "crates/horizon-client/src/game.rs" +name = "smp_store" +source = "smp_store.rs" + +[[module]] +id = "economy" +file = "crates/horizon-client/src/game.rs" +name = "smp_economy" +source = "smp_economy.rs" + +[[module]] +id = "glue" +file = "crates/horizon-client/src/game.rs" +name = "smp_glue" +source = "glue.rs" + +[[injection]] +id = "register" +kind = "HEAD" +target = "horizon_client::game::native_hooks::Game<'_>::native_register_commands" +helper = "register.rs" +bindings = [ + { name = "commands", ty = "&mut Vec", value = "&mut *commands" }, +] + +[[injection]] +id = "command" +kind = "HEAD" +target = "horizon_client::game::native_hooks::Game<'_>::native_perform_command" +helper = "command.rs" +bindings = [ + { name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" }, + { name = "player", ty = "&crate::game::native_server::NativeServerPlayer", value = "player" }, + { name = "command", ty = "&str", value = "command" }, + { name = "ran", ty = "&mut bool", value = "&mut *ran" }, +] + +[[injection]] +id = "tick" +kind = "HEAD" +target = "horizon_client::game::native_hooks::Game<'_>::native_server_tick" +helper = "tick.rs" +bindings = [ + { name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" }, + { name = "phase", ty = "crate::game::native_lifecycle::ServerTickPhase", value = "phase" }, +] + +[[injection]] +id = "lifecycle" +kind = "HEAD" +target = "horizon_client::game::native_hooks::Game<'_>::native_world_lifecycle" +helper = "lifecycle.rs" +bindings = [ + { name = "phase", ty = "crate::game::native_lifecycle::WorldPhase", value = "phase" }, +] diff --git a/native/smp/mixins/smp/lifecycle.rs b/native/smp/mixins/smp/lifecycle.rs new file mode 100644 index 0000000..ca4f2d8 --- /dev/null +++ b/native/smp/mixins/smp/lifecycle.rs @@ -0,0 +1,10 @@ +// The world closing: the SMP writes its files and lets its state go. +fn smp_world_lifecycle(phase: crate::game::native_lifecycle::WorldPhase) { + use crate::game::native_lifecycle::WorldPhase; + if matches!( + phase, + WorldPhase::WorldCloseFinal | WorldPhase::WorldCloseFailed + ) { + crate::game::smp_glue::closed(); + } +} diff --git a/native/smp/mixins/smp/register.rs b/native/smp/mixins/smp/register.rs new file mode 100644 index 0000000..8d19650 --- /dev/null +++ b/native/smp/mixins/smp/register.rs @@ -0,0 +1,4 @@ +// The SMP's commands in every player's command tree. +fn smp_register_commands(commands: &mut Vec) { + crate::game::smp_glue::commands(commands); +} diff --git a/native/smp/mixins/smp/smp_economy.rs b/native/smp/mixins/smp/smp_economy.rs new file mode 100644 index 0000000..b007f77 --- /dev/null +++ b/native/smp/mixins/smp/smp_economy.rs @@ -0,0 +1,571 @@ +// The SMP's economy: one currency, a balance per player UUID +// (`economy.json`), and the commands `/balance`, `/pay`, `/baltop` and the +// operators' `/eco`. Every change is written to the audit log first, then +// made; a change the log refuses is not made. No game types: the glue +// (glue.rs) hands in the player and the line, and sends back what this +// answers. Added to the game as `game::smp_economy`; also a module of this +// crate, for its tests. + +use super::smp_store::{Audit, read_json, uuid_string, write_json}; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, path::PathBuf}; + +/// Amounts above this are refused: far beyond any economy, far below overflow. +pub const MAX_AMOUNT: i64 = 1_000_000_000_000; +/// Lines of `/baltop` a page. +pub const PAGE: usize = 10; + +/// `config.json`'s economy settings. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Config { + /// Shown after amounts: `1,250 coins`. + pub currency: String, + /// What a new account holds. + pub starting_balance: i64, +} + +impl Default for Config { + fn default() -> Self { + Self { + currency: "coins".into(), + starting_balance: 0, + } + } +} + +/// One player's account. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Account { + /// The name last seen. + pub name: String, + pub balance: i64, +} + +/// `economy.json`. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Accounts { + /// By UUID, as `uuid_string` writes it. + pub accounts: BTreeMap, +} + +/// A player as a command sees them. +#[derive(Clone, Debug, PartialEq)] +pub struct Player { + pub uuid: u128, + pub name: String, + /// Operator level, 0 to 4. + pub level: u8, +} + +/// What a command answers: its feedback, red when an error, and lines to +/// other players. +#[derive(Clone, Debug, PartialEq)] +pub enum Out { + Reply(String), + Error(String), + Tell(u128, String), +} + +pub struct Economy { + pub config: Config, + accounts: Accounts, + path: PathBuf, + audit: Audit, + dirty: bool, + /// Milliseconds since 1970, for the log (the glue sets it each call). + pub now: i64, +} + +impl Economy { + /// The economy kept in `dir` (`world/horizon-smp/`); an error when + /// `economy.json` exists and can't be read: the economy then stays off + /// rather than start from nothing. + pub fn open(dir: &std::path::Path, config: Config) -> Result { + let path = dir.join("economy.json"); + let accounts = read_json(&path)?; + Ok(Self { + config, + accounts, + path, + audit: Audit::new(dir.join("audit.log")), + dirty: false, + now: 0, + }) + } + + /// Writes `economy.json` when it changed (end of a tick, world closing). + pub fn save(&mut self) -> Result<(), String> { + self.audit.sync(); + if !self.dirty { + return Ok(()); + } + write_json(&self.path, &self.accounts)?; + self.dirty = false; + Ok(()) + } + + pub fn balance(&self, uuid: u128) -> i64 { + self.accounts + .accounts + .get(&uuid_string(uuid)) + .map_or(0, |a| a.balance) + } + + /// The account of a player who is on: opened with the starting balance + /// the first time (logged), its name kept up to date. + pub fn seen(&mut self, uuid: u128, name: &str) { + let key = uuid_string(uuid); + match self.accounts.accounts.get_mut(&key) { + Some(a) if a.name == name => {} + Some(a) => { + a.name = name.to_owned(); + self.dirty = true; + } + None => { + let start = self.config.starting_balance.clamp(0, MAX_AMOUNT); + let entry = self.entry("open", None, Some(uuid), start, ""); + if self.audit.append(&entry).is_ok() { + self.accounts.accounts.insert( + key, + Account { + name: name.to_owned(), + balance: start, + }, + ); + self.dirty = true; + } + } + } + } + + /// An account by the name last seen, any case. + pub fn find(&self, name: &str) -> Option<(u128, &Account)> { + self.accounts + .accounts + .iter() + .find(|(_, a)| a.name.eq_ignore_ascii_case(name)) + .and_then(|(k, a)| Some((super::smp_store::parse_uuid(k)?, a))) + } + + /// `1,250 coins`. + pub fn money(&self, amount: i64) -> String { + let digits = amount.unsigned_abs().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); + } + let sign = if amount < 0 { "-" } else { "" }; + format!("{sign}{out} {}", self.config.currency) + } + + fn entry( + &self, + kind: &str, + from: Option, + to: Option, + amount: i64, + by: &str, + ) -> serde_json::Value { + let after = |u: Option| u.map(|u| self.balance(u)); + let mut e = serde_json::json!({ + "time": self.now, + "kind": kind, + "amount": amount, + }); + if let Some(f) = from { + e["from"] = uuid_string(f).into(); + e["from_before"] = after(Some(f)).into(); + } + if let Some(t) = to { + e["to"] = uuid_string(t).into(); + e["to_before"] = after(Some(t)).into(); + } + if !by.is_empty() { + e["by"] = by.into(); + } + e + } + + /// Moves `amount` from one account to another, logged as `kind`. Both + /// must exist; `from` must hold it. + pub fn transfer( + &mut self, + kind: &str, + from: u128, + to: u128, + amount: i64, + ) -> Result<(), String> { + if amount <= 0 || amount > MAX_AMOUNT { + return Err("The amount must be a positive whole number.".into()); + } + if from == to { + return Err("You can't pay yourself.".into()); + } + let (Some(f), Some(t)) = ( + self.accounts.accounts.get(&uuid_string(from)), + self.accounts.accounts.get(&uuid_string(to)), + ) else { + return Err("No such account.".into()); + }; + if f.balance < amount { + return Err(format!("You only have {}.", self.money(f.balance))); + } + let Some(to_after) = t + .balance + .checked_add(amount) + .filter(|b| *b <= MAX_AMOUNT * 1000) + else { + return Err("That account can't hold more.".into()); + }; + let entry = self.entry(kind, Some(from), Some(to), amount, ""); + self.audit.append(&entry)?; + self.accounts + .accounts + .get_mut(&uuid_string(from)) + .expect("checked") + .balance -= amount; + self.accounts + .accounts + .get_mut(&uuid_string(to)) + .expect("checked") + .balance = to_after; + self.dirty = true; + 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 { + let key = uuid_string(uuid); + let Some(a) = self.accounts.accounts.get(&key) else { + return Err("No such account.".into()); + }; + let after = a.balance.saturating_add(delta); + if after < 0 { + return Err(format!("Only {} there.", self.money(a.balance))); + } + if after > MAX_AMOUNT * 1000 { + return Err("That account can't hold more.".into()); + } + let (from, to) = if delta < 0 { + (Some(uuid), None) + } else { + (None, Some(uuid)) + }; + let entry = self.entry(kind, from, to, delta.abs(), by); + self.audit.append(&entry)?; + self.accounts + .accounts + .get_mut(&key) + .expect("checked") + .balance = after; + self.dirty = true; + Ok(after) + } + + /// The accounts, richest first (then by name). + pub fn top(&self) -> Vec<(&str, i64)> { + let mut all: Vec<_> = self + .accounts + .accounts + .values() + .map(|a| (a.name.as_str(), a.balance)) + .collect(); + all.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0))); + all + } + + /// Runs `/balance`, `/bal`, `/pay`, `/baltop` or `/eco`; None for another + /// command. + pub fn command(&mut self, me: &Player, line: &str) -> Option> { + 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), + _ => return None, + }) + } + + fn balance_command(&mut self, me: &Player, args: &[&str]) -> Vec { + match args { + [] => vec![Out::Reply(format!( + "Balance: {}", + self.money(self.balance(me.uuid)) + ))], + [who] => match self.find(who) { + Some((_, a)) => vec![Out::Reply(format!("{}: {}", a.name, self.money(a.balance)))], + None => vec![Out::Error(format!("No player named {who} has an account."))], + }, + _ => vec![Out::Error("Usage: /balance [player]".into())], + } + } + + fn pay(&mut self, me: &Player, args: &[&str]) -> Vec { + let [who, amount] = args else { + return vec![Out::Error("Usage: /pay ".into())]; + }; + let Some(amount) = parse_amount(amount) else { + return vec![Out::Error( + "The amount must be a positive whole number.".into(), + )]; + }; + let Some((to, account)) = self.find(who) else { + return vec![Out::Error(format!("No player named {who} has an account."))]; + }; + let to_name = account.name.clone(); + match self.transfer("pay", me.uuid, to, amount) { + Ok(()) => vec![ + Out::Reply(format!( + "You paid {} to {to_name}. Balance: {}", + self.money(amount), + self.money(self.balance(me.uuid)) + )), + Out::Tell(to, format!("{} paid you {}.", me.name, self.money(amount))), + ], + Err(e) => vec![Out::Error(e)], + } + } + + fn baltop(&mut self, args: &[&str]) -> Vec { + let page = match args { + [] => 1, + [p] => match p.parse::() { + Ok(p) if p >= 1 => p, + _ => return vec![Out::Error("Usage: /baltop [page]".into())], + }, + _ => return vec![Out::Error("Usage: /baltop [page]".into())], + }; + let top = self.top(); + let pages = top.len().div_ceil(PAGE).max(1); + let mut out = vec![Out::Reply(format!("Richest players ({page}/{pages}):"))]; + for (i, (name, balance)) in top.iter().enumerate().skip((page - 1) * PAGE).take(PAGE) { + out.push(Out::Reply(format!( + "{}. {name}: {}", + i + 1, + self.money(*balance) + ))); + } + out + } + + fn eco(&mut self, me: &Player, args: &[&str]) -> Vec { + if me.level < 3 { + return vec![Out::Error("Only operators of level 3 may use /eco.".into())]; + } + let [verb, who, amount] = args else { + return vec![Out::Error( + "Usage: /eco give|take|set ".into(), + )]; + }; + let amount = match amount.parse::() { + Ok(a) if (0..=MAX_AMOUNT).contains(&a) => a, + _ => { + return vec![Out::Error( + "The amount must be a whole number from 0.".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 ".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)))], + Err(e) => vec![Out::Error(e)], + } + } +} + +/// A positive whole amount, `1250` or `1,250`. +pub fn parse_amount(s: &str) -> Option { + let digits: String = s.chars().filter(|c| *c != ',').collect(); + if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) { + return None; + } + digits + .parse::() + .ok() + .filter(|a| (1..=MAX_AMOUNT).contains(a)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + const ALEX: u128 = 1; + const STEVE: u128 = 2; + + fn player(uuid: u128, name: &str, level: u8) -> Player { + Player { + uuid, + name: name.into(), + level, + } + } + + fn economy(name: &str) -> (Economy, PathBuf) { + let dir = std::env::temp_dir().join(format!("smp-economy-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + let config = Config { + starting_balance: 100, + ..Config::default() + }; + let mut e = Economy::open(&dir, config).unwrap(); + e.seen(ALEX, "Alex"); + e.seen(STEVE, "Steve"); + (e, dir) + } + + fn log_lines(dir: &std::path::Path) -> Vec { + fs::read_to_string(dir.join("audit.log")) + .unwrap() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect() + } + + #[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(); + assert_eq!( + out, + [ + Out::Reply("You paid 30 coins to Steve. Balance: 70 coins".into()), + Out::Tell(STEVE, "Alex paid you 30 coins.".into()), + ] + ); + assert_eq!((e.balance(ALEX), e.balance(STEVE)), (70, 130)); + let log = log_lines(&dir); + assert_eq!(log.len(), 3, "two accounts opened, one payment"); + assert_eq!(log[2]["kind"], "pay"); + assert_eq!(log[2]["from_before"], 100); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn pay_refuses_what_it_must() { + let (mut e, dir) = economy("refuse"); + let alex = player(ALEX, "Alex", 0); + for (line, error) in [ + ("pay Steve 101", "You only have 100 coins."), + ("pay Steve 0", "The amount must be a positive whole number."), + ( + "pay Steve -5", + "The amount must be a positive whole number.", + ), + ( + "pay Steve 1.5", + "The amount must be a positive whole number.", + ), + ("pay Alex 5", "You can't pay yourself."), + ( + "pay Herobrine 5", + "No player named Herobrine has an account.", + ), + ("pay Steve", "Usage: /pay "), + ] { + assert_eq!( + e.command(&alex, line).unwrap(), + [Out::Error(error.into())], + "{line}" + ); + } + assert_eq!((e.balance(ALEX), e.balance(STEVE)), (100, 100)); + assert_eq!( + log_lines(&dir).len(), + 2, + "nothing logged but the two accounts" + ); + fs::remove_dir_all(dir).unwrap(); + } + + #[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") + .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)); + fs::write(dir.join("economy.json"), "{ half").unwrap(); + assert!(Economy::open(&dir, Config::default()).is_err()); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn eco_is_for_level_3_and_never_goes_below_zero() { + let (mut e, dir) = economy("eco"); + let alex = player(ALEX, "Alex", 2); + assert_eq!( + e.command(&alex, "eco give Steve 5").unwrap(), + [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_eq!( + e.command(&op, "eco take Steve 200").unwrap(), + [Out::Error("Only 105 coins there.".into())] + ); + assert_eq!( + e.command(&op, "eco set Steve 1234567").unwrap(), + [Out::Reply("Steve now has 1,234,567 coins.".into())] + ); + let log = log_lines(&dir); + assert_eq!(log.last().unwrap()["kind"], "eco_set"); + assert_eq!(log.last().unwrap()["by"], uuid_string(ALEX)); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn baltop_pages_the_richest_first() { + let (mut e, dir) = economy("baltop"); + 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(); + 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(); + assert_eq!( + last.last().unwrap(), + &Out::Reply("14. Alex: 50 coins".into()) + ); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn a_name_change_keeps_the_account() { + let (mut e, dir) = economy("rename"); + e.seen(ALEX, "Alexandra"); + assert_eq!(e.balance(ALEX), 100); + assert!(e.find("alexandra").is_some() && e.find("Alex").is_none()); + assert_eq!(log_lines(&dir).len(), 2); + fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/native/smp/mixins/smp/smp_store.rs b/native/smp/mixins/smp/smp_store.rs new file mode 100644 index 0000000..86a7bc7 --- /dev/null +++ b/native/smp/mixins/smp/smp_store.rs @@ -0,0 +1,177 @@ +// The SMP's files under the world's folder (`world/horizon-smp/`): JSON +// documents written whole and atomically (a temporary file, flushed, renamed +// over the old one), and the audit log, one JSON line per transaction, +// appended before the change it records. Added to the game as +// `game::smp_store`; also a module of this crate, for its tests. + +use std::{ + fs::{self, File, OpenOptions}, + io::{self, Write}, + path::{Path, PathBuf}, +}; + +/// The folder of the SMP's files in a world's folder. +pub const DIR: &str = "horizon-smp"; + +/// Writes `bytes` to `path` so that a crash leaves either the old file or the +/// new one: a temporary file beside it, flushed to disk, then renamed over it. +pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { + let dir = path.parent().unwrap_or(Path::new(".")); + fs::create_dir_all(dir)?; + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file"); + let temp = dir.join(format!(".{name}.tmp")); + { + let mut file = File::create(&temp)?; + file.write_all(bytes)?; + file.sync_all()?; + } + fs::rename(&temp, path)?; + // The rename itself reaches the disk with the folder's entry (Unix). + if let Ok(d) = File::open(dir) { + let _ = d.sync_all(); + } + Ok(()) +} + +/// A JSON document: its default when the file doesn't exist, an error when it +/// exists but can't be read, so that a damaged file is never replaced by an +/// empty one. +pub fn read_json(path: &Path) -> Result { + match fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display())), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(T::default()), + Err(e) => Err(format!("{}: {e}", path.display())), + } +} + +/// Writes a JSON document atomically, pretty-printed. +pub fn write_json(path: &Path, value: &T) -> Result<(), String> { + let mut bytes = serde_json::to_vec_pretty(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, + file: Option, +} + +impl Audit { + pub fn new(path: PathBuf) -> Self { + Self { path, file: None } + } + /// Appends one line; the change it records waits for it. + pub fn append(&mut self, entry: &serde_json::Value) -> Result<(), String> { + let mut line = entry.to_string(); + line.push('\n'); + let path = &self.path; + if self.file.is_none() { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + let file = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|e| format!("{}: {e}", path.display()))?; + self.file = Some(file); + } + let file = self.file.as_mut().expect("opened above"); + file.write_all(line.as_bytes()) + .map_err(|e| format!("{}: {e}", path.display())) + } + /// Flushes what was appended to disk (once a tick, and when the world closes). + pub fn sync(&mut self) { + if let Some(f) = &self.file { + let _ = f.sync_data(); + } + } +} + +/// `UUID.toString()`: 8-4-4-4-12 lowercase hex digits. +pub fn uuid_string(u: u128) -> String { + let h = format!("{u:032x}"); + format!( + "{}-{}-{}-{}-{}", + &h[0..8], + &h[8..12], + &h[12..16], + &h[16..20], + &h[20..] + ) +} + +/// The UUID a [`uuid_string`] wrote. +pub fn parse_uuid(s: &str) -> Option { + let hex: String = s.chars().filter(|c| *c != '-').collect(); + (hex.len() == 32) + .then(|| u128::from_str_radix(&hex, 16).ok()) + .flatten() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("smp-store-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + dir + } + + #[test] + fn atomic_writes_replace_the_file_and_leave_no_temporary() { + let dir = scratch("atomic"); + let path = dir.join("economy.json"); + write_atomic(&path, b"one").unwrap(); + write_atomic(&path, b"two").unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"two"); + let names: Vec<_> = fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect(); + assert_eq!(names, ["economy.json"]); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn a_damaged_document_is_an_error_not_an_empty_one() { + let dir = scratch("damaged"); + let path = dir.join("economy.json"); + let missing: Vec = read_json(&path).unwrap(); + assert!(missing.is_empty()); + write_atomic(&path, b"{ not json").unwrap(); + assert!(read_json::>(&path).is_err()); + write_json(&path, &vec![1u32, 2]).unwrap(); + assert_eq!(read_json::>(&path).unwrap(), [1, 2]); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn the_audit_log_appends_lines() { + let dir = scratch("audit"); + let mut audit = Audit::new(dir.join("audit.log")); + audit + .append(&serde_json::json!({"kind": "pay", "amount": 5})) + .unwrap(); + audit + .append(&serde_json::json!({"kind": "pay", "amount": 6})) + .unwrap(); + audit.sync(); + let text = fs::read_to_string(dir.join("audit.log")).unwrap(); + assert_eq!( + text, + "{\"amount\":5,\"kind\":\"pay\"}\n{\"amount\":6,\"kind\":\"pay\"}\n" + ); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn uuids_round_trip() { + let u = 0x0000_0000_0000_4000_8000_0000_0000_0001u128; + assert_eq!(uuid_string(u), "00000000-0000-4000-8000-000000000001"); + assert_eq!(parse_uuid(&uuid_string(u)), Some(u)); + assert_eq!(parse_uuid("nope"), None); + } +} diff --git a/native/smp/mixins/smp/tick.rs b/native/smp/mixins/smp/tick.rs new file mode 100644 index 0000000..bd4a171 --- /dev/null +++ b/native/smp/mixins/smp/tick.rs @@ -0,0 +1,12 @@ +// The end of each server tick: the SMP's accounts and files. +fn smp_server_tick( + game: &mut crate::game::Game<'_>, + phase: crate::game::native_lifecycle::ServerTickPhase, +) { + if matches!( + phase, + crate::game::native_lifecycle::ServerTickPhase::ServerTickEnd { .. } + ) { + crate::game::smp_glue::tick(game); + } +} diff --git a/native/smp/proof.py b/native/smp/proof.py new file mode 100644 index 0000000..542e14f --- /dev/null +++ b/native/smp/proof.py @@ -0,0 +1,193 @@ +"""The SMP on a real dedicated server: the built game runs `--dedicated` with the mod, and two real clients (Alex and +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); +- the commands are in each player's command tree (the chat box's suggestions); +- after the server stops cleanly and starts again, the balances are the same, and audit.log holds every change. + +Everything runs in fresh temporary folders (the server's, each client's home, launcher and XDG folders), no audio. +""" +import json +import os +import shutil +import socket +import subprocess +import tempfile +import threading +import time +from pathlib import Path + +PROPERTIES = { + "online-mode": "false", "white-list": "false", "enforce-secure-profile": "false", "level-type": "minecraft\\:flat", "spawn-protection": "0", "max-players": "4", + "view-distance": "4", "simulation-distance": "4", "generate-structures": "false", "enable-command-block": "false", +} + + +def free_port(): + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def scratch_env(root): + env = dict(os.environ, HORIZON_NO_AUDIO="1", HORIZON_LAUNCHER_NO_SELF_UPDATE="1") + for key in ("HORIZON_HOME", "HORIZON_LAUNCHER_HOME", "XDG_DATA_HOME", "XDG_CONFIG_HOME"): + path = root / key.lower() + path.mkdir(parents=True, exist_ok=True) + env[key] = str(path) + return env + + +class Server: + """`horizon-client --dedicated --nogui` in a scratch server folder, its console on stdin.""" + + def __init__(self, game, resources, folder, port): + self.folder, self.lines, self.game, self.resources, self.port = folder, [], game, resources, port + folder.mkdir(parents=True, exist_ok=True) + props = dict(PROPERTIES, **{"server-port": str(port)}) + (folder / "server.properties").write_text("".join(f"{k}={v}\n" for k, v in props.items())) + (folder / "eula.txt").write_text("eula=true\n") + self.env = scratch_env(folder.parent / "server-env") + + def start(self): + self.lines = [] + self.process = subprocess.Popen( + [str(self.game), "--resources", str(self.resources), "--dedicated", "--nogui"], cwd=self.folder, + env=self.env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + threading.Thread(target=self._read, daemon=True).start() + self.wait_for("Done (", 600) + + def _read(self): + for line in self.process.stdout: + self.lines.append(line.rstrip("\n")) + + def wait_for(self, text, timeout=120): + end = time.time() + timeout + while time.time() < end: + if any(text in line for line in self.lines): + return + if self.process.poll() is not None: + break + time.sleep(0.2) + raise AssertionError(f"server: no {text!r} in {timeout} s; last lines: {self.lines[-30:]}") + + def console(self, line): + self.process.stdin.write(line + "\n") + self.process.stdin.flush() + + def stop(self): + if self.process.poll() is None: + self.console("stop") + try: + self.process.wait(120) + except subprocess.TimeoutExpired: + self.process.kill() + return self.lines + + +class Client: + """A real client under horizon-ctl, joined to the server as `name`.""" + + def __init__(self, ctl, resources, name, root): + self.ctl, self.name, self.resources = ctl, name, resources + self.home = root / name + self.env = scratch_env(self.home) + self.socket = str(self.home / "ctl.sock") + + def run(self, *args, check=True): + done = subprocess.run([str(self.ctl), "--socket", self.socket, *map(str, args)], env=self.env, + cwd=self.home, capture_output=True, text=True, timeout=600) + if check and done.returncode: + raise AssertionError(f"{self.name}: {args}: {done.stdout[-400:]} {done.stderr[-400:]}") + try: + return json.loads(done.stdout.strip().splitlines()[-1]) + except (json.JSONDecodeError, IndexError): + return done.stdout + + def join(self, port): + self.run("launch", "--offscreen", "--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 say(self, line): + """A command typed in chat; returns the chat lines it brought.""" + before = len(self.chat()) + self.run("chat", line) + time.sleep(1.5) + return self.chat()[before:] + + def chat(self): + """Every chat line so far, oldest first (`messages` answers newest first).""" + reply = self.run("messages", 500) + return list(reversed(reply.get("chat", []))) + + def quit(self): + self.run("quit", check=False) + + +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-proof-")) + port = free_port() + server = Server(game, resources, root / "server", port) + alex = Client(ctl, resources, "Alex", root) + steve = Client(ctl, resources, "Steve", root) + results = {} + try: + server.start() + alex.join(port) + steve.join(port) + time.sleep(2) + data = server.folder / "world" / "horizon-smp" + results["config"] = json.loads((data / "config.json").read_text()) + assert results["config"] == {"currency": "coins", "starting_balance": 0}, results["config"] + server.console("op Alex") + time.sleep(1) + alex.say("/eco give Alex 500") + 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.", + "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["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 + steve.quit() + alex.quit() + lines = server.stop() + assert not any("panicked" in l for l in lines), lines[-40:] + economy = json.loads((data / "economy.json").read_text()) + balances = {a["name"]: a["balance"] for a in economy["accounts"].values()} + results["economy.json"] = balances + assert balances == {"Alex": 380, "Steve": 120}, balances + 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"], results["audit"] + server.start() + alex = Client(ctl, resources, "Alex2", root) + alex.name = "Alex" + alex.join(port) + results["after_restart"] = alex.say("/balance") + assert any("Balance: 380 coins" in l for l in results["after_restart"]), results + alex.quit() + server.stop() + print(json.dumps({"proof": "smp", "ok": True, **results}, indent=2)) + 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() + shutil.rmtree(root, ignore_errors=True) diff --git a/native/smp/src/lib.rs b/native/smp/src/lib.rs new file mode 100644 index 0000000..a4ee4a9 --- /dev/null +++ b/native/smp/src/lib.rs @@ -0,0 +1,7 @@ +//! The SMP's rules, as the bundle adds them to the game: each file is a +//! module of `game` there and of this crate here, under the same name, so +//! `super::smp_store` reaches the same code in both. +#[path = "../mixins/smp/smp_economy.rs"] +pub mod smp_economy; +#[path = "../mixins/smp/smp_store.rs"] +pub mod smp_store; From 7e795012ed1b326e5c3bdaee47368734aed3723f Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:46:51 +0100 Subject: [PATCH 09/18] SMP: the server market (/worth, /sell, /buy) The server buys and sells vanilla items at config.json's market prices (a starting list the owner edits): plain items only, players in survival or adventure mode only. Items move first, then money, both in the audit log; a sale the log refuses gives the items back, a purchase checks money and room first. Needs hook points API 9 (the acting player's items). config.json is written again when a version adds keys. Co-Authored-By: Claude Opus 5.5 --- native/smp/Cargo.lock | 2 +- native/smp/Cargo.toml | 2 +- native/smp/README.md | 17 +- native/smp/compatibility.json | 4 +- native/smp/mixins/smp/glue.rs | 140 ++++-- native/smp/mixins/smp/horizon-mixin.toml | 12 +- native/smp/mixins/smp/smp_market.rs | 518 +++++++++++++++++++++++ native/smp/proof.py | 34 +- native/smp/src/lib.rs | 2 + 9 files changed, 676 insertions(+), 55 deletions(-) create mode 100644 native/smp/mixins/smp/smp_market.rs diff --git a/native/smp/Cargo.lock b/native/smp/Cargo.lock index 7191b60..ebc8d3c 100644 --- a/native/smp/Cargo.lock +++ b/native/smp/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "horizon-mod-smp" -version = "0.1.0" +version = "0.2.0" dependencies = [ "serde", "serde_json", diff --git a/native/smp/Cargo.toml b/native/smp/Cargo.toml index c11154a..a8fbe8d 100644 --- a/native/smp/Cargo.toml +++ b/native/smp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "horizon-mod-smp" -version = "0.1.0" +version = "0.2.0" edition = "2024" license = "MIT" publish = false diff --git a/native/smp/README.md b/native/smp/README.md index 4b806e5..442d9c7 100644 --- a/native/smp/README.md +++ b/native/smp/README.md @@ -4,7 +4,7 @@ The official Horizon server's survival layer ([design](https://github.com/horizo It is **not** part of Horizon's own server: only a server that builds this mod into its engine has it. It isn't listed in the registry. -This version: the economy. +This version: the economy and the server market. | Command | Does | |---|---| @@ -12,6 +12,13 @@ This version: the economy. | `/pay ` | 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 ` | 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) | +| `/sell hand [count]`, `/sell [count]`, `/sell all` | sells to the market: the stack in your hand, an item, or everything it buys | +| `/buy [count]` | buys from the market, only what fits in your inventory | + +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. The currency is earned in game only: it can't be bought with real money or cashed out (Mojang's usage guidelines). @@ -22,7 +29,7 @@ folder is untouched until the mod first runs on it; it changes no block and no p | File | Holds | |---|---| -| `config.json` | `currency` (default `coins`) and `starting_balance` (default 0), written with the defaults the first time | +| `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 | | `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 | @@ -33,12 +40,12 @@ 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 -server tick and the world lifecycle. `compatibility.json` names `"hooks": 8`. +acting player's items (API 9), the server tick and the world lifecycle. `compatibility.json` names `"hooks": 9`. | File | What it is | |---|---| -| `mixins/smp/horizon-mixin.toml` | The bundle: three modules and the HEAD hooks | -| `mixins/smp/smp_store.rs`, `smp_economy.rs` | The rules, no game types: added to the game as `game::smp_store`, `game::smp_economy`; also this crate's modules (`src/lib.rs`), for their tests | +| `mixins/smp/horizon-mixin.toml` | The bundle: four modules and the HEAD hooks | +| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs` | The rules, no game types: added to the game as `game::smp_store`, `game::smp_economy`, `game::smp_market`; 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}.rs` | One small helper per hook | | `proof.py` | A dedicated server built with the mod and two real clients, on fresh temporary data | diff --git a/native/smp/compatibility.json b/native/smp/compatibility.json index dc321c0..3b5e4da 100644 --- a/native/smp/compatibility.json +++ b/native/smp/compatibility.json @@ -1,8 +1,8 @@ { "game": "26.3", - "hooks": 8, + "hooks": 9, "horizon_repository": "https://github.com/horizon-rust/horizon", - "horizon_revision": "768d20d9980c2e04cc76951d18ec6c72ccef0176", + "horizon_revision": "b070f41ddae470df36694dff7f176bdda2a3ad90", "horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins", "horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06" } diff --git a/native/smp/mixins/smp/glue.rs b/native/smp/mixins/smp/glue.rs index afd43a0..aef2ccd 100644 --- a/native/smp/mixins/smp/glue.rs +++ b/native/smp/mixins/smp/glue.rs @@ -1,12 +1,13 @@ // The SMP in the server's game: its state for the open world, and the edges -// between the game's types and the rules (smp_economy). Added to the game as -// `game::smp_glue`; the helpers (register.rs, command.rs, tick.rs, -// lifecycle.rs) call it from the hook points. +// between the game's types and the rules (smp_economy, smp_market). Added to +// the game as `game::smp_glue`; the helpers (register.rs, command.rs, +// tick.rs, lifecycle.rs) call it from the hook points. use crate::game::{ Game, native_server::{NativeCommand, NativeServerPlayer}, smp_economy::{self, Economy, Out, Player}, + smp_market::{self, Bag, Market}, smp_store, }; use horizon_client::text::{self, Text}; @@ -15,15 +16,16 @@ use std::{path::PathBuf, sync::Mutex}; /// The mod's id, name and version. pub const ID: &str = "smp"; pub const NAME: &str = "SMP"; -pub const VERSION: &str = "0.1.0"; +pub const VERSION: &str = "0.2.0"; /// `config.json` in `world/horizon-smp/`, written with its defaults the -/// first time. +/// first time, and again with the keys a newer version adds (values kept). #[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] #[serde(default)] pub struct Config { #[serde(flatten)] pub economy: smp_economy::Config, + pub market: smp_market::Config, } /// The SMP of the open world. @@ -31,6 +33,7 @@ pub struct Smp { dir: PathBuf, /// An error when `economy.json` can't be read: the commands say so. economy: Result, + market: Market, } static STATE: Mutex> = Mutex::new(None); @@ -38,16 +41,32 @@ static TICKS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0 fn open(dir: PathBuf) -> Smp { let config_path = dir.join("config.json"); - let config: Config = match smp_store::read_json(&config_path) { - Ok(c) => c, + let (config, write) = match smp_store::read_json::>(&config_path) { + // No file yet: the defaults, written for the owner to edit. + Ok(None) => (Config::default(), true), + Ok(Some(v)) => match serde_json::from_value::(v.clone()) { + // Written again only when a newer version adds keys; values kept. + Ok(c) => { + let keys = |v: &serde_json::Value| v.as_object().map(|o| o.len()); + let full = serde_json::to_value(&c).unwrap_or_default(); + let added = keys(&full) != keys(&v); + (c, added) + } + Err(e) => { + log::error!( + "[smp] {}: {e}: the defaults are used", + config_path.display() + ); + (Config::default(), false) + } + }, + // A damaged file stays as it is for the owner to mend. Err(e) => { log::error!("[smp] {e}: the defaults are used"); - Config::default() + (Config::default(), false) } }; - if !config_path.exists() - && let Err(e) = smp_store::write_json(&config_path, &config) - { + if write && let Err(e) = smp_store::write_json(&config_path, &config) { log::error!("[smp] {e}"); } log::info!("[smp] {NAME} {VERSION} on {}", dir.display()); @@ -55,12 +74,18 @@ fn open(dir: PathBuf) -> Smp { if let Err(e) = &economy { log::error!("[smp] the economy is off: {e}"); } - Smp { dir, economy } + Smp { + dir, + economy, + market: Market { + config: config.market, + }, + } } -/// Runs `f` on the SMP of the game's open world (opened on first use). -fn with(game: &Game<'_>, f: impl FnOnce(&mut Smp) -> R) -> Option { - let dir = game.native_world_dir()?.join(smp_store::DIR); +/// The SMP's state, opened for the world in `dir` on first use (the files +/// of another world it held are written first). +fn state(dir: PathBuf) -> std::sync::MutexGuard<'static, Option> { let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner()); if state.as_ref().is_none_or(|s| s.dir != dir) { if let Some(old) = state.as_mut() { @@ -68,11 +93,10 @@ fn with(game: &Game<'_>, f: impl FnOnce(&mut Smp) -> R) -> Option { } *state = Some(open(dir)); } - let smp = state.as_mut().expect("opened above"); - if let Ok(e) = &mut smp.economy { + if let Some(Ok(e)) = state.as_mut().map(|s| &mut s.economy) { e.now = chrono::Utc::now().timestamp_millis(); } - Some(f(smp)) + state } fn save(smp: &mut Smp) { @@ -90,6 +114,11 @@ pub fn commands(commands: &mut Vec) { "bal [player:word]", "pay ", "baltop [page:integer]", + "worth [item:item]", + "sell hand [count:integer]", + "sell all", + "sell [count:integer]", + "buy [count:integer]", ] { commands.push(NativeCommand::new(usage)); } @@ -101,6 +130,34 @@ pub fn commands(commands: &mut Vec) { } } +/// The acting player's items (inside `native_perform_command`). +struct GameBag<'a, 'g>(&'a mut Game<'g>); + +impl Bag for GameBag<'_, '_> { + fn held(&self) -> Option<(String, u32, bool)> { + self.0.native_held() + } + fn carried(&self) -> Vec<(String, u32)> { + self.0.native_carried_items() + } + fn count(&mut self, item: &str) -> u32 { + self.0.native_count_items(item) + } + fn take(&mut self, item: &str, count: u32) -> u32 { + self.0.native_take_items(item, count) + } + fn room(&mut self, item: &str) -> u32 { + self.0.native_room_for(item) + } + fn give(&mut self, item: &str, count: u32) -> u32 { + self.0.native_give_items(item, count) + } +} + +const COMMANDS: [&str; 8] = [ + "balance", "bal", "pay", "baltop", "eco", "worth", "sell", "buy", +]; + /// A player's command. pub(in crate::game) fn perform( game: &mut Game<'_>, @@ -108,32 +165,34 @@ pub(in crate::game) fn perform( line: &str, ran: &mut bool, ) { - if !game.native_on(ID) { + if !game.native_on(ID) || !COMMANDS.contains(&line.split(' ').next().unwrap_or_default()) { return; } + let Some(dir) = game.native_world_dir() else { + return; + }; let me = Player { uuid: player.uuid, name: player.name.clone(), level: player.permission, }; - let Some(out) = with(game, |smp| match &mut smp.economy { + // Survival and adventure: a creative player could sell what they made. + let trades = matches!(player.game_mode, 0 | 2); + let mut guard = state(dir.join(smp_store::DIR)); + let smp = guard.as_mut().expect("opened"); + let out = match &mut smp.economy { Ok(e) => { e.seen(me.uuid, &me.name); + let mut bag = GameBag(game); e.command(&me, line) + .or_else(|| smp.market.command(e, &me, trades, &mut bag, line)) + .unwrap_or_default() } - Err(_) => matches!( - line.split(' ').next(), - Some("balance" | "bal" | "pay" | "baltop" | "eco") - ) - .then(|| { - vec![Out::Error( - "The economy is off: its file can't be read (see the server log).".into(), - )] - }), - }) - .flatten() else { - return; + Err(_) => vec![Out::Error( + "The economy is off: its file can't be read (see the server log).".into(), + )], }; + drop(guard); *ran = true; show(game, out); } @@ -153,20 +212,23 @@ fn show(game: &mut Game<'_>, out: Vec) { /// The end of each server tick: accounts for the players on (once a second) /// and the files that changed. pub(in crate::game) fn tick(game: &mut Game<'_>) { + let Some(dir) = game.native_world_dir() else { + return; + }; let n = TICKS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let players = if n % 20 == 0 { game.native_server_players() } else { Vec::new() }; - with(game, |smp| { - if let Ok(e) = &mut smp.economy { - for p in &players { - e.seen(p.uuid, &p.name); - } + let mut guard = state(dir.join(smp_store::DIR)); + let smp = guard.as_mut().expect("opened"); + if let Ok(e) = &mut smp.economy { + for p in &players { + e.seen(p.uuid, &p.name); } - save(smp); - }); + } + save(smp); } /// The world closed: its files are written and its state let go. diff --git a/native/smp/mixins/smp/horizon-mixin.toml b/native/smp/mixins/smp/horizon-mixin.toml index bb1c183..0672709 100644 --- a/native/smp/mixins/smp/horizon-mixin.toml +++ b/native/smp/mixins/smp/horizon-mixin.toml @@ -1,12 +1,12 @@ [mod] id = "smp" -version = "0.1.0" +version = "0.2.0" priority = 100 -# The commit it was tested on. It uses only the native hook points (hooks 8 in +# The commit it was tested on. It uses only the native hook points (hooks 9 in # compatibility.json): it builds on any Horizon that has them. [compatibility] -horizon_revision = "768d20d9980c2e04cc76951d18ec6c72ccef0176" +horizon_revision = "b070f41ddae470df36694dff7f176bdda2a3ad90" [[module]] id = "store" @@ -20,6 +20,12 @@ file = "crates/horizon-client/src/game.rs" name = "smp_economy" source = "smp_economy.rs" +[[module]] +id = "market" +file = "crates/horizon-client/src/game.rs" +name = "smp_market" +source = "smp_market.rs" + [[module]] id = "glue" file = "crates/horizon-client/src/game.rs" diff --git a/native/smp/mixins/smp/smp_market.rs b/native/smp/mixins/smp/smp_market.rs new file mode 100644 index 0000000..8ea5bf6 --- /dev/null +++ b/native/smp/mixins/smp/smp_market.rs @@ -0,0 +1,518 @@ +// The server market: the server buys and sells vanilla items at the prices +// of `config.json`'s `market`, the economy's way to earn without changing +// survival. `/worth`, `/sell hand|all| [count]`, `/buy [count]`. +// Only plain items (no damage, no name or enchantment) are traded, and only +// by players in survival or adventure mode. Items move first, then money, +// both logged; a sale the log refuses gives the items back. No game types: +// the glue lends the acting player's items as a `Bag`. Added to the game as +// `game::smp_market`; also a module of this crate, for its tests. + +use super::smp_economy::{Economy, MAX_AMOUNT, Out, Player, parse_amount}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// What the server pays for one item (`sell`) and asks for one (`buy`); 0 +/// closes that side. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Price { + pub buy: i64, + pub sell: i64, +} + +/// `config.json`'s `market`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Config { + pub enabled: bool, + /// By item id, `minecraft:diamond`. + pub items: BTreeMap, +} + +impl Default for Config { + /// A small starting list the owner edits: farm and mine produce the + /// server buys, a few things it sells, buying always dearer than selling. + fn default() -> Self { + let list: [(&str, i64, i64); 18] = [ + ("minecraft:wheat", 0, 1), + ("minecraft:carrot", 0, 1), + ("minecraft:potato", 0, 1), + ("minecraft:sugar_cane", 0, 1), + ("minecraft:pumpkin", 0, 3), + ("minecraft:melon_slice", 0, 1), + ("minecraft:cactus", 0, 1), + ("minecraft:oak_log", 4, 1), + ("minecraft:coal", 8, 2), + ("minecraft:iron_ingot", 30, 8), + ("minecraft:gold_ingot", 40, 10), + ("minecraft:redstone", 4, 1), + ("minecraft:lapis_lazuli", 8, 2), + ("minecraft:emerald", 80, 20), + ("minecraft:diamond", 400, 100), + ("minecraft:bread", 5, 0), + ("minecraft:cooked_beef", 6, 0), + ("minecraft:torch", 1, 0), + ]; + Self { + enabled: true, + items: list + .iter() + .map(|(id, buy, sell)| { + ( + (*id).to_owned(), + Price { + buy: *buy, + sell: *sell, + }, + ) + }) + .collect(), + } + } +} + +/// The acting player's items, lent by the game for one command. +pub trait Bag { + /// The main hand: item id, count, plain. + fn held(&self) -> Option<(String, u32, bool)>; + /// Every plain item carried, with how many. + fn carried(&self) -> Vec<(String, u32)>; + fn count(&mut self, item: &str) -> u32; + /// Takes up to `count`; what it took. + fn take(&mut self, item: &str, count: u32) -> u32; + /// How many more fit. + fn room(&mut self, item: &str) -> u32; + /// Gives up to `count` where they fit; what it gave. + fn give(&mut self, item: &str, count: u32) -> u32; +} + +/// `diamond` or `minecraft:diamond` as `minecraft:diamond`. +pub fn item_id(name: &str) -> String { + let name = name.to_ascii_lowercase(); + if name.contains(':') { + name + } else { + format!("minecraft:{name}") + } +} + +/// `minecraft:iron_ingot` as `iron ingot`. +pub fn item_name(id: &str) -> String { + id.strip_prefix("minecraft:") + .unwrap_or(id) + .replace('_', " ") +} + +pub struct Market { + pub config: Config, +} + +impl Market { + /// Runs `/worth`, `/sell` or `/buy`; None for another command. `trades` + /// is false for a player in creative or spectator mode. + pub fn command( + &self, + economy: &mut Economy, + me: &Player, + trades: bool, + bag: &mut dyn Bag, + line: &str, + ) -> Option> { + let words: Vec<&str> = line.split_whitespace().collect(); + let (&name, args) = words.split_first()?; + if !matches!(name, "worth" | "sell" | "buy") { + return None; + } + if !self.config.enabled { + return Some(vec![Out::Error("The market is closed.".into())]); + } + if name == "worth" { + return Some(self.worth(economy, bag, args)); + } + if !trades { + return Some(vec![Out::Error( + "The market trades only with players in survival or adventure mode.".into(), + )]); + } + Some(match name { + "sell" => self.sell(economy, me, bag, args), + _ => self.buy(economy, me, bag, args), + }) + } + + fn price(&self, id: &str) -> Price { + self.config.items.get(id).copied().unwrap_or_default() + } + + fn worth(&self, economy: &Economy, bag: &mut dyn Bag, args: &[&str]) -> Vec { + let id = match args { + [] => match bag.held() { + Some((id, ..)) => id, + None => { + return vec![Out::Error( + "Hold an item, or name one: /worth ".into(), + )]; + } + }, + [item] => item_id(item), + _ => return vec![Out::Error("Usage: /worth [item]".into())], + }; + let p = self.price(&id); + let name = item_name(&id); + let mut parts = Vec::new(); + if p.sell > 0 { + parts.push(format!("the market pays {} each", economy.money(p.sell))); + } + if p.buy > 0 { + parts.push(format!("sells it for {} each", economy.money(p.buy))); + } + if parts.is_empty() { + return vec![Out::Reply(format!("The market doesn't trade {name}."))]; + } + vec![Out::Reply(format!("{name}: {}.", parts.join(", ")))] + } + + fn sell( + &self, + economy: &mut Economy, + me: &Player, + bag: &mut dyn Bag, + args: &[&str], + ) -> Vec { + let usage = || vec![Out::Error("Usage: /sell hand|all| [count]".into())]; + // What to sell: (item, at most how many). + let wanted: Vec<(String, u32)> = match args { + ["all"] => bag + .carried() + .into_iter() + .filter(|(id, _)| self.price(id).sell > 0) + .collect(), + ["hand", rest @ ..] => { + let Some((id, count, plain)) = bag.held() else { + return vec![Out::Error("Your hand is empty.".into())]; + }; + if !plain { + return vec![Out::Error( + "The market only takes plain items (not worn, named or enchanted).".into(), + )]; + } + match rest { + [] => vec![(id, count)], + [n] => match parse_count(n) { + Some(n) => vec![(id, n.min(count))], + None => return usage(), + }, + _ => return usage(), + } + } + [item] => vec![(item_id(item), u32::MAX)], + [item, n] => match parse_count(n) { + Some(n) => vec![(item_id(item), n)], + None => return usage(), + }, + _ => return usage(), + }; + if wanted.is_empty() { + return vec![Out::Error("You carry nothing the market buys.".into())]; + } + let mut out = Vec::new(); + let mut total = 0i64; + for (id, at_most) in wanted { + let price = self.price(&id).sell; + let name = item_name(&id); + if price <= 0 { + out.push(Out::Error(format!("The market doesn't buy {name}."))); + continue; + } + // Never more than the account can be paid for at once. + let cap = (MAX_AMOUNT / price).min(u32::MAX as i64) as u32; + let have = bag.count(&id).min(at_most).min(cap); + if have == 0 { + out.push(Out::Error(format!("You have no plain {name}."))); + continue; + } + let taken = bag.take(&id, have); + let pay = price * i64::from(taken); + let kind = format!("sell {taken} {id}"); + match economy.adjust(&kind, me.uuid, pay, "") { + Ok(_) => { + total += pay; + out.push(Out::Reply(format!( + "Sold {taken} {name} for {}.", + economy.money(pay) + ))); + } + Err(e) => { + let back = bag.give(&id, taken); + out.push(Out::Error(format!( + "The sale failed ({e}); {back} {name} given back." + ))); + } + } + } + if total > 0 { + out.push(Out::Reply(format!( + "Balance: {}", + economy.money(economy.balance(me.uuid)) + ))); + } + out + } + + fn buy( + &self, + economy: &mut Economy, + me: &Player, + bag: &mut dyn Bag, + args: &[&str], + ) -> Vec { + let (item, count) = match args { + [item] => (*item, 1), + [item, n] => match parse_count(n) { + Some(n) => (*item, n), + None => return vec![Out::Error("Usage: /buy [count]".into())], + }, + _ => return vec![Out::Error("Usage: /buy [count]".into())], + }; + let id = item_id(item); + let name = item_name(&id); + let price = self.price(&id).buy; + if price <= 0 { + return vec![Out::Error(format!("The market doesn't sell {name}."))]; + } + if count > 64 * 36 { + return vec![Out::Error("At most a full inventory at once.".into())]; + } + let cost = price * i64::from(count); + if economy.balance(me.uuid) < cost { + return vec![Out::Error(format!( + "{count} {name} cost {}; you have {}.", + economy.money(cost), + economy.money(economy.balance(me.uuid)) + ))]; + } + let room = bag.room(&id); + if room < count { + return vec![Out::Error(format!( + "Only {room} {name} fit in your inventory." + ))]; + } + if let Err(e) = economy.adjust(&format!("buy {count} {id}"), me.uuid, -cost, "") { + return vec![Out::Error(e)]; + } + let given = bag.give(&id, count); + let mut out = vec![Out::Reply(format!( + "Bought {given} {name} for {}.", + economy.money(cost) + ))]; + if given < count { + // Checked above; kept so money never goes for nothing. + let refund = price * i64::from(count - given); + if economy + .adjust( + &format!("refund {} {id}", count - given), + me.uuid, + refund, + "", + ) + .is_ok() + { + out.push(Out::Reply(format!( + "{} refunded for what didn't fit.", + economy.money(refund) + ))); + } + } + out.push(Out::Reply(format!( + "Balance: {}", + economy.money(economy.balance(me.uuid)) + ))); + out + } +} + +fn parse_count(s: &str) -> Option { + parse_amount(s).and_then(|n| u32::try_from(n).ok()) +} + +#[cfg(test)] +mod tests { + use super::super::smp_economy::Config as EconomyConfig; + use super::*; + use std::fs; + + /// A pretend inventory: plain stacks by item, one held item, a room cap. + struct TestBag { + items: BTreeMap, + held: Option<(String, u32, bool)>, + room: u32, + } + + impl Bag for TestBag { + fn held(&self) -> Option<(String, u32, bool)> { + self.held.clone() + } + fn carried(&self) -> Vec<(String, u32)> { + self.items.iter().map(|(k, v)| (k.clone(), *v)).collect() + } + fn count(&mut self, item: &str) -> u32 { + self.items.get(item).copied().unwrap_or(0) + } + fn take(&mut self, item: &str, count: u32) -> u32 { + let have = self.items.entry(item.into()).or_default(); + let n = (*have).min(count); + *have -= n; + n + } + fn room(&mut self, _item: &str) -> u32 { + self.room + } + fn give(&mut self, item: &str, count: u32) -> u32 { + let n = count.min(self.room); + self.room -= n; + *self.items.entry(item.into()).or_default() += n; + n + } + } + + const ALEX: u128 = 1; + + fn setup(name: &str) -> (Economy, Market, Player, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("smp-market-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + let mut e = Economy::open(&dir, EconomyConfig::default()).unwrap(); + e.seen(ALEX, "Alex"); + let me = Player { + uuid: ALEX, + name: "Alex".into(), + level: 0, + }; + ( + e, + Market { + config: Config::default(), + }, + me, + dir, + ) + } + + fn bag(items: &[(&str, u32)], room: u32) -> TestBag { + TestBag { + items: items.iter().map(|(k, v)| ((*k).into(), *v)).collect(), + held: None, + room, + } + } + + #[test] + fn selling_takes_the_items_then_pays_and_logs_both() { + let (mut e, m, me, dir) = setup("sell"); + let mut b = bag( + &[ + ("minecraft:diamond", 3), + ("minecraft:wheat", 20), + ("minecraft:dirt", 64), + ], + 0, + ); + let out = m + .command(&mut e, &me, true, &mut b, "sell diamond 2") + .unwrap(); + assert_eq!(out[0], Out::Reply("Sold 2 diamond for 200 coins.".into())); + assert_eq!(b.items["minecraft:diamond"], 1); + let out = m.command(&mut e, &me, true, &mut b, "sell all").unwrap(); + assert!( + out.contains(&Out::Reply("Sold 1 diamond for 100 coins.".into())), + "{out:?}" + ); + assert!( + out.contains(&Out::Reply("Sold 20 wheat for 20 coins.".into())), + "{out:?}" + ); + assert_eq!(b.items["minecraft:dirt"], 64, "dirt has no price"); + assert_eq!(e.balance(ALEX), 320); + let log = fs::read_to_string(dir.join("audit.log")).unwrap(); + assert_eq!(log.matches("\"sell ").count(), 3, "{log}"); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn selling_the_hand_takes_only_plain_items() { + let (mut e, m, me, dir) = setup("hand"); + let mut b = bag(&[("minecraft:iron_ingot", 10)], 0); + b.held = Some(("minecraft:iron_ingot".into(), 10, true)); + let out = m.command(&mut e, &me, true, &mut b, "sell hand 4").unwrap(); + assert_eq!(out[0], Out::Reply("Sold 4 iron ingot for 32 coins.".into())); + b.held = Some(("minecraft:diamond_sword".into(), 1, false)); + let out = m.command(&mut e, &me, true, &mut b, "sell hand").unwrap(); + assert_eq!( + out, + [Out::Error( + "The market only takes plain items (not worn, named or enchanted).".into() + )] + ); + b.held = None; + let out = m.command(&mut e, &me, true, &mut b, "sell hand").unwrap(); + assert_eq!(out, [Out::Error("Your hand is empty.".into())]); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn buying_checks_money_and_room_before_anything_moves() { + let (mut e, m, me, dir) = setup("buy"); + e.adjust("test", ALEX, 1000, "").unwrap(); + let mut b = bag(&[], 3); + let out = m + .command(&mut e, &me, true, &mut b, "buy diamond 3") + .unwrap(); + assert_eq!( + out, + [Out::Error( + "3 diamond cost 1,200 coins; you have 1,000 coins.".into() + )] + ); + let out = m.command(&mut e, &me, true, &mut b, "buy torch 4").unwrap(); + assert_eq!( + out, + [Out::Error("Only 3 torch fit in your inventory.".into())] + ); + assert_eq!(e.balance(ALEX), 1000); + let out = m + .command(&mut e, &me, true, &mut b, "buy minecraft:diamond 2") + .unwrap(); + assert_eq!(out[0], Out::Reply("Bought 2 diamond for 800 coins.".into())); + assert_eq!((e.balance(ALEX), b.items["minecraft:diamond"]), (200, 2)); + let out = m.command(&mut e, &me, true, &mut b, "buy wheat").unwrap(); + assert_eq!(out, [Out::Error("The market doesn't sell wheat.".into())]); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn creative_players_and_a_closed_market_trade_nothing() { + let (mut e, mut m, me, dir) = setup("closed"); + let mut b = bag(&[("minecraft:diamond", 64)], 64); + let out = m.command(&mut e, &me, false, &mut b, "sell all").unwrap(); + assert_eq!( + out, + [Out::Error( + "The market trades only with players in survival or adventure mode.".into() + )] + ); + let worth = m + .command(&mut e, &me, false, &mut b, "worth diamond") + .unwrap(); + assert_eq!( + worth, + [Out::Reply( + "diamond: the market pays 100 coins each, sells it for 400 coins each.".into() + )] + ); + m.config.enabled = false; + let out = m.command(&mut e, &me, true, &mut b, "sell all").unwrap(); + assert_eq!(out, [Out::Error("The market is closed.".into())]); + assert_eq!((e.balance(ALEX), b.items["minecraft:diamond"]), (0, 64)); + assert!(m.command(&mut e, &me, true, &mut b, "balance").is_none()); + fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/native/smp/proof.py b/native/smp/proof.py index 542e14f..aedaa7b 100644 --- a/native/smp/proof.py +++ b/native/smp/proof.py @@ -5,6 +5,8 @@ Steve, `horizon-ctl --offscreen`) join it. Checked from the clients' own chat an - /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); - 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 , /sell hand, /buy, with the + items gone from or in his inventory as his client sees it; a creative player can't sell; - after the server stops cleanly and starts again, the balances are the same, and audit.log holds every change. Everything runs in fresh temporary folders (the server's, each client's home, launcher and XDG folders), no audio. @@ -144,8 +146,10 @@ def run(ctl, resources, out): steve.join(port) time.sleep(2) data = server.folder / "world" / "horizon-smp" - results["config"] = json.loads((data / "config.json").read_text()) - assert results["config"] == {"currency": "coins", "starting_balance": 0}, results["config"] + config = json.loads((data / "config.json").read_text()) + results["config_keys"] = sorted(config) + assert results["config_keys"] == ["currency", "market", "starting_balance"], config + assert config["market"]["items"]["minecraft:diamond"] == {"buy": 400, "sell": 100}, config server.console("op Alex") time.sleep(1) alex.say("/eco give Alex 500") @@ -164,6 +168,27 @@ def run(ctl, resources, out): 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") + 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 + results["sell_wheat"] = steve.say("/sell wheat") + assert any("Sold 20 wheat for 20 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 + results["buy"] = steve.say("/buy torch 5") + assert any("Bought 5 torch for 5 coins." in l for l in results["buy"]), results + time.sleep(1) + counts = steve.run("inventory").get("counts", {}) + results["steve_items"] = counts + have = lambda item: counts.get(f"minecraft:{item}", counts.get(item, 0)) + assert (have("diamond"), have("wheat"), have("torch")) == (0, 0, 5), counts + server.console("gamemode creative Alex") + time.sleep(1) + results["creative"] = alex.say("/sell all") + assert any("only with players in survival or adventure mode" in l for l in results["creative"]), results steve.quit() alex.quit() lines = server.stop() @@ -171,10 +196,11 @@ def run(ctl, resources, out): economy = json.loads((data / "economy.json").read_text()) balances = {a["name"]: a["balance"] for a in economy["accounts"].values()} results["economy.json"] = balances - assert balances == {"Alex": 380, "Steve": 120}, balances + assert balances == {"Alex": 380, "Steve": 435}, balances 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"], results["audit"] + assert results["audit"] == ["open", "open", "eco_give", "pay", "sell 20 minecraft:wheat", + "sell 3 minecraft:diamond", "buy 5 minecraft:torch"], results["audit"] server.start() alex = Client(ctl, resources, "Alex2", root) alex.name = "Alex" diff --git a/native/smp/src/lib.rs b/native/smp/src/lib.rs index a4ee4a9..40df6cd 100644 --- a/native/smp/src/lib.rs +++ b/native/smp/src/lib.rs @@ -3,5 +3,7 @@ //! `super::smp_store` reaches the same code in both. #[path = "../mixins/smp/smp_economy.rs"] pub mod smp_economy; +#[path = "../mixins/smp/smp_market.rs"] +pub mod smp_market; #[path = "../mixins/smp/smp_store.rs"] pub mod smp_store; From 830bc7884b1f1c04c9768afc364b326f73184a5f Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:54:15 +0100 Subject: [PATCH 10/18] SMP README: players install nothing Co-Authored-By: Claude Opus 5.5 --- native/smp/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/native/smp/README.md b/native/smp/README.md index 442d9c7..31e1401 100644 --- a/native/smp/README.md +++ b/native/smp/README.md @@ -4,6 +4,9 @@ The official Horizon server's survival layer ([design](https://github.com/horizo It is **not** part of Horizon's own server: only a server that builds this mod into its engine has it. It isn't listed in the registry. +Players install nothing: it speaks only what a stock Horizon client already shows (commands with completions, chat +lines; later vanilla chest menus filled by the server, sounds, titles, the action bar, boss bars, the sidebar). + This version: the economy and the server market. | Command | Does | From 5728b12cbb06abc936b7179650960c68cac3aeaa Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:55:20 +0500 Subject: [PATCH 11/18] shulker_peek: host the icon in the repo Co-Authored-By: Claude Sonnet 5.5 --- icons/shulker_peek.png | Bin 0 -> 1367 bytes mods/shulker_peek.toml | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 icons/shulker_peek.png diff --git a/icons/shulker_peek.png b/icons/shulker_peek.png new file mode 100644 index 0000000000000000000000000000000000000000..7f88129631627d8966581b4af38714a92781f91b GIT binary patch literal 1367 zcmV-d1*rOoP)000FZNkldW2M-=Rc<|uCg9i_N;q&=? zKHt}FF)YMcqn>9@nr~qR)oo~XnZP15)o6TmYrl!8! z74(>HZEfA&-qP&{@wU4#XaQB@_V#vbYpZ9Y;jFGkD;H(4qp;D=cqDbrd$H8$NLMWflJF|yf_xJbdS1ilI_B_w?JkN0) zh(ZV*92~guLq4CU1U{ATlgXqT+o#iM*l&>Ep&bH3>2%tS4#{Nllouf zrxsq->vf*zyR$ev1OkD*y}kAIb>Iy$S(fE_zFx1}w%wDgy3aJmSl4w~mLZHkX#Pwl z+9=@i3#w4&*u{aLDMuUWKbP!x7&)MIAkQoL4nQ9 zO;Hpv#)DD;^r7W)`RVD2Zm_ntHZwCL2*T3R((&;z%d#BDy}rJN!{OcCT|x+i#M9GL zxm>0~`X)e(u_%h0o0|>_^grV;aKvIUHkZJX!$!^6Yz@$vind$1fK1j2?8 zvbws;ahzpYJkQ&<{ zlO%~UYq#4&sI!CIhY+e(t8VJZ=;$bAX&8pC>(Fm@p9+ZShldBlFz6PeqoZyrX|-CV zl-w!+BT_z}?{qpaG&$%wH#Z0TSeC^Ymr5lFr|y-OWibp>DwQzC9LK@Iotv9;U`7aG zS+>*ZKt+4^)aE9Q0*|}9J8%M|`9L7hXfz;2(&2^>(r7dSfdFJ!F#p}%okJG-R2p?( z$bj7T_BPZ3FlQtZQB}2jnX0OhNQB-1AcVHJx1F2Q&lWP5tpw=SY&QLVKeaA=9*@U& zc6N4lcH;4PcWcNnjNk8XHk%HWcnGb8`(_TTcye+Aexq&NpIt4yLI|CloH&?&Xw97a zb{eQayn@X*j^n!k{VBYKb2W(lklShZ0}T*5Jw1iG(-*N=?DX{XP1_H1pn2Cw3A8ZA z#bPm$NGvQY$g(WU^1{MGB9SN-ixBaB*?-|k`L3b9hn!&;hG94?_@<%$UDqm5hI@#@ zfBszgeb*`v&;H2g!Gi}69z1yP;Nd@v Ze*km=)zx#2yWs!;002ovPDHLkV1i{#p& Date: Fri, 30 Oct 2026 22:55:28 +0500 Subject: [PATCH 12/18] xtoxray: host the icon in the repo, SPDX licence id Co-Authored-By: Claude Sonnet 5.5 --- icons/xtoxray.png | Bin 0 -> 143533 bytes mods/xtoxray.toml | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 icons/xtoxray.png diff --git a/icons/xtoxray.png b/icons/xtoxray.png new file mode 100644 index 0000000000000000000000000000000000000000..376a80f5a0b004da3fad91af5ab9ecfa61fe0c21 GIT binary patch literal 143533 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
  • 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Ð*rN$VNrVI~3Dspq#&_YF?~e?Wl0 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 YiO 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 Ef+p-bq5uE@ literal 0 HcmV?d00001 diff --git a/mods/xtoxray.toml b/mods/xtoxray.toml index b703346..002d12e 100644 --- a/mods/xtoxray.toml +++ b/mods/xtoxray.toml @@ -14,9 +14,9 @@ Instalation: """ authors = ["1_st_Cat"] owners = ["RealCat1st"] -license = "Apache 2.0" +license = "Apache-2.0" source = "https://github.com/RealCat1st/XToXrayRust" -icon = "https://github.com/RealCat1st/XToXrayRust/blob/main/icon.png" +icon = "icons/xtoxray.png" key = "RWQVF+EFGGnX6eUNms+cP5AceLvx9b0rFYYXCty7so4MVbJ1SNpi9zNK" [[versions]] From 7339aa299266c9a64e9f960fa18dbd8dce7d99c9 Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:55:31 +0500 Subject: [PATCH 13/18] chatheads: add the icon its entry points at Co-Authored-By: Claude Sonnet 5.5 --- icons/chatheads.png | Bin 0 -> 543 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 icons/chatheads.png diff --git a/icons/chatheads.png b/icons/chatheads.png new file mode 100644 index 0000000000000000000000000000000000000000..21867cdc0e95218e44e6ef95f9dffc8c4becb4ed GIT binary patch literal 543 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV0`B3;uumf=j~lxKjuJ*wu_A= zoUzPYW)}pU3^*of1+}^@(28VWiV^M32q=|s;&{oHAbFR$f@R5>x%1Ce_o*7o9-5q= z_g*wa6lf#_7_d~|jx(4!r_S`V#*__l`}4k<-_`%L@{JB-i?G9vz`N3G!u(U`wbak_ z(_d}n(@?;~q25ri^*V$7SwFtjcG3H4%l>lgO=1EnwqR`0-p3{}>*t?suO=UTwC}}V znDT23y8DZNA7cS&W#S00;VmdH4g59DEHXKuFsd15A~fTZpZ!N z_P!>#q^ZN@_a7GS-Ry15c!*PB4g(S)dY|25N8O6HlzmSl53GADJp*JM&`c|P?iag{ z_U*mAJr(LNu;*|R(xrd@zkWFlYGvN~xBPc<%;J6T)!WEz|7ZQyNEp>xG*>{lu;^f5 bNMP8 Date: Fri, 30 Oct 2026 22:55:33 +0500 Subject: [PATCH 14/18] rightharvest: add the icon its entry points at Co-Authored-By: Claude Sonnet 5.5 --- icons/rightharvest.png | Bin 0 -> 644 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 icons/rightharvest.png diff --git a/icons/rightharvest.png b/icons/rightharvest.png new file mode 100644 index 0000000000000000000000000000000000000000..4fc7239e0313e2056295047ca954449a823bdf0c GIT binary patch literal 644 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrVDk2KaSW-L^Y)Hnzp$eO+XLIC z91gBlrx*cIkD%5S4vN2-f3UK&IxYCY>LFrTDAel^s;qV5BzM<>6KveOq&J;S***8& zzVnY~EwkQza(4bQ;O@+y1-#{qM84-xv2;e*bu^Ed7h1MK+Uw zAH$)$x_=M$>+4rNKk)Wj+6CUFz2URDK;oPZ7K|L~3=dfp<}kEa|BO74ReJOH@AlW{ zUB8(d|9^4g3H0+cz_bf?N+2oBW>r$A|P!{Q;%E3!pB~`}CLL{5_rO_Tw|=kuTo}5g{NLwi%H|b3$8xje#vrw3GasopYBaKZSH)X;eVz50fk?T7FxlekPNStZ!kjUC9H7%%MgG6 zz234d=J&zopr08g?DO8@`> literal 0 HcmV?d00001 From 1d4cb83ca2471afe72eb7022c79e67566d3d9f5b Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:08:45 +0100 Subject: [PATCH 15/18] SMP: player trades (/trade) on server menus Co-Authored-By: Claude Opus 5.5 --- native/smp/Cargo.lock | 2 +- native/smp/Cargo.toml | 2 +- native/smp/README.md | 30 +- native/smp/compatibility.json | 4 +- native/smp/mixins/smp/glue.rs | 293 ++++++++++- native/smp/mixins/smp/horizon-mixin.toml | 36 +- native/smp/mixins/smp/menu_clicked.rs | 4 + native/smp/mixins/smp/menu_closed.rs | 4 + native/smp/mixins/smp/smp_economy.rs | 7 + native/smp/mixins/smp/smp_trade.rs | 609 +++++++++++++++++++++++ native/smp/proof.py | 130 ++++- native/smp/src/lib.rs | 2 + 12 files changed, 1100 insertions(+), 23 deletions(-) create mode 100644 native/smp/mixins/smp/menu_clicked.rs create mode 100644 native/smp/mixins/smp/menu_closed.rs create mode 100644 native/smp/mixins/smp/smp_trade.rs diff --git a/native/smp/Cargo.lock b/native/smp/Cargo.lock index ebc8d3c..8c4498b 100644 --- a/native/smp/Cargo.lock +++ b/native/smp/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "horizon-mod-smp" -version = "0.2.0" +version = "0.3.0" dependencies = [ "serde", "serde_json", diff --git a/native/smp/Cargo.toml b/native/smp/Cargo.toml index a8fbe8d..fdada82 100644 --- a/native/smp/Cargo.toml +++ b/native/smp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "horizon-mod-smp" -version = "0.2.0" +version = "0.3.0" edition = "2024" license = "MIT" publish = false diff --git a/native/smp/README.md b/native/smp/README.md index 31e1401..c3f5064 100644 --- a/native/smp/README.md +++ b/native/smp/README.md @@ -5,9 +5,10 @@ It is **not** part of Horizon's own server: only a server that builds this mod i in the registry. Players install nothing: it speaks only what a stock Horizon client already shows (commands with completions, chat -lines; later vanilla chest menus filled by the server, sounds, titles, the action bar, boss bars, the sidebar). +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 and the server market. +This version: the economy, the server market and player trades. | Command | Does | |---|---| @@ -18,11 +19,25 @@ This version: the economy and the server market. | `/worth [item]` | what the market pays and asks for an item (the one in your hand without a name) | | `/sell hand [count]`, `/sell [count]`, `/sell all` | sells to the market: the stack in your hand, an item, or everything it buys | | `/buy [count]` | buys from the market, only what fits in your inventory | +| `/trade ` | asks a player to trade (60 s to answer) | +| `/trade accept [player]`, `/trade deny [player]` | answers a request | +| `/trade money ` | the money you add to your offer (0: none) | +| `/trade cancel` | ends your trade, or withdraws your request | 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. +**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 +moves nothing: offered stacks stay in their owners' inventories, the menu shows copies, and a stack that changes +(used, dropped, grown by a pickup) leaves the offer. Any change clears both confirmations. When both have confirmed, +the server checks that each still holds exactly what they offer, has room for what they get and has the money, logs +the trade, and swaps everything in one step, or nothing (and both must confirm again). Closing the menu, +`/trade cancel`, leaving, dying or the other player leaving ends the trade with nothing moved. Survival and +adventure players only. + The currency is earned in game only: it can't be bought with real money or cashed out (Mojang's usage guidelines). ## Data @@ -34,7 +49,7 @@ folder is untouched until the mod first runs on it; it changes no block and no p |---|---| | `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 | -| `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 | +| `audit.log` | one JSON line per change, written before the change: `time` (ms), `kind` (`open`, `pay`, `eco_give`...), `amount`, `from`/`to` with their balance before, `by` for an operator; a trade's line (`trade`: both players, their items and money) comes before its swap, `trade_failed` after one that didn't go through | `economy.json` is written whole to a temporary file, flushed and renamed over the old one, at most once a server tick 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 @@ -43,14 +58,15 @@ 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 tick and the world lifecycle. `compatibility.json` names `"hooks": 9`. +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`. | File | What it is | |---|---| -| `mixins/smp/horizon-mixin.toml` | The bundle: four modules and the HEAD hooks | -| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs` | The rules, no game types: added to the game as `game::smp_store`, `game::smp_economy`, `game::smp_market`; also this crate's modules (`src/lib.rs`), for their tests | +| `mixins/smp/horizon-mixin.toml` | The bundle: five modules and the HEAD hooks | +| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs`, `smp_trade.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`; 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}.rs` | One small helper per hook | +| `mixins/smp/{register,command,tick,lifecycle,menu_clicked,menu_closed}.rs` | One small helper per hook | | `proof.py` | A dedicated server built with the mod and two real clients, on fresh temporary data | ## Build and prove diff --git a/native/smp/compatibility.json b/native/smp/compatibility.json index 3b5e4da..fa029fb 100644 --- a/native/smp/compatibility.json +++ b/native/smp/compatibility.json @@ -1,8 +1,8 @@ { "game": "26.3", - "hooks": 9, + "hooks": 10, "horizon_repository": "https://github.com/horizon-rust/horizon", - "horizon_revision": "b070f41ddae470df36694dff7f176bdda2a3ad90", + "horizon_revision": "e23b4b7d8ff3318da3f96f9e0f11ba961ffefe99", "horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins", "horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06" } diff --git a/native/smp/mixins/smp/glue.rs b/native/smp/mixins/smp/glue.rs index aef2ccd..50334ac 100644 --- a/native/smp/mixins/smp/glue.rs +++ b/native/smp/mixins/smp/glue.rs @@ -1,22 +1,30 @@ // The SMP in the server's game: its state for the open world, and the edges -// between the game's types and the rules (smp_economy, smp_market). Added to +// between the game's types and the rules (smp_economy, smp_market, +// smp_trade). Added to // the game as `game::smp_glue`; the helpers (register.rs, command.rs, -// tick.rs, lifecycle.rs) call it from the hook points. +// tick.rs, lifecycle.rs, menu_clicked.rs, menu_closed.rs) call it from the +// hook points. use crate::game::{ Game, native_server::{NativeCommand, NativeServerPlayer}, smp_economy::{self, Economy, Out, Player}, + native_menus::native_menu_inventory_slot, smp_market::{self, Bag, Market}, smp_store, + smp_trade::{self, Act, Cell, Trades}, +}; +use horizon_client::{ + items::ItemStack, + lan::data::component_nbt, + text::{self, Text}, }; -use horizon_client::text::{self, Text}; use std::{path::PathBuf, sync::Mutex}; /// The mod's id, name and version. pub const ID: &str = "smp"; pub const NAME: &str = "SMP"; -pub const VERSION: &str = "0.2.0"; +pub const VERSION: &str = "0.3.0"; /// `config.json` in `world/horizon-smp/`, written with its defaults the /// first time, and again with the keys a newer version adds (values kept). @@ -34,6 +42,7 @@ pub struct Smp { /// An error when `economy.json` can't be read: the commands say so. economy: Result, market: Market, + trades: Trades, } static STATE: Mutex> = Mutex::new(None); @@ -80,6 +89,7 @@ fn open(dir: PathBuf) -> Smp { market: Market { config: config.market, }, + trades: Trades::default(), } } @@ -119,6 +129,11 @@ pub fn commands(commands: &mut Vec) { "sell all", "sell [count:integer]", "buy [count:integer]", + "trade ", + "trade accept [player:word]", + "trade deny [player:word]", + "trade cancel", + "trade money ", ] { commands.push(NativeCommand::new(usage)); } @@ -154,8 +169,8 @@ impl Bag for GameBag<'_, '_> { } } -const COMMANDS: [&str; 8] = [ - "balance", "bal", "pay", "baltop", "eco", "worth", "sell", "buy", +const COMMANDS: [&str; 9] = [ + "balance", "bal", "pay", "baltop", "eco", "worth", "sell", "buy", "trade", ]; /// A player's command. @@ -178,25 +193,259 @@ pub(in crate::game) fn perform( }; // Survival and adventure: a creative player could sell what they made. let trades = matches!(player.game_mode, 0 | 2); + let players = game.native_server_players(); + let online = |name: &str| { + players + .iter() + .find(|p| p.name.eq_ignore_ascii_case(name)) + .map(|p| (p.uuid, p.name.clone(), matches!(p.game_mode, 0 | 2))) + }; + let now = chrono::Utc::now().timestamp_millis(); let mut guard = state(dir.join(smp_store::DIR)); let smp = guard.as_mut().expect("opened"); + let mut acts = Vec::new(); let out = match &mut smp.economy { Ok(e) => { e.seen(me.uuid, &me.name); let mut bag = GameBag(game); e.command(&me, line) .or_else(|| smp.market.command(e, &me, trades, &mut bag, line)) + .or_else(|| { + let (out, a) = smp.trades.command(e, &me, trades, &online, now, line)?; + acts = a; + Some(out) + }) .unwrap_or_default() } Err(_) => vec![Out::Error( "The economy is off: its file can't be read (see the server log).".into(), )], }; + act(game, smp, acts); drop(guard); *ran = true; show(game, out); } +/// An item named `name` (not in italics, as a name the game gives). +fn button(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 +} + +/// The trade menu's cells as items. +fn menu_items(smp: &Smp, uuid: u128) -> Vec> { + let money = |n| match &smp.economy { + Ok(e) => e.money(n), + Err(_) => n.to_string(), + }; + smp.trades + .layout(uuid) + .into_iter() + .map(|cell| match cell { + Cell::Empty => None, + Cell::Stack(s) => Some(s), + Cell::Divider => Some(button("minecraft:gray_stained_glass_pane", " ", "gray")), + Cell::Money(true, n) => Some(button( + "minecraft:gold_ingot", + &format!("Your money: {} (/trade money )", money(n)), + "gold", + )), + Cell::Money(false, n) => Some(button( + "minecraft:gold_ingot", + &format!("Their money: {}", money(n)), + "gold", + )), + Cell::Confirm(false) => Some(button( + "minecraft:lime_stained_glass_pane", + "Confirm the trade", + "green", + )), + Cell::Confirm(true) => Some(button( + "minecraft:lime_concrete", + "Confirmed (click to take it back)", + "green", + )), + Cell::Cancel => Some(button( + "minecraft:red_stained_glass_pane", + "Cancel the trade", + "red", + )), + Cell::Other(name, true) => Some(button( + "minecraft:lime_concrete", + &format!("{name} confirmed"), + "green", + )), + Cell::Other(name, false) => Some(button( + "minecraft:gray_concrete", + &format!("{name} has not confirmed"), + "gray", + )), + }) + .collect() +} + +/// Carries out what the trade rules ask. +fn act(game: &mut Game<'_>, smp: &mut Smp, acts: Vec) { + for a in acts { + match a { + Act::Show(uuid) => { + let items = menu_items(smp, uuid); + let Some(t) = smp.trades.open.iter_mut().find(|t| t.side_of(uuid).is_some()) else { + continue; + }; + let me = t.side_of(uuid).expect("found"); + if t.sides[me].window.is_some() { + game.native_set_menu_items(uuid, items); + } else { + let title = Text::literal(format!("Trade with {}", t.sides[1 - me].name)); + t.sides[me].window = game.native_open_menu(uuid, smp_trade::ROWS, title, items); + } + } + Act::Close(uuid) => game.native_close_menu(uuid), + Act::Settle(uuid) => settle(game, smp, uuid), + } + } +} + +/// Both confirmed: the trade is logged, then both offers swap in one step +/// with the money, or nothing moves and both are told why. +fn settle(game: &mut Game<'_>, smp: &mut Smp, uuid: u128) { + let Some(t) = smp.trades.trade_of(uuid).cloned() else { + return; + }; + let [a, b] = &t.sides; + let players = game.native_server_players(); + let may = |u: u128| players.iter().any(|p| p.uuid == u && matches!(p.game_mode, 0 | 2)); + let Ok(economy) = &mut smp.economy else { + return; + }; + let fail = |why: String| -> Vec { + t.sides + .iter() + .map(|s| Out::Tell(s.uuid, format!("The trade didn't go through: {why}."))) + .collect() + }; + let stacks = |side: &smp_trade::Side| -> serde_json::Value { + side.offer + .iter() + .map(|(_, s)| serde_json::json!({ "item": &*s.item, "count": s.count, "plain": s.damage == 0 && s.extra.is_none() })) + .collect() + }; + let out = if !may(a.uuid) || !may(b.uuid) { + fail("both must be here, in survival or adventure mode".into()) + } else if economy.balance(a.uuid) < a.money || economy.balance(b.uuid) < b.money { + fail("one of you no longer has the money offered".into()) + } else if let Err(e) = economy.record(serde_json::json!({ + "kind": "trade", + "a": smp_store::uuid_string(a.uuid), "a_items": stacks(a), "a_money": a.money, + "b": smp_store::uuid_string(b.uuid), "b_items": stacks(b), "b_money": b.money, + })) { + fail(e) + } else if let Err(e) = game.native_exchange_items(a.uuid, &a.offer, b.uuid, &b.offer) { + let _ = economy.record(serde_json::json!({ "kind": "trade_failed", "why": &e })); + fail(e) + } else { + // The balances were checked above: only the log could refuse now. + let net = a.money - b.money; + let paid = match net.signum() { + 1 => economy.transfer("trade_money", a.uuid, b.uuid, net), + -1 => economy.transfer("trade_money", b.uuid, a.uuid, -net), + _ => Ok(()), + }; + if let Err(e) = paid { + log::error!("[smp] a trade's money: {e}"); + } + let (_, acts) = smp.trades.end(uuid, ""); + // Ended as a success: the menus close, with their own line. + for act in acts { + if let Act::Close(u) = act { + game.native_close_menu(u); + } + } + let line = |me: &smp_trade::Side, them: &smp_trade::Side| { + Out::Tell( + me.uuid, + format!("Trade with {} done: you gave {} stack(s), got {}.", them.name, me.offer.len(), them.offer.len()), + ) + }; + show(game, vec![line(a, b), line(b, a)]); + return; + }; + // Not settled: both must confirm again. + if let Some(t) = smp.trades.open.iter_mut().find(|t| t.side_of(uuid).is_some()) { + for s in &mut t.sides { + s.confirmed = false; + } + } + act(game, smp, vec![Act::Show(a.uuid), Act::Show(b.uuid)]); + show(game, out); +} + +/// A click in a native menu: a trade's, when it is one. +pub(in crate::game) fn menu_clicked(game: &mut Game<'_>, uuid: u128, window: i32, slot: i32, mode: i32) { + // A plain click or a shift-click; drags and double clicks are ignored. + if !game.native_on(ID) || !matches!(mode, 0 | 1) { + return; + } + let Some(dir) = game.native_world_dir() else { + return; + }; + let mut guard = state(dir.join(smp_store::DIR)); + let smp = guard.as_mut().expect("opened"); + let Some(t) = smp.trades.trade_of(uuid) else { + return; + }; + let me = t.side_of(uuid).expect("found"); + if t.sides[me].window != Some(window) { + return; + } + let (out, acts) = match native_menu_inventory_slot(smp_trade::SIZE, slot) { + Some(i) => match game.native_player_stacks(uuid).and_then(|s| s.get(i).cloned().flatten()) { + Some(stack) => smp.trades.click(uuid, None, Some((i, stack))), + None => (Vec::new(), Vec::new()), + }, + None => match usize::try_from(slot).ok().filter(|&s| s < smp_trade::SIZE) { + Some(s) => smp.trades.click(uuid, Some(s), None), + None => (Vec::new(), Vec::new()), + }, + }; + act(game, smp, acts); + drop(guard); + show(game, out); +} + +/// A native menu closed: a trade's ends, nothing moved. +pub(in crate::game) fn menu_closed(game: &mut Game<'_>, uuid: u128, window: i32) { + if !game.native_on(ID) { + return; + } + let Some(dir) = game.native_world_dir() else { + return; + }; + let mut guard = state(dir.join(smp_store::DIR)); + let smp = guard.as_mut().expect("opened"); + let Some(t) = smp.trades.trade_of(uuid) else { + return; + }; + let me = t.side_of(uuid).expect("found"); + if t.sides[me].window != Some(window) { + return; + } + let why = format!("{} closed the trade", t.sides[me].name); + let (out, acts) = smp.trades.end(uuid, &why); + act(game, smp, acts); + drop(guard); + show(game, out); +} + fn show(game: &mut Game<'_>, out: Vec) { for o in out { match o { @@ -228,7 +477,39 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) { e.seen(p.uuid, &p.name); } } + // Each trade's offers against the inventories now: a player gone or + // dead ends it; a stack that changed leaves its offer. + let sides: Vec<(u128, String)> = smp + .trades + .open + .iter() + .flat_map(|t| t.sides.iter().map(|s| (s.uuid, s.name.clone()))) + .collect(); + let mut out = Vec::new(); + for (uuid, name) in sides { + match game.native_player_stacks(uuid) { + None => { + let (o, acts) = smp.trades.end(uuid, &format!("{name} is gone")); + out.extend(o); + act(game, smp, acts); + } + Some(stacks) if smp.trades.refresh(uuid, &stacks) => { + let other = smp.trades.trade_of(uuid).and_then(|t| { + t.sides.iter().find(|s| s.uuid != uuid).map(|s| s.uuid) + }); + act(game, smp, vec![Act::Show(uuid)]); + if let Some(o) = other { + act(game, smp, vec![Act::Show(o)]); + out.push(Out::Tell(o, format!("{name}'s offer changed."))); + } + out.push(Out::Tell(uuid, "A stack you offered changed: it left your offer.".into())); + } + Some(_) => {} + } + } save(smp); + drop(guard); + show(game, out); } /// The world closed: its files are written and its state let go. diff --git a/native/smp/mixins/smp/horizon-mixin.toml b/native/smp/mixins/smp/horizon-mixin.toml index 0672709..ce5811f 100644 --- a/native/smp/mixins/smp/horizon-mixin.toml +++ b/native/smp/mixins/smp/horizon-mixin.toml @@ -1,12 +1,12 @@ [mod] id = "smp" -version = "0.2.0" +version = "0.3.0" priority = 100 -# The commit it was tested on. It uses only the native hook points (hooks 9 in +# The commit it was tested on. It uses only the native hook points (hooks 10 in # compatibility.json): it builds on any Horizon that has them. [compatibility] -horizon_revision = "b070f41ddae470df36694dff7f176bdda2a3ad90" +horizon_revision = "e23b4b7d8ff3318da3f96f9e0f11ba961ffefe99" [[module]] id = "store" @@ -26,6 +26,12 @@ file = "crates/horizon-client/src/game.rs" name = "smp_market" source = "smp_market.rs" +[[module]] +id = "trade" +file = "crates/horizon-client/src/game.rs" +name = "smp_trade" +source = "smp_trade.rs" + [[module]] id = "glue" file = "crates/horizon-client/src/game.rs" @@ -71,3 +77,27 @@ helper = "lifecycle.rs" bindings = [ { name = "phase", ty = "crate::game::native_lifecycle::WorldPhase", value = "phase" }, ] + +[[injection]] +id = "menu_clicked" +kind = "HEAD" +target = "horizon_client::game::native_hooks::Game<'_>::native_menu_clicked" +helper = "menu_clicked.rs" +bindings = [ + { name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" }, + { name = "uuid", ty = "u128", value = "uuid" }, + { name = "window", ty = "i32", value = "window" }, + { name = "slot", ty = "i32", value = "slot" }, + { name = "mode", ty = "i32", value = "mode" }, +] + +[[injection]] +id = "menu_closed" +kind = "HEAD" +target = "horizon_client::game::native_hooks::Game<'_>::native_menu_closed" +helper = "menu_closed.rs" +bindings = [ + { name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" }, + { name = "uuid", ty = "u128", value = "uuid" }, + { name = "window", ty = "i32", value = "window" }, +] diff --git a/native/smp/mixins/smp/menu_clicked.rs b/native/smp/mixins/smp/menu_clicked.rs new file mode 100644 index 0000000..98c2284 --- /dev/null +++ b/native/smp/mixins/smp/menu_clicked.rs @@ -0,0 +1,4 @@ +// 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); +} diff --git a/native/smp/mixins/smp/menu_closed.rs b/native/smp/mixins/smp/menu_closed.rs new file mode 100644 index 0000000..cb79286 --- /dev/null +++ b/native/smp/mixins/smp/menu_closed.rs @@ -0,0 +1,4 @@ +// 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); +} diff --git a/native/smp/mixins/smp/smp_economy.rs b/native/smp/mixins/smp/smp_economy.rs index b007f77..76d1c3c 100644 --- a/native/smp/mixins/smp/smp_economy.rs +++ b/native/smp/mixins/smp/smp_economy.rs @@ -267,6 +267,13 @@ impl Economy { Ok(after) } + /// 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> { + entry["time"] = self.now.into(); + self.audit.append(&entry) + } + /// The accounts, richest first (then by name). pub fn top(&self) -> Vec<(&str, i64)> { let mut all: Vec<_> = self diff --git a/native/smp/mixins/smp/smp_trade.rs b/native/smp/mixins/smp/smp_trade.rs new file mode 100644 index 0000000..d8f2111 --- /dev/null +++ b/native/smp/mixins/smp/smp_trade.rs @@ -0,0 +1,609 @@ +// Player trades (`/trade`): the requests, both sides' offers and +// confirmations, and the trade menu's layout, with no game types (`S` is the +// game's item stack). The menu is display only and the offered stacks stay +// in their owners' inventories: the glue swaps them in one checked step once +// both have confirmed (`native_exchange_items`), so every way out of a trade +// (closing, `/trade cancel`, leaving, dying) moves nothing. Added to the game +// as `game::smp_trade`; also a module of this crate, for its tests. + +use super::smp_economy::{Economy, MAX_AMOUNT, Out, Player, parse_amount}; + +/// How long a request waits for an answer. +pub const REQUEST_MS: i64 = 60_000; +/// Stacks one side may offer (the menu's 4 by 4 squares). +pub const OFFER_SLOTS: usize = 16; +/// The menu's rows: a 6-row chest. +pub const ROWS: u8 = 6; +pub const SIZE: usize = ROWS as usize * 9; +/// The own side's buttons, and the other side's status. +pub const MONEY: usize = 37; +pub const OTHER_MONEY: usize = 43; +pub const CONFIRM: usize = 45; +pub const CANCEL: usize = 48; +pub const OTHER_STATUS: usize = 52; + +/// The menu slot of the `i`th square of an offer: the own side on the left +/// (columns 0 to 3), the other's on the right (5 to 8), four rows. +pub fn offer_slot(i: usize, own: bool) -> usize { + (i / 4) * 9 + i % 4 + if own { 0 } else { 5 } +} + +/// Which square of the own offer a menu slot is. +fn own_square(slot: usize) -> Option { + let (row, col) = (slot / 9, slot % 9); + (row < 4 && col < 4).then_some(row * 4 + col) +} + +/// One side of a trade. +#[derive(Clone, Debug, PartialEq)] +pub struct Side { + pub uuid: u128, + pub name: String, + /// Inventory slots offered, with the stack each held when offered: the + /// stack it must still hold at the swap. + pub offer: Vec<(usize, S)>, + pub money: i64, + pub confirmed: bool, + /// The menu's window, once open. + pub window: Option, +} + +impl Side { + fn new(uuid: u128, name: &str) -> Self { + Self { + uuid, + name: name.to_owned(), + offer: Vec::new(), + money: 0, + confirmed: false, + window: None, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Trade { + pub sides: [Side; 2], +} + +impl Trade { + /// The side of `uuid` (0 or 1). + pub fn side_of(&self, uuid: u128) -> Option { + self.sides.iter().position(|s| s.uuid == uuid) + } + fn unconfirm(&mut self) { + for s in &mut self.sides { + s.confirmed = false; + } + } +} + +struct Request { + from: u128, + from_name: String, + to: u128, + at: i64, +} + +/// Finds a player who is on by name: their uuid, name, and whether they +/// may trade (survival or adventure). +pub type Online<'a> = dyn Fn(&str) -> Option<(u128, String, bool)> + 'a; + +/// What the glue does for the trades. +#[derive(Clone, Debug, PartialEq)] +pub enum Act { + /// Open (or show again) the trade menu of the trade `uuid` is in. + Show(u128), + /// Close the player's trade menu (their trade ended). + Close(u128), + /// Both confirmed: swap the trade `uuid` is in. + Settle(u128), +} + +/// What a menu cell shows; the glue makes the item. +#[derive(Clone, Debug, PartialEq)] +pub enum Cell { + Empty, + Stack(S), + Divider, + /// A side's money: whether it is the viewer's, the amount. + Money(bool, i64), + /// The viewer's confirm button: confirmed or not. + Confirm(bool), + Cancel, + /// The other side: their name, confirmed or not. + Other(String, bool), +} + +/// The open trades and the requests waiting. +pub struct Trades { + requests: Vec, + pub open: Vec>, +} + +impl Default for Trades { + fn default() -> Self { + Self { + requests: Vec::new(), + open: Vec::new(), + } + } +} + +impl Trades { + pub fn trade_of(&self, uuid: u128) -> Option<&Trade> { + self.open.iter().find(|t| t.side_of(uuid).is_some()) + } + fn index_of(&self, uuid: u128) -> Option { + self.open.iter().position(|t| t.side_of(uuid).is_some()) + } + + /// Ends the trade `uuid` is in, nothing moved: its players' menus close + /// and both are told why. + pub fn end(&mut self, uuid: u128, why: &str) -> (Vec, Vec) { + let Some(i) = self.index_of(uuid) else { + return (Vec::new(), Vec::new()); + }; + let t = self.open.remove(i); + let mut out = Vec::new(); + let mut acts = Vec::new(); + for s in &t.sides { + out.push(Out::Tell( + s.uuid, + format!("Trade cancelled: {why}. Nothing was exchanged."), + )); + acts.push(Act::Close(s.uuid)); + } + (out, acts) + } + + /// `/trade `, `/trade accept|deny [player]`, `/trade cancel`, + /// `/trade money `; None for another command. `online` finds a + /// player who is on by name (uuid, name, may trade). + pub fn command( + &mut self, + economy: &Economy, + me: &Player, + may_trade: bool, + online: &Online, + now: i64, + line: &str, + ) -> Option<(Vec, Vec)> { + let mut words = line.split_whitespace(); + if words.next()? != "trade" { + return None; + } + self.requests.retain(|r| now - r.at < REQUEST_MS); + let err = |s: &str| Some((vec![Out::Error(s.to_owned())], Vec::new())); + let usage = "Usage: /trade , /trade accept|deny [player], /trade cancel, /trade money "; + let arg = words.next(); + let rest = words.next(); + match arg { + None => err(usage), + Some("cancel") => { + if self.index_of(me.uuid).is_some() { + return Some(self.end(me.uuid, &format!("{} cancelled", me.name))); + } + let before = self.requests.len(); + self.requests.retain(|r| r.from != me.uuid); + if self.requests.len() < before { + Some(( + vec![Out::Reply("Your trade request is withdrawn.".into())], + Vec::new(), + )) + } else { + err("You have no trade to cancel.") + } + } + Some("money") => { + let Some(i) = self.index_of(me.uuid) else { + return err("You are not in a trade."); + }; + let Some(amount) = rest + .and_then(parse_amount) + .or(rest.filter(|r| *r == "0").map(|_| 0)) + else { + return err("Usage: /trade money (0 to offer none)"); + }; + if amount > MAX_AMOUNT || amount > economy.balance(me.uuid) { + return err(&format!( + "You only have {}.", + economy.money(economy.balance(me.uuid)) + )); + } + let t = &mut self.open[i]; + let side = t.side_of(me.uuid).expect("found"); + t.sides[side].money = amount; + t.unconfirm(); + let other = t.sides[1 - side].uuid; + Some(( + vec![ + Out::Reply(format!("You offer {}.", economy.money(amount))), + Out::Tell( + other, + format!("{} offers {}.", me.name, economy.money(amount)), + ), + ], + vec![Act::Show(me.uuid), Act::Show(other)], + )) + } + Some(verb @ ("accept" | "deny")) => { + let pick = |r: &&Request| { + r.to == me.uuid && rest.is_none_or(|n| r.from_name.eq_ignore_ascii_case(n)) + }; + let Some(r) = self.requests.iter().rfind(pick) else { + return err("You have no trade request to answer."); + }; + let (from, from_name) = (r.from, r.from_name.clone()); + self.requests + .retain(|r| !(r.from == from && r.to == me.uuid)); + if verb == "deny" { + return Some(( + vec![ + Out::Reply(format!("You refused {from_name}'s trade.")), + Out::Tell(from, format!("{} refused your trade.", me.name)), + ], + Vec::new(), + )); + } + if !may_trade { + return err("You can trade only in survival or adventure mode."); + } + let Some((_, _, they_may)) = online(&from_name).filter(|(u, _, _)| *u == from) + else { + return err(&format!("{from_name} is no longer here.")); + }; + if !they_may { + return err(&format!("{from_name} can't trade in their game mode.")); + } + if self.index_of(me.uuid).is_some() || self.index_of(from).is_some() { + return err("One of you is already in a trade."); + } + self.requests + .retain(|r| r.from != from && r.from != me.uuid); + self.open.push(Trade { + sides: [Side::new(from, &from_name), Side::new(me.uuid, &me.name)], + }); + Some((Vec::new(), vec![Act::Show(from), Act::Show(me.uuid)])) + } + Some(name) => { + if rest.is_some() { + return err(usage); + } + if !may_trade { + return err("You can trade only in survival or adventure mode."); + } + let Some((to, to_name, they_may)) = online(name) else { + return err(&format!("{name} is not here.")); + }; + if to == me.uuid { + return err("You can't trade with yourself."); + } + if !they_may { + return err(&format!("{to_name} can't trade in their game mode.")); + } + if self.index_of(me.uuid).is_some() || self.index_of(to).is_some() { + return err("One of you is already in a trade."); + } + // A request back is an answer. + if self + .requests + .iter() + .any(|r| r.from == to && r.to == me.uuid) + { + return self.command( + economy, + me, + may_trade, + online, + now, + &format!("trade accept {to_name}"), + ); + } + self.requests.retain(|r| r.from != me.uuid); + self.requests.push(Request { + from: me.uuid, + from_name: me.name.clone(), + to, + at: now, + }); + Some(( + vec![ + Out::Reply(format!("Trade request sent to {to_name}.")), + Out::Tell( + to, + format!( + "{} wants to trade with you: /trade accept {} or /trade deny {} (60 s).", + me.name, me.name, me.name + ), + ), + ], + Vec::new(), + )) + } + } + } + + /// A click in `uuid`'s trade menu: on their inventory's slot `inventory` + /// holding `stack` (offered whole), or on the menu's slot `menu`. + pub fn click( + &mut self, + uuid: u128, + menu: Option, + inventory: Option<(usize, S)>, + ) -> (Vec, Vec) { + let Some(i) = self.index_of(uuid) else { + return (Vec::new(), Vec::new()); + }; + let t = &mut self.open[i]; + let me = t.side_of(uuid).expect("found"); + let other = t.sides[1 - me].uuid; + let shown = vec![Act::Show(uuid), Act::Show(other)]; + if let Some((slot, stack)) = inventory { + let side = &mut t.sides[me]; + if side.offer.iter().any(|(s, _)| *s == slot) { + return (Vec::new(), Vec::new()); + } + if side.offer.len() >= OFFER_SLOTS { + return ( + vec![Out::Tell( + uuid, + format!("You can offer {OFFER_SLOTS} stacks at most."), + )], + Vec::new(), + ); + } + side.offer.push((slot, stack)); + t.unconfirm(); + return (Vec::new(), shown); + } + match menu { + Some(CANCEL) => { + let why = format!("{} cancelled", t.sides[me].name); + self.end(uuid, &why) + } + Some(CONFIRM) => { + t.sides[me].confirmed = !t.sides[me].confirmed; + if t.sides.iter().all(|s| s.confirmed) { + (Vec::new(), vec![Act::Settle(uuid)]) + } else { + (Vec::new(), shown) + } + } + Some(slot) => match own_square(slot).filter(|&q| q < t.sides[me].offer.len()) { + Some(q) => { + t.sides[me].offer.remove(q); + t.unconfirm(); + (Vec::new(), shown) + } + None => (Vec::new(), Vec::new()), + }, + None => (Vec::new(), Vec::new()), + } + } + + /// Checks `uuid`'s offer against their inventory now (`stacks`, by + /// slot): a stack that changed (used, dropped, grown by a pickup) leaves + /// the offer and both confirmations are cleared. Whether anything left. + pub fn refresh(&mut self, uuid: u128, stacks: &[Option]) -> bool { + let Some(i) = self.index_of(uuid) else { + return false; + }; + let t = &mut self.open[i]; + let me = t.side_of(uuid).expect("found"); + let before = t.sides[me].offer.len(); + t.sides[me] + .offer + .retain(|(slot, s)| stacks.get(*slot).and_then(|x| x.as_ref()) == Some(s)); + let changed = t.sides[me].offer.len() < before; + if changed { + t.unconfirm(); + } + changed + } + + /// What `uuid`'s trade menu shows, slot by slot. + pub fn layout(&self, uuid: u128) -> Vec> { + let mut cells = vec![Cell::Empty; SIZE]; + let Some(t) = self.trade_of(uuid) else { + return cells; + }; + let me = t.side_of(uuid).expect("found"); + let (mine, theirs) = (&t.sides[me], &t.sides[1 - me]); + for row in 0..ROWS as usize { + cells[row * 9 + 4] = Cell::Divider; + } + for (i, (_, s)) in mine.offer.iter().enumerate() { + cells[offer_slot(i, true)] = Cell::Stack(s.clone()); + } + for (i, (_, s)) in theirs.offer.iter().enumerate() { + cells[offer_slot(i, false)] = Cell::Stack(s.clone()); + } + cells[MONEY] = Cell::Money(true, mine.money); + cells[OTHER_MONEY] = Cell::Money(false, theirs.money); + cells[CONFIRM] = Cell::Confirm(mine.confirmed); + cells[CANCEL] = Cell::Cancel; + cells[OTHER_STATUS] = Cell::Other(theirs.name.clone(), theirs.confirmed); + cells + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const A: u128 = 1; + const B: u128 = 2; + type S = (&'static str, u32); + + fn economy() -> Economy { + // One folder per call: the tests run side by side. + static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("smp-trade-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut e = Economy::open(&dir, Default::default()).unwrap(); + e.seen(A, "Alex"); + e.seen(B, "Steve"); + e.adjust("eco_give", A, 100, "test").unwrap(); + e + } + fn player(uuid: u128) -> Player { + Player { + uuid, + name: if uuid == A { "Alex" } else { "Steve" }.into(), + level: 0, + } + } + fn online(name: &str) -> Option<(u128, String, bool)> { + match name.to_ascii_lowercase().as_str() { + "alex" => Some((A, "Alex".into(), true)), + "steve" => Some((B, "Steve".into(), true)), + _ => None, + } + } + fn open() -> (Economy, Trades) { + let e = economy(); + let mut t = Trades::default(); + t.command(&e, &player(A), true, &online, 0, "trade Steve") + .unwrap(); + let (_, acts) = t + .command(&e, &player(B), true, &online, 1_000, "trade accept") + .unwrap(); + assert_eq!(acts, [Act::Show(A), Act::Show(B)]); + (e, t) + } + + #[test] + fn a_request_opens_a_trade_once_accepted() { + let (_, t) = open(); + assert_eq!(t.open.len(), 1); + let cells = t.layout(B); + assert_eq!(cells[OTHER_STATUS], Cell::Other("Alex".into(), false)); + assert_eq!(cells[CONFIRM], Cell::Confirm(false)); + } + + #[test] + fn requests_expire_and_can_be_refused() { + let e = economy(); + let mut t: Trades = Trades::default(); + t.command(&e, &player(A), true, &online, 0, "trade Steve") + .unwrap(); + let (out, _) = t + .command(&e, &player(B), true, &online, REQUEST_MS, "trade accept") + .unwrap(); + assert!(matches!(&out[0], Out::Error(_))); + t.command(&e, &player(A), true, &online, 0, "trade Steve") + .unwrap(); + t.command(&e, &player(B), true, &online, 10, "trade deny Alex") + .unwrap(); + let (out, _) = t + .command(&e, &player(B), true, &online, 20, "trade accept") + .unwrap(); + assert!(matches!(&out[0], Out::Error(_))); + // Creative players don't trade; nor does a player with themselves. + let (out, _) = t + .command(&e, &player(A), false, &online, 0, "trade Steve") + .unwrap(); + assert!(matches!(&out[0], Out::Error(_))); + let (out, _) = t + .command(&e, &player(A), true, &online, 0, "trade Alex") + .unwrap(); + assert!(matches!(&out[0], Out::Error(_))); + assert!(t.open.is_empty()); + } + + #[test] + fn any_change_clears_both_confirmations() { + let (e, mut t) = open(); + t.click(A, None, Some((3, ("minecraft:diamond", 5)))); + t.click(A, Some(CONFIRM), None); + t.click(B, Some(CONFIRM), None); + // Both confirmed: settled; a click after the settle's failure, or a + // change, needs both again. + assert_eq!(t.open[0].sides.iter().filter(|s| s.confirmed).count(), 2); + t.click(B, None, Some((0, ("minecraft:emerald", 1)))); + assert!(t.open[0].sides.iter().all(|s| !s.confirmed)); + t.click(A, Some(CONFIRM), None); + t.command(&e, &player(B), true, &online, 0, "trade money 0") + .unwrap(); + assert!(t.open[0].sides.iter().all(|s| !s.confirmed)); + // Taking a stack back out of the offer. + t.click(A, Some(CONFIRM), None); + t.click(A, Some(offer_slot(0, true)), None); + assert!(t.open[0].sides[0].offer.is_empty()); + assert!(!t.open[0].sides[0].confirmed); + } + + #[test] + fn the_second_confirmation_settles() { + let (_, mut t) = open(); + t.click(A, None, Some((3, ("minecraft:diamond", 5)))); + assert_eq!( + t.click(A, Some(CONFIRM), None).1, + [Act::Show(A), Act::Show(B)] + ); + assert_eq!(t.click(B, Some(CONFIRM), None).1, [Act::Settle(B)]); + } + + #[test] + fn an_offer_is_one_slot_once_and_sixteen_at_most() { + let (_, mut t) = open(); + t.click(A, None, Some((3, ("minecraft:diamond", 5)))); + t.click(A, None, Some((3, ("minecraft:diamond", 5)))); + assert_eq!(t.open[0].sides[0].offer.len(), 1); + for slot in 10..30 { + t.click(A, None, Some((slot, ("minecraft:dirt", 1)))); + } + assert_eq!(t.open[0].sides[0].offer.len(), OFFER_SLOTS); + assert_eq!( + t.layout(B)[offer_slot(0, false)], + Cell::Stack(("minecraft:diamond", 5)) + ); + } + + #[test] + fn a_stack_that_changed_leaves_the_offer() { + let (_, mut t) = open(); + t.click(A, None, Some((3, ("minecraft:diamond", 5)))); + t.click(A, None, Some((4, ("minecraft:dirt", 1)))); + t.click(A, Some(CONFIRM), None); + let mut inv: Vec> = vec![None; 36]; + inv[3] = Some(("minecraft:diamond", 5)); + inv[4] = Some(("minecraft:dirt", 1)); + assert!(!t.refresh(A, &inv)); + assert!(t.open[0].sides[0].confirmed); + // A pickup grew the diamonds. + inv[3] = Some(("minecraft:diamond", 6)); + assert!(t.refresh(A, &inv)); + assert_eq!(t.open[0].sides[0].offer, [(4, ("minecraft:dirt", 1))]); + assert!(!t.open[0].sides[0].confirmed); + } + + #[test] + fn every_way_out_ends_the_trade_with_nothing_moved() { + // Cancel button, /trade cancel; closing and leaving go through + // `end` from the glue. + let (e, mut t) = open(); + let (out, acts) = t.click(B, Some(CANCEL), None); + assert_eq!(acts, [Act::Close(A), Act::Close(B)]); + assert!(matches!(&out[0], Out::Tell(A, s) if s.contains("Nothing was exchanged"))); + assert!(t.open.is_empty()); + let (_, mut t) = open(); + t.command(&e, &player(A), true, &online, 0, "trade cancel") + .unwrap(); + assert!(t.open.is_empty()); + let (_, mut t) = open(); + t.end(B, "Steve left"); + assert!(t.open.is_empty()); + // Money beyond the balance is refused when offered. + let (_, mut t) = open(); + let (out, _) = t + .command(&e, &player(A), true, &online, 0, "trade money 101") + .unwrap(); + assert!(matches!(&out[0], Out::Error(_))); + t.command(&e, &player(A), true, &online, 0, "trade money 100") + .unwrap(); + assert_eq!(t.open[0].sides[0].money, 100); + } +} diff --git a/native/smp/proof.py b/native/smp/proof.py index aedaa7b..9076688 100644 --- a/native/smp/proof.py +++ b/native/smp/proof.py @@ -7,6 +7,9 @@ Steve, `horizon-ctl --offscreen`) join it. Checked from the clients' own chat an - 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 , /sell hand, /buy, with the items gone from or in his inventory as his client sees it; a creative player can't sell; +- trades (`/trade`, the server's chest menu clicked from each client): closing the menu, a full inventory, the + cancel button, dying and leaving each end or refuse a trade with nothing moved (each player's items counted from + their own client); a change of offer clears the other's confirmation; a confirmed trade swaps the items and money; - after the server stops cleanly and starts again, the balances are the same, and audit.log holds every change. Everything runs in fresh temporary folders (the server's, each client's home, launcher and XDG folders), no audio. @@ -129,6 +132,117 @@ class Client: def quit(self): self.run("quit", check=False) + def screen(self): + return self.run("screen") + + def menu(self): + """The open server menu's items by its own slot (horizon-ctl numbers them from 300).""" + return {e["slot"] - 300: e for e in self.screen().get("slots", []) if e.get("item") and e["slot"] >= 300} + + def slot_of(self, item): + """The inventory slot (0..36) holding `item`, as the open screen shows it.""" + return next(e["slot"] for e in self.screen().get("slots", []) if e.get("item") == item and e["slot"] < 36) + + def count(self, item): + counts = self.run("inventory").get("counts", {}) + return counts.get(item, counts.get(item.removeprefix("minecraft:"), 0)) + + def click(self, slot): + self.run("click-slot", slot) + time.sleep(1) + + +CONFIRM, CANCEL, THEIR_FIRST = 300 + 45, 300 + 48, 5 + + +def open_trade(alex, steve): + assert any("Trade request sent to Steve." in l for l in alex.say("/trade Steve")) + assert any("Alex wants to trade with you" in l for l in steve.chat()[-3:]) + steve.say("/trade accept") + time.sleep(1) + for c, other in ((alex, "Steve"), (steve, "Alex")): + menu = c.menu() + assert menu.get(45, {}).get("item") == "minecraft:lime_stained_glass_pane", (c.name, menu) + title = "".join(s["text"] for s in c.screen().get("title", [])) + assert title == f"Trade with {other}", title + + +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 = {} + items = lambda: (alex.count("minecraft:diamond"), alex.count("minecraft:emerald"), + steve.count("minecraft:diamond"), steve.count("minecraft:emerald")) + assert items() == (10, 0, 0, 5), items() + # Closing the menu. + open_trade(alex, steve) + alex.click(alex.slot_of("minecraft:diamond")) + shown = steve.menu().get(THEIR_FIRST, {}) + assert (shown.get("item"), shown.get("count")) == ("minecraft:diamond", 10), shown + assert alex.count("minecraft:diamond") == 10, "offered stacks stay in the inventory" + alex.run("close") + time.sleep(1.5) + results["closed"] = steve.chat()[-2:] + assert any("Trade cancelled: Alex closed the trade. Nothing was exchanged." in l for l in results["closed"]) + assert not steve.menu(), "the other's menu closes too" + assert items() == (10, 0, 0, 5), items() + # A full inventory: Steve can't take the diamonds; nothing moves, the trade stays open, both must confirm again. + server.console("give Steve minecraft:dirt 2176") + time.sleep(2) + open_trade(alex, steve) + alex.click(alex.slot_of("minecraft:diamond")) + alex.click(CONFIRM) + steve.click(CONFIRM) + results["full"] = alex.chat()[-2:] + assert any("The trade didn't go through: the second player has no room." in l for l in results["full"]), results + assert alex.menu().get(45, {}).get("item") == "minecraft:lime_stained_glass_pane", "confirmations cleared" + assert items() == (10, 0, 0, 5), items() + # The cancel button. + steve.click(CANCEL) + assert any("Trade cancelled: Steve cancelled." in l for l in alex.chat()[-2:]) + server.console("clear Steve minecraft:dirt") + time.sleep(1) + # Through: Alex's diamonds for Steve's emeralds and 35 coins. Steve's money offer clears Alex's confirmation. + open_trade(alex, steve) + alex.click(alex.slot_of("minecraft:diamond")) + steve.click(steve.slot_of("minecraft:emerald")) + alex.click(CONFIRM) + assert alex.menu()[45]["item"] == "minecraft:lime_concrete" + assert steve.menu()[52]["item"] == "minecraft:lime_concrete", "Steve sees Alex confirmed" + steve.say("/trade money 35") + assert alex.menu()[45]["item"] == "minecraft:lime_stained_glass_pane", "a change clears the confirmation" + alex.click(CONFIRM) + steve.click(CONFIRM) + time.sleep(1) + results["done"] = alex.chat()[-2:] + assert any("Trade with Steve done" in l for l in results["done"]), results + assert items() == (0, 5, 10, 0), items() + assert any("Balance: 415 coins" in l for l in alex.say("/balance")) + assert any("Balance: 400 coins" in l for l in steve.say("/balance")) + # Dying (inventories kept, so a duplicate would show): Alex's death ends it, nothing moved. + server.console("gamerule keepInventory true") + open_trade(alex, steve) + alex.click(alex.slot_of("minecraft:emerald")) + server.console("kill Alex") + time.sleep(2) + results["death"] = steve.chat()[-2:] + assert any("Trade cancelled" in l for l in results["death"]), results + alex.run("respawn") + time.sleep(2) + assert items() == (0, 5, 10, 0), items() + # Leaving: Steve quits mid-trade; nothing moved, his items are his when he comes back. + open_trade(alex, steve) + steve.click(steve.slot_of("minecraft:diamond")) + steve.quit() + time.sleep(3) + results["left"] = alex.chat()[-2:] + assert any("Trade cancelled" in l for l in results["left"]), results + assert not alex.menu() + steve = Client(ctl, resources, "Steve2", root) + steve.name = "Steve" + steve.join(port) + assert items() == (0, 5, 10, 0), items() + return results, steve + def run(ctl, resources, out): out = Path(out) @@ -189,6 +303,11 @@ def run(ctl, resources, out): time.sleep(1) results["creative"] = alex.say("/sell all") assert any("only with players in survival or adventure mode" in l for l in results["creative"]), results + server.console("gamemode survival Alex") + server.console("give Alex minecraft:diamond 10") + server.console("give Steve minecraft:emerald 5") + time.sleep(2) + results["trades"], steve = trades(server, alex, steve, port, ctl, resources, root) steve.quit() alex.quit() lines = server.stop() @@ -196,17 +315,22 @@ def run(ctl, resources, out): economy = json.loads((data / "economy.json").read_text()) balances = {a["name"]: a["balance"] for a in economy["accounts"].values()} results["economy.json"] = balances - assert balances == {"Alex": 380, "Steve": 435}, balances + assert balances == {"Alex": 415, "Steve": 400}, balances 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"], results["audit"] + "sell 3 minecraft:diamond", "buy 5 minecraft:torch", "trade", "trade_failed", + "trade", "trade_money"], 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 server.start() alex = Client(ctl, resources, "Alex2", root) alex.name = "Alex" alex.join(port) results["after_restart"] = alex.say("/balance") - assert any("Balance: 380 coins" in l for l in results["after_restart"]), results + assert any("Balance: 415 coins" in l for l in results["after_restart"]), results alex.quit() server.stop() print(json.dumps({"proof": "smp", "ok": True, **results}, indent=2)) diff --git a/native/smp/src/lib.rs b/native/smp/src/lib.rs index 40df6cd..8cb177f 100644 --- a/native/smp/src/lib.rs +++ b/native/smp/src/lib.rs @@ -7,3 +7,5 @@ pub mod smp_economy; pub mod smp_market; #[path = "../mixins/smp/smp_store.rs"] pub mod smp_store; +#[path = "../mixins/smp/smp_trade.rs"] +pub mod smp_trade; From 7bd4f6207b621e4e636a41c3f47129eddea41865 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:13:40 +0100 Subject: [PATCH 16/18] SMP proof: the 26.3 keep_inventory rule Co-Authored-By: Claude Opus 5.5 --- native/smp/.gitignore | 2 ++ native/smp/proof.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 native/smp/.gitignore diff --git a/native/smp/.gitignore b/native/smp/.gitignore new file mode 100644 index 0000000..46670be --- /dev/null +++ b/native/smp/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +target/ diff --git a/native/smp/proof.py b/native/smp/proof.py index 9076688..0bb0f15 100644 --- a/native/smp/proof.py +++ b/native/smp/proof.py @@ -219,7 +219,8 @@ def trades(server, alex, steve, port, ctl, resources, root): assert any("Balance: 415 coins" in l for l in alex.say("/balance")) assert any("Balance: 400 coins" in l for l in steve.say("/balance")) # Dying (inventories kept, so a duplicate would show): Alex's death ends it, nothing moved. - server.console("gamerule keepInventory true") + server.console("gamerule keep_inventory true") + time.sleep(1) open_trade(alex, steve) alex.click(alex.slot_of("minecraft:emerald")) server.console("kill Alex") From 7f1bb6920b9125fe4901b9bb3be4b7d8218820d6 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:06:15 +0100 Subject: [PATCH 17/18] SMP: pinned on horizon main (f533c3a, hooks 10 merged) Co-Authored-By: Claude Opus 5.5 --- native/smp/compatibility.json | 2 +- native/smp/mixins/smp/horizon-mixin.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/native/smp/compatibility.json b/native/smp/compatibility.json index fa029fb..235b9bf 100644 --- a/native/smp/compatibility.json +++ b/native/smp/compatibility.json @@ -2,7 +2,7 @@ "game": "26.3", "hooks": 10, "horizon_repository": "https://github.com/horizon-rust/horizon", - "horizon_revision": "e23b4b7d8ff3318da3f96f9e0f11ba961ffefe99", + "horizon_revision": "f533c3af7d41d7ecc3b015a9fbb0dbf5eb64ae26", "horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins", "horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06" } diff --git a/native/smp/mixins/smp/horizon-mixin.toml b/native/smp/mixins/smp/horizon-mixin.toml index ce5811f..72c366a 100644 --- a/native/smp/mixins/smp/horizon-mixin.toml +++ b/native/smp/mixins/smp/horizon-mixin.toml @@ -3,10 +3,10 @@ id = "smp" version = "0.3.0" priority = 100 -# The commit it was tested on. It uses only the native hook points (hooks 10 in +# The commit of horizon main it was tested on. It uses only the native hook points (hooks 10 in # compatibility.json): it builds on any Horizon that has them. [compatibility] -horizon_revision = "e23b4b7d8ff3318da3f96f9e0f11ba961ffefe99" +horizon_revision = "f533c3af7d41d7ecc3b015a9fbb0dbf5eb64ae26" [[module]] id = "store" From 5dad8648b397c74727af1ff40585d421e3e3d59b Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:44:35 +0100 Subject: [PATCH 18/18] SMP: coinflips with held stakes (/coinflip), off by default Co-Authored-By: Claude Opus 5.5 --- native/smp/Cargo.lock | 2 +- native/smp/Cargo.toml | 2 +- native/smp/README.md | 16 +- native/smp/mixins/smp/glue.rs | 46 ++- native/smp/mixins/smp/horizon-mixin.toml | 8 +- native/smp/mixins/smp/smp_economy.rs | 108 +++++- native/smp/mixins/smp/smp_games.rs | 457 +++++++++++++++++++++++ native/smp/proof.py | 57 ++- native/smp/src/lib.rs | 2 + 9 files changed, 676 insertions(+), 22 deletions(-) create mode 100644 native/smp/mixins/smp/smp_games.rs diff --git a/native/smp/Cargo.lock b/native/smp/Cargo.lock index 8c4498b..cb97149 100644 --- a/native/smp/Cargo.lock +++ b/native/smp/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "horizon-mod-smp" -version = "0.3.0" +version = "0.4.0" dependencies = [ "serde", "serde_json", diff --git a/native/smp/Cargo.toml b/native/smp/Cargo.toml index fdada82..c1c8f3e 100644 --- a/native/smp/Cargo.toml +++ b/native/smp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "horizon-mod-smp" -version = "0.3.0" +version = "0.4.0" edition = "2024" license = "MIT" publish = false diff --git a/native/smp/README.md b/native/smp/README.md index c3f5064..9ebf130 100644 --- a/native/smp/README.md +++ b/native/smp/README.md @@ -8,7 +8,7 @@ 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 and player trades. +This version: the economy, the server market, player trades and the coinflip. | Command | Does | |---|---| @@ -23,6 +23,8 @@ This version: the economy, the server market and player trades. | `/trade accept [player]`, `/trade deny [player]` | answers a request | | `/trade money ` | the money you add to your offer (0: none) | | `/trade cancel` | ends your trade, or withdraws your request | +| `/coinflip [player]` | stakes an amount for anyone (or one player) to match; off unless `gambling.enabled` | +| `/coinflip accept `, `/coinflip cancel`, `/coinflip list` | takes an offer (a fair coin decides, the winner gets both stakes), withdraws yours, lists those waiting | 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 @@ -38,6 +40,12 @@ the trade, and swaps everything in one step, or nothing (and both must confirm a `/trade cancel`, leaving, dying or the other player leaving ends the trade with nothing moved. Survival and 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 +`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 currency is earned in game only: it can't be bought with real money or cashed out (Mojang's usage guidelines). ## Data @@ -48,7 +56,7 @@ 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 | +| `economy.json` | balances by player UUID, with the name last seen, and `held`: coinflip stakes 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 | `economy.json` is written whole to a temporary file, flushed and renamed over the old one, at most once a server tick @@ -63,8 +71,8 @@ lifecycle. `compatibility.json` names `"hooks": 10`. | File | What it is | |---|---| -| `mixins/smp/horizon-mixin.toml` | The bundle: five modules and the HEAD hooks | -| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs`, `smp_trade.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`; also this crate's modules (`src/lib.rs`), for their tests | +| `mixins/smp/horizon-mixin.toml` | The bundle: six modules and the HEAD hooks | +| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs`, `smp_trade.rs`, `smp_games.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`; 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 | | `proof.py` | A dedicated server built with the mod and two real clients, on fresh temporary data | diff --git a/native/smp/mixins/smp/glue.rs b/native/smp/mixins/smp/glue.rs index 50334ac..dccc190 100644 --- a/native/smp/mixins/smp/glue.rs +++ b/native/smp/mixins/smp/glue.rs @@ -1,6 +1,6 @@ // The SMP in the server's game: its state for the open world, and the edges // between the game's types and the rules (smp_economy, smp_market, -// smp_trade). Added to +// smp_trade, smp_games). Added to // the game as `game::smp_glue`; the helpers (register.rs, command.rs, // tick.rs, lifecycle.rs, menu_clicked.rs, menu_closed.rs) call it from the // hook points. @@ -9,6 +9,7 @@ use crate::game::{ Game, native_server::{NativeCommand, NativeServerPlayer}, smp_economy::{self, Economy, Out, Player}, + smp_games::{self, Games}, native_menus::native_menu_inventory_slot, smp_market::{self, Bag, Market}, smp_store, @@ -24,7 +25,7 @@ use std::{path::PathBuf, sync::Mutex}; /// The mod's id, name and version. pub const ID: &str = "smp"; pub const NAME: &str = "SMP"; -pub const VERSION: &str = "0.3.0"; +pub const VERSION: &str = "0.4.0"; /// `config.json` in `world/horizon-smp/`, written with its defaults the /// first time, and again with the keys a newer version adds (values kept). @@ -34,6 +35,7 @@ pub struct Config { #[serde(flatten)] pub economy: smp_economy::Config, pub market: smp_market::Config, + pub gambling: smp_games::Config, } /// The SMP of the open world. @@ -43,6 +45,7 @@ pub struct Smp { economy: Result, market: Market, trades: Trades, + games: Games, } static STATE: Mutex> = Mutex::new(None); @@ -90,6 +93,7 @@ fn open(dir: PathBuf) -> Smp { config: config.market, }, trades: Trades::default(), + games: Games::new(config.gambling), } } @@ -134,6 +138,10 @@ pub fn commands(commands: &mut Vec) { "trade deny [player:word]", "trade cancel", "trade money ", + "coinflip [player:word]", + "coinflip accept ", + "coinflip cancel", + "coinflip list", ] { commands.push(NativeCommand::new(usage)); } @@ -169,10 +177,18 @@ impl Bag for GameBag<'_, '_> { } } -const COMMANDS: [&str; 9] = [ - "balance", "bal", "pay", "baltop", "eco", "worth", "sell", "buy", "trade", +const COMMANDS: [&str; 10] = [ + "balance", "bal", "pay", "baltop", "eco", "worth", "sell", "buy", "trade", "coinflip", ]; +/// A fair coin, never the world's random: a fresh SipHash key (seeded from +/// the system's randomness) over the time. +fn heads() -> bool { + use std::hash::BuildHasher; + let time = std::time::SystemTime::now(); + std::collections::hash_map::RandomState::new().hash_one(time) & 1 == 1 +} + /// A player's command. pub(in crate::game) fn perform( game: &mut Game<'_>, @@ -215,6 +231,10 @@ pub(in crate::game) fn perform( acts = a; Some(out) }) + .or_else(|| { + let named = |n: &str| online(n).map(|(u, name, _)| (u, name)); + smp.games.command(e, &me, &named, now, &mut heads, line) + }) .unwrap_or_default() } Err(_) => vec![Out::Error( @@ -472,10 +492,25 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) { }; let mut guard = state(dir.join(smp_store::DIR)); let smp = guard.as_mut().expect("opened"); - if let Ok(e) = &mut smp.economy { + let mut out = Vec::new(); + if let Ok(e) = &mut smp.economy + && n % 20 == 0 + { for p in &players { e.seen(p.uuid, &p.name); } + // Once a second: coinflips nobody took, and those of players gone. + out.extend(smp.games.expire(e, chrono::Utc::now().timestamp_millis())); + let gone: Vec = smp + .games + .offers + .iter() + .map(|o| o.from) + .filter(|u| !players.iter().any(|p| p.uuid == *u)) + .collect(); + for uuid in gone { + smp.games.left(e, uuid); + } } // Each trade's offers against the inventories now: a player gone or // dead ends it; a stack that changed leaves its offer. @@ -485,7 +520,6 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) { .iter() .flat_map(|t| t.sides.iter().map(|s| (s.uuid, s.name.clone()))) .collect(); - let mut out = Vec::new(); for (uuid, name) in sides { match game.native_player_stacks(uuid) { None => { diff --git a/native/smp/mixins/smp/horizon-mixin.toml b/native/smp/mixins/smp/horizon-mixin.toml index 72c366a..ebf01dd 100644 --- a/native/smp/mixins/smp/horizon-mixin.toml +++ b/native/smp/mixins/smp/horizon-mixin.toml @@ -1,6 +1,6 @@ [mod] id = "smp" -version = "0.3.0" +version = "0.4.0" priority = 100 # The commit of horizon main it was tested on. It uses only the native hook points (hooks 10 in @@ -32,6 +32,12 @@ file = "crates/horizon-client/src/game.rs" name = "smp_trade" source = "smp_trade.rs" +[[module]] +id = "games" +file = "crates/horizon-client/src/game.rs" +name = "smp_games" +source = "smp_games.rs" + [[module]] id = "glue" file = "crates/horizon-client/src/game.rs" diff --git a/native/smp/mixins/smp/smp_economy.rs b/native/smp/mixins/smp/smp_economy.rs index 76d1c3c..dad19b7 100644 --- a/native/smp/mixins/smp/smp_economy.rs +++ b/native/smp/mixins/smp/smp_economy.rs @@ -47,6 +47,11 @@ pub struct Account { pub struct Accounts { /// By UUID, as `uuid_string` writes it. pub accounts: BTreeMap, + /// Money held for a game being decided (a coinflip's stake), by UUID: + /// in the same file as the balances, so a crash can't lose it or count + /// it twice. Given back when the economy opens (a restart voids games). + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub held: BTreeMap, } /// A player as a command sees them. @@ -84,14 +89,113 @@ impl Economy { pub fn open(dir: &std::path::Path, config: Config) -> Result { let path = dir.join("economy.json"); let accounts = read_json(&path)?; - Ok(Self { + let mut economy = Self { config, accounts, path, audit: Audit::new(dir.join("audit.log")), dirty: false, now: 0, - }) + }; + // 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 { + if let Some(uuid) = super::smp_store::parse_uuid(&key) { + economy.release("held_returned", uuid, amount)?; + } + } + Ok(economy) + } + + /// Money `uuid` has held for a game. + pub fn held(&self, uuid: u128) -> i64 { + self.accounts + .held + .get(&uuid_string(uuid)) + .copied() + .unwrap_or(0) + } + + /// Moves `amount` of `uuid`'s balance to held, logged as `kind`. + pub fn hold(&mut self, kind: &str, uuid: u128, amount: i64) -> Result<(), String> { + let key = uuid_string(uuid); + let balance = self.balance(uuid); + if amount <= 0 || amount > MAX_AMOUNT { + return Err("The amount must be a positive whole number.".into()); + } + if !self.accounts.accounts.contains_key(&key) { + return Err("No such account.".into()); + } + if balance < amount { + return Err(format!("You only have {}.", self.money(balance))); + } + let entry = self.entry(kind, Some(uuid), None, amount, ""); + self.audit.append(&entry)?; + self.accounts + .accounts + .get_mut(&key) + .expect("checked") + .balance -= amount; + *self.accounts.held.entry(key).or_default() += amount; + self.dirty = true; + Ok(()) + } + + /// Gives back up to `amount` of what `uuid` has held, logged as `kind`. + pub fn release(&mut self, kind: &str, uuid: u128, amount: i64) -> Result<(), String> { + let key = uuid_string(uuid); + let amount = amount.min(self.held(uuid)); + if amount <= 0 { + return Ok(()); + } + let entry = self.entry(kind, None, Some(uuid), amount, ""); + self.audit.append(&entry)?; + self.take_held(&key, amount); + let account = self.accounts.accounts.entry(key).or_default(); + account.balance = account.balance.saturating_add(amount); + self.dirty = true; + Ok(()) + } + + /// A game decided: each stake leaves held, and `winner` gets `prize` + /// (at most the stakes together), 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) { + return Err("The stakes are not held.".into()); + } + let mut entry = self.entry(kind, None, Some(winner), prize, ""); + entry["stakes"] = stakes + .iter() + .map(|(u, a)| serde_json::json!({ "uuid": uuid_string(*u), "amount": a })) + .collect(); + self.audit.append(&entry)?; + for (u, a) in stakes { + self.take_held(&uuid_string(*u), *a); + } + let account = self + .accounts + .accounts + .entry(uuid_string(winner)) + .or_default(); + account.balance = account.balance.saturating_add(prize); + self.dirty = true; + Ok(()) + } + + fn take_held(&mut self, key: &str, amount: i64) { + if let Some(h) = self.accounts.held.get_mut(key) { + *h -= amount; + if *h <= 0 { + self.accounts.held.remove(key); + } + } } /// Writes `economy.json` when it changed (end of a tick, world closing). diff --git a/native/smp/mixins/smp/smp_games.rs b/native/smp/mixins/smp/smp_games.rs new file mode 100644 index 0000000..e38f573 --- /dev/null +++ b/native/smp/mixins/smp/smp_games.rs @@ -0,0 +1,457 @@ +// Games of chance with the in-game currency only (`/coinflip`): a player +// stakes an amount, another matches it, a fair coin decides, the winner +// takes both stakes. Each stake is held in `economy.json` from the moment it +// is offered (`Economy::hold`) until the coin falls or the offer ends, so a +// crash or a restart gives it back and never loses or doubles it. Off by +// default (`gambling.enabled`); limits, a cooldown and the audit log on every +// step. Nothing here is bought with real money or paid out of the game +// (Mojang's usage guidelines). No game types; the coin is the glue's, never +// the world's random. Added to the game as `game::smp_games`; also a module +// of this crate, for its tests. + +use super::smp_economy::{Economy, Out, Player, parse_amount}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// `config.json`'s `gambling` section. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Config { + /// Off until the owner turns it on. + pub enabled: bool, + pub min_bet: i64, + pub max_bet: i64, + /// Between two coinflips of one player (offered or accepted). + 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, +} + +impl Default for Config { + fn default() -> Self { + Self { + enabled: false, + min_bet: 10, + max_bet: 1000, + cooldown_seconds: 30, + offer_seconds: 60, + fee_percent: 0, + } + } +} + +/// An offer waiting for a taker: its stake is held. +#[derive(Clone, Debug, PartialEq)] +pub struct Offer { + pub from: u128, + pub from_name: String, + /// Only this player may take it; anyone when None. + pub to: Option, + pub amount: i64, + pub at: i64, +} + +#[derive(Default)] +pub struct Games { + pub config: Config, + pub offers: Vec, + /// When each player last played, for the cooldown. + last: HashMap, +} + +const USAGE: &str = "Usage: /coinflip [player], /coinflip accept , /coinflip cancel, /coinflip list"; + +impl Games { + pub fn new(config: Config) -> Self { + Self { + config, + ..Default::default() + } + } + + /// The milliseconds `uuid` still waits before playing again. + fn cooling(&self, uuid: u128, now: i64) -> i64 { + self.last + .get(&uuid) + .map_or(0, |t| t + self.config.cooldown_seconds * 1000 - now) + .max(0) + } + + /// Offers nobody took in time: their stakes go back. + pub fn expire(&mut self, economy: &mut Economy, now: i64) -> Vec { + let limit = self.config.offer_seconds * 1000; + let (old, keep): (Vec, Vec) = + self.offers.drain(..).partition(|o| now - o.at >= limit); + self.offers = keep; + let mut out = Vec::new(); + for o in old { + // A stake the log refuses to release stays held: given back + // when the economy next opens. + if economy + .release("coinflip_expired", o.from, o.amount) + .is_ok() + { + out.push(Out::Tell( + o.from, + format!( + "Nobody took your coinflip: {} back.", + economy.money(o.amount) + ), + )); + } + } + out + } + + /// The player left: their offer ends and its stake goes back. + pub fn left(&mut self, economy: &mut Economy, uuid: u128) { + if let Some(i) = self.offers.iter().position(|o| o.from == uuid) { + let o = self.offers.remove(i); + // Refused by the log: still held, given back at the next start. + let _ = economy.release("coinflip_cancel", o.from, o.amount); + } + } + + /// `/coinflip ...`; None for another command. `online` finds a player who + /// is on by name (uuid, name); `heads` flips the coin. + pub fn command( + &mut self, + economy: &mut Economy, + me: &Player, + online: &dyn Fn(&str) -> Option<(u128, String)>, + now: i64, + heads: &mut dyn FnMut() -> bool, + line: &str, + ) -> Option> { + let words: Vec<&str> = line.split_whitespace().collect(); + if words.first() != Some(&"coinflip") { + return None; + } + let err = |s: String| Some(vec![Out::Error(s)]); + if !self.config.enabled { + return err("Games of chance are off on this server.".into()); + } + let mut out = self.expire(economy, now); + let result = match words[1..] { + ["list"] => { + let open: Vec = self + .offers + .iter() + .filter(|o| o.to.is_none_or(|t| t == me.uuid)) + .map(|o| format!("{}: {}", o.from_name, economy.money(o.amount))) + .collect(); + if open.is_empty() { + vec![Out::Reply("No coinflip is waiting.".into())] + } else { + vec![Out::Reply(format!( + "Coinflips waiting: {}", + open.join(", ") + ))] + } + } + ["cancel"] => match self.offers.iter().position(|o| o.from == me.uuid) { + None => vec![Out::Error("You have no coinflip waiting.".into())], + Some(i) => { + let o = self.offers.remove(i); + match economy.release("coinflip_cancel", o.from, o.amount) { + Ok(()) => vec![Out::Reply(format!( + "Coinflip cancelled: {} back.", + economy.money(o.amount) + ))], + Err(e) => { + self.offers.insert(i, o); + vec![Out::Error(e)] + } + } + } + }, + ["accept", name] => self.accept(economy, me, name, now, heads), + [amount] | [amount, _] => { + let to = match words.get(2) { + None => None, + Some(name) => match online(name) { + Some((uuid, _)) if uuid == me.uuid => { + return err("You can't flip against yourself.".into()); + } + Some(found) => Some(found), + None => return err(format!("{name} is not here.")), + }, + }; + match parse_amount(amount) { + None => vec![Out::Error(USAGE.into())], + Some(a) => self.offer(economy, me, to, a, now), + } + } + _ => vec![Out::Error(USAGE.into())], + }; + out.extend(result); + Some(out) + } + + fn offer( + &mut self, + economy: &mut Economy, + me: &Player, + to: Option<(u128, String)>, + amount: i64, + now: i64, + ) -> Vec { + let c = &self.config; + if !(c.min_bet..=c.max_bet).contains(&amount) { + return vec![Out::Error(format!( + "A coinflip is {} to {}.", + economy.money(c.min_bet), + economy.money(c.max_bet) + ))]; + } + if self.offers.iter().any(|o| o.from == me.uuid) { + return vec![Out::Error( + "You already have a coinflip waiting (/coinflip cancel).".into(), + )]; + } + let wait = self.cooling(me.uuid, now); + if wait > 0 { + return vec![Out::Error(format!( + "Wait {} s before your next coinflip.", + (wait + 999) / 1000 + ))]; + } + if let Err(e) = economy.hold("coinflip_stake", me.uuid, amount) { + return vec![Out::Error(e)]; + } + self.offers.push(Offer { + from: me.uuid, + from_name: me.name.clone(), + to: to.as_ref().map(|(u, _)| *u), + amount, + at: now, + }); + let money = economy.money(amount); + let secs = self.config.offer_seconds; + match to { + Some((uuid, name)) => vec![ + Out::Reply(format!("Coinflip of {money} offered to {name} ({secs} s).")), + Out::Tell( + uuid, + format!( + "{} offers you a coinflip of {money}: /coinflip accept {}", + me.name, me.name + ), + ), + ], + None => vec![Out::Reply(format!( + "Coinflip of {money} offered ({secs} s): /coinflip accept {}", + me.name + ))], + } + } + + fn accept( + &mut self, + economy: &mut Economy, + me: &Player, + name: &str, + now: i64, + heads: &mut dyn FnMut() -> bool, + ) -> Vec { + let Some(i) = self.offers.iter().position(|o| { + o.from_name.eq_ignore_ascii_case(name) && o.to.is_none_or(|t| t == me.uuid) + }) else { + return vec![Out::Error(format!("{name} has no coinflip for you."))]; + }; + if self.offers[i].from == me.uuid { + return vec![Out::Error("You can't flip against yourself.".into())]; + } + let wait = self.cooling(me.uuid, now); + if wait > 0 { + return vec![Out::Error(format!( + "Wait {} s before your next coinflip.", + (wait + 999) / 1000 + ))]; + } + let amount = self.offers[i].amount; + if let Err(e) = economy.hold("coinflip_stake", me.uuid, amount) { + 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 (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) { + // 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); + } + return vec![Out::Error(e)]; + } + self.last.insert(o.from, now); + self.last.insert(me.uuid, now); + let won = economy.money(prize); + vec![ + Out::Tell( + winner, + format!("Coinflip against {loser_name}: you won {won}!"), + ), + Out::Tell( + loser, + format!( + "Coinflip against {winner_name}: you lost {}.", + economy.money(amount) + ), + ), + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const A: u128 = 1; + const B: u128 = 2; + + fn setup(name: &str) -> (Economy, Games, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("smp-games-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut e = Economy::open(&dir, Default::default()).unwrap(); + e.seen(A, "Alex"); + e.seen(B, "Steve"); + e.adjust("eco_give", A, 500, "test").unwrap(); + e.adjust("eco_give", B, 500, "test").unwrap(); + let g = Games::new(Config { + enabled: true, + ..Default::default() + }); + (e, g, dir) + } + fn player(uuid: u128) -> Player { + Player { + uuid, + name: if uuid == A { "Alex" } else { "Steve" }.into(), + level: 0, + } + } + fn online(name: &str) -> Option<(u128, String)> { + match name.to_ascii_lowercase().as_str() { + "alex" => Some((A, "Alex".into())), + "steve" => Some((B, "Steve".into())), + _ => None, + } + } + fn run( + e: &mut Economy, + g: &mut Games, + who: u128, + now: i64, + heads: bool, + line: &str, + ) -> Vec { + g.command(e, &player(who), &online, now, &mut || heads, line) + .unwrap() + } + fn total(e: &Economy) -> i64 { + e.balance(A) + e.balance(B) + e.held(A) + e.held(B) + } + + #[test] + fn the_stake_is_held_then_the_winner_takes_both() { + let (mut e, mut g, dir) = setup("win"); + run(&mut e, &mut g, A, 0, true, "coinflip 100"); + assert_eq!((e.balance(A), e.held(A)), (400, 100)); + let out = run(&mut e, &mut g, B, 1000, true, "coinflip accept alex"); + assert!( + matches!(&out[0], Out::Tell(A, s) if s.contains("you won 200 coins")), + "{out:?}" + ); + assert_eq!( + (e.balance(A), e.balance(B), e.held(A), e.held(B)), + (600, 400, 0, 0) + ); + // Tails: the taker wins. + run(&mut e, &mut g, A, 100_000, false, "coinflip 50 steve"); + run(&mut e, &mut g, B, 101_000, false, "coinflip accept Alex"); + assert_eq!((e.balance(A), e.balance(B)), (550, 450)); + assert_eq!(total(&e), 1000, "nothing made or lost"); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn limits_cooldown_and_refusals() { + let (mut e, mut g, dir) = setup("limits"); + for line in [ + "coinflip 5", + "coinflip 1001", + "coinflip 100 alex", + "coinflip 100 nobody", + "coinflip x", + ] { + assert!( + matches!(&run(&mut e, &mut g, A, 0, true, line)[0], Out::Error(_)), + "{line}" + ); + } + assert_eq!(total(&e), 1000); + assert_eq!(e.held(A), 0); + // More than the balance. + e.adjust("eco_take", B, -480, "test").unwrap(); + run(&mut e, &mut g, A, 0, true, "coinflip 100"); + assert!(matches!( + &run(&mut e, &mut g, B, 0, true, "coinflip accept alex")[0], + Out::Error(_) + )); + assert_eq!(e.held(A), 100, "the offer still waits"); + // Only the named player may take a named offer. + run(&mut e, &mut g, A, 0, true, "coinflip cancel"); + e.adjust("eco_give", B, 480, "test").unwrap(); + // Cooldown after a game. + run(&mut e, &mut g, A, 0, true, "coinflip 10"); + run(&mut e, &mut g, B, 0, true, "coinflip accept alex"); + assert!( + matches!(&run(&mut e, &mut g, A, 10_000, true, "coinflip 10")[0], Out::Error(s) if s.contains("Wait 20 s")) + ); + assert!(matches!( + &run(&mut e, &mut g, A, 30_000, true, "coinflip 10")[0], + Out::Reply(_) + )); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn off_by_default() { + let (mut e, _, dir) = setup("off"); + let mut g = Games::default(); + assert!( + matches!(&run(&mut e, &mut g, A, 0, true, "coinflip 100")[0], Out::Error(s) if s.contains("off")) + ); + assert_eq!(e.held(A), 0); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn every_way_out_gives_the_stake_back() { + let (mut e, mut g, dir) = setup("out"); + run(&mut e, &mut g, A, 0, true, "coinflip 100"); + run(&mut e, &mut g, A, 0, true, "coinflip cancel"); + assert_eq!((e.balance(A), e.held(A)), (500, 0)); + run(&mut e, &mut g, A, 0, true, "coinflip 100"); + let out = g.expire(&mut e, 60_000); + assert!(matches!(&out[0], Out::Tell(A, _))); + assert_eq!((e.balance(A), e.held(A)), (500, 0)); + run(&mut e, &mut g, A, 0, true, "coinflip 100"); + g.left(&mut e, A); + assert_eq!((e.balance(A), e.held(A)), (500, 0)); + // A crash with a stake held: saved, then opened again, it goes back. + run(&mut e, &mut g, A, 0, true, "coinflip 100"); + e.save().unwrap(); + let again = Economy::open(&dir, Default::default()).unwrap(); + assert_eq!((again.balance(A), again.held(A)), (500, 0)); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/native/smp/proof.py b/native/smp/proof.py index 0bb0f15..b6c97e7 100644 --- a/native/smp/proof.py +++ b/native/smp/proof.py @@ -10,6 +10,9 @@ Steve, `horizon-ctl --offscreen`) join it. Checked from the clients' own chat an - trades (`/trade`, the server's chest menu clicked from each client): closing the menu, a full inventory, the cancel button, dying and leaving each end or refuse a trade with nothing moved (each player's items counted from their own client); a change of offer clears the other's confirmation; a confirmed trade swaps the items and money; +- coinflips (turned on in config.json before the first start, the other settings filled in): the stake is held from + the offer, the winner gets both, the limits and the cooldown refuse, and a stake left waiting when the server stops + comes back; the money in the game stays the same throughout; - after the server stops cleanly and starts again, the balances are the same, and audit.log holds every change. Everything runs in fresh temporary folders (the server's, each client's home, launcher and XDG folders), no audio. @@ -27,6 +30,7 @@ from pathlib import Path PROPERTIES = { "online-mode": "false", "white-list": "false", "enforce-secure-profile": "false", "level-type": "minecraft\\:flat", "spawn-protection": "0", "max-players": "4", "view-distance": "4", "simulation-distance": "4", "generate-structures": "false", "enable-command-block": "false", + "difficulty": "peaceful", "spawn-monsters": "false", } @@ -167,6 +171,31 @@ def open_trade(alex, steve): assert title == f"Trade with {other}", title +def coinflips(alex, steve): + """Alex 415, Steve 400. Returns what was seen and whether Alex won.""" + results = {} + results["limits"] = alex.say("/coinflip 5") + assert any("A coinflip is 10 coins to 1,000 coins." in l for l in results["limits"]), results + results["offer"] = alex.say("/coinflip 100 Steve") + assert any("Coinflip of 100 coins offered to Steve" in l for l in results["offer"]), results + assert any("Balance: 315 coins" in l for l in alex.say("/balance")), "the stake is held" + assert any("Alex offers you a coinflip of 100 coins" in l for l in steve.chat()[-3:]) + steve.say("/coinflip accept Alex") + time.sleep(1) + results["alex_saw"] = alex.chat()[-1:] + alex_wins = any("you won 200 coins" in l for l in results["alex_saw"]) + assert alex_wins or any("you lost 100 coins" in l for l in results["alex_saw"]), results + steve_saw = steve.chat()[-2:] + assert any(("you lost 100 coins" if alex_wins else "you won 200 coins") in l for l in steve_saw), steve_saw + assert any(f"Balance: {415 + (100 if alex_wins else -100)} coins" in l for l in alex.say("/balance")) + assert any(f"Balance: {400 + (-100 if alex_wins else 100)} coins" in l for l in steve.say("/balance")) + results["cooldown"] = steve.say("/coinflip 50") + assert any("before your next coinflip" in l for l in results["cooldown"]), results + time.sleep(20) + assert any("Coinflip of 50 coins offered" in l for l in steve.say("/coinflip 50")), "after the cooldown" + return results, alex_wins + + 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 = {} @@ -256,14 +285,18 @@ def run(ctl, resources, out): 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, "cooldown_seconds": 20}})) server.start() alex.join(port) steve.join(port) time.sleep(2) - data = server.folder / "world" / "horizon-smp" config = json.loads((data / "config.json").read_text()) results["config_keys"] = sorted(config) - assert results["config_keys"] == ["currency", "market", "starting_balance"], config + assert results["config_keys"] == ["currency", "gambling", "market", "starting_balance"], config + assert config["gambling"] == {"enabled": True, "min_bet": 10, "max_bet": 1000, "cooldown_seconds": 20, + "offer_seconds": 60, "fee_percent": 0}, config assert config["market"]["items"]["minecraft:diamond"] == {"buy": 400, "sell": 100}, config server.console("op Alex") time.sleep(1) @@ -309,19 +342,24 @@ 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) + results["coinflip"], alex_wins = coinflips(alex, steve) + alex_after = 415 + (100 if alex_wins else -100) + # Steve's 50 is still waiting: the server stops with both on. + lines = server.stop() steve.quit() alex.quit() - lines = server.stop() assert not any("panicked" in l for l in lines), lines[-40:] economy = json.loads((data / "economy.json").read_text()) balances = {a["name"]: a["balance"] for a in economy["accounts"].values()} - results["economy.json"] = balances - assert balances == {"Alex": 415, "Steve": 400}, balances + results["economy.json"] = economy + held = sum(economy.get("held", {}).values()) + 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"], results["audit"] + "trade", "trade_money", "coinflip_stake", "coinflip_stake", "coinflip_win", + "coinflip_stake"] + ["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}], @@ -331,9 +369,14 @@ def run(ctl, resources, out): alex.name = "Alex" alex.join(port) results["after_restart"] = alex.say("/balance") - assert any("Balance: 415 coins" in l for l in results["after_restart"]), results + assert any(f"Balance: {alex_after} coins" in l for l in results["after_restart"]), results alex.quit() server.stop() + economy = json.loads((data / "economy.json").read_text()) + assert "held" not in economy and sum(a["balance"] for a in economy["accounts"].values()) == 815, economy + 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] print(json.dumps({"proof": "smp", "ok": True, **results}, indent=2)) finally: (out / "server.log").write_text("\n".join(server.lines)) diff --git a/native/smp/src/lib.rs b/native/smp/src/lib.rs index 8cb177f..9859db9 100644 --- a/native/smp/src/lib.rs +++ b/native/smp/src/lib.rs @@ -3,6 +3,8 @@ //! `super::smp_store` reaches the same code in both. #[path = "../mixins/smp/smp_economy.rs"] pub mod smp_economy; +#[path = "../mixins/smp/smp_games.rs"] +pub mod smp_games; #[path = "../mixins/smp/smp_market.rs"] pub mod smp_market; #[path = "../mixins/smp/smp_store.rs"]