mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 02:56:08 -10:00
Merge main into blockscope-native-0.3.0
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
commit
5495b461fd
32 files changed
+3999
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 543 B |
Binary file not shown.
|
After Width: | Height: | Size: 644 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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 = "icons/shulker_peek.png"
|
||||
|
||||
[[versions]]
|
||||
version = "0.1.0"
|
||||
published = "2026-10-29"
|
||||
game = "26.3"
|
||||
file = "shulker_peek-0.1.0.hnmod"
|
||||
sha256 = "e4e33cb698d91042b2ed2f93a1c935f279d16d83988115d53c0f33530cabeab5"
|
||||
@@ -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 = "icons/xtoxray.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"
|
||||
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
target/
|
||||
Generated
+107
@@ -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.4.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"
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "horizon-mod-smp"
|
||||
version = "0.4.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"
|
||||
@@ -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.
|
||||
@@ -0,0 +1,97 @@
|
||||
# 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.
|
||||
|
||||
Players install nothing: it speaks only what a stock Horizon client already shows (commands with completions, chat
|
||||
lines, vanilla chest menus the server fills with ordinary items (the trade menu); later sounds, titles, the action
|
||||
bar, boss bars and the sidebar).
|
||||
|
||||
This version: the economy, the server market, player trades and the coinflip.
|
||||
|
||||
| Command | Does |
|
||||
|---|---|
|
||||
| `/balance [player]`, `/bal` | your balance, or another player's |
|
||||
| `/pay <player> <amount>` | gives money to a player who has an account (anyone who has played since the mod was on) |
|
||||
| `/baltop [page]` | the richest players, 10 a page |
|
||||
| `/eco give\|take\|set <player> <amount>` | operators of level 3: corrections, logged |
|
||||
| `/worth [item]` | what the market pays and asks for an item (the one in your hand without a name) |
|
||||
| `/sell hand [count]`, `/sell <item> [count]`, `/sell all` | sells to the market: the stack in your hand, an item, or everything it buys |
|
||||
| `/buy <item> [count]` | buys from the market, only what fits in your inventory |
|
||||
| `/trade <player>` | asks a player to trade (60 s to answer) |
|
||||
| `/trade accept [player]`, `/trade deny [player]` | answers a request |
|
||||
| `/trade money <amount>` | the money you add to your offer (0: none) |
|
||||
| `/trade cancel` | ends your trade, or withdraws your request |
|
||||
| `/coinflip <amount> [player]` | stakes an amount for anyone (or one player) to match; off unless `gambling.enabled` |
|
||||
| `/coinflip accept <player>`, `/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
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
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`), `starting_balance` (default 0), and `market`: `enabled` and `items`, each item's `buy` (what the market asks) and `sell` (what it pays), 0 closing that side; written with the defaults the first time, and again when a newer version adds keys (your values kept) |
|
||||
| `economy.json` | balances by player UUID, with the name last seen, and `held`: coinflip stakes 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
|
||||
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
|
||||
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: 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 |
|
||||
|
||||
## 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 <the release's commit>
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"game": "26.3",
|
||||
"hooks": 10,
|
||||
"horizon_repository": "https://github.com/horizon-rust/horizon",
|
||||
"horizon_revision": "f533c3af7d41d7ecc3b015a9fbb0dbf5eb64ae26",
|
||||
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
|
||||
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
// 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, 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.
|
||||
|
||||
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,
|
||||
smp_trade::{self, Act, Cell, Trades},
|
||||
};
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
lan::data::component_nbt,
|
||||
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.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).
|
||||
#[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,
|
||||
pub gambling: smp_games::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<Economy, String>,
|
||||
market: Market,
|
||||
trades: Trades<ItemStack>,
|
||||
games: Games,
|
||||
}
|
||||
|
||||
static STATE: Mutex<Option<Smp>> = 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, write) = match smp_store::read_json::<Option<serde_json::Value>>(&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::<Config>(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(), false)
|
||||
}
|
||||
};
|
||||
if write && 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,
|
||||
market: Market {
|
||||
config: config.market,
|
||||
},
|
||||
trades: Trades::default(),
|
||||
games: Games::new(config.gambling),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Smp>> {
|
||||
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));
|
||||
}
|
||||
if let Some(Ok(e)) = state.as_mut().map(|s| &mut s.economy) {
|
||||
e.now = chrono::Utc::now().timestamp_millis();
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
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<NativeCommand>) {
|
||||
for usage in [
|
||||
"balance [player:word]",
|
||||
"bal [player:word]",
|
||||
"pay <player:word> <amount:integer>",
|
||||
"baltop [page:integer]",
|
||||
"worth [item:item]",
|
||||
"sell hand [count:integer]",
|
||||
"sell all",
|
||||
"sell <item:item> [count:integer]",
|
||||
"buy <item:item> [count:integer]",
|
||||
"trade <player:word>",
|
||||
"trade accept [player:word]",
|
||||
"trade deny [player:word]",
|
||||
"trade cancel",
|
||||
"trade money <amount:integer>",
|
||||
"coinflip <amount:integer> [player:word]",
|
||||
"coinflip accept <player:word>",
|
||||
"coinflip cancel",
|
||||
"coinflip list",
|
||||
] {
|
||||
commands.push(NativeCommand::new(usage));
|
||||
}
|
||||
for verb in ["give", "take", "set"] {
|
||||
commands.push(NativeCommand::op(
|
||||
&format!("eco {verb} <player:word> <amount:integer>"),
|
||||
3,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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; 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<'_>,
|
||||
player: &NativeServerPlayer,
|
||||
line: &str,
|
||||
ran: &mut bool,
|
||||
) {
|
||||
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,
|
||||
};
|
||||
// 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)
|
||||
})
|
||||
.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(
|
||||
"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<Option<ItemStack>> {
|
||||
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 <amount>)", 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<Act>) {
|
||||
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<Out> {
|
||||
t.sides
|
||||
.iter()
|
||||
.map(|s| Out::Tell(s.uuid, format!("The trade didn't go through: {why}.")))
|
||||
.collect()
|
||||
};
|
||||
let stacks = |side: &smp_trade::Side<ItemStack>| -> 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<ItemStack>, them: &smp_trade::Side<ItemStack>| {
|
||||
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<Out>) {
|
||||
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 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()
|
||||
};
|
||||
let mut guard = state(dir.join(smp_store::DIR));
|
||||
let smp = guard.as_mut().expect("opened");
|
||||
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<u128> = 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.
|
||||
let sides: Vec<(u128, String)> = smp
|
||||
.trades
|
||||
.open
|
||||
.iter()
|
||||
.flat_map(|t| t.sides.iter().map(|s| (s.uuid, s.name.clone())))
|
||||
.collect();
|
||||
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.
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
[mod]
|
||||
id = "smp"
|
||||
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
|
||||
# compatibility.json): it builds on any Horizon that has them.
|
||||
[compatibility]
|
||||
horizon_revision = "f533c3af7d41d7ecc3b015a9fbb0dbf5eb64ae26"
|
||||
|
||||
[[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 = "market"
|
||||
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 = "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"
|
||||
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<crate::game::native_server::NativeCommand>", 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" },
|
||||
]
|
||||
|
||||
[[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" },
|
||||
]
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// The SMP's commands in every player's command tree.
|
||||
fn smp_register_commands(commands: &mut Vec<crate::game::native_server::NativeCommand>) {
|
||||
crate::game::smp_glue::commands(commands);
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
// 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<String, Account>,
|
||||
/// 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<String, i64>,
|
||||
}
|
||||
|
||||
/// 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<Self, String> {
|
||||
let path = dir.join("economy.json");
|
||||
let accounts = read_json(&path)?;
|
||||
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).
|
||||
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<u128>,
|
||||
to: Option<u128>,
|
||||
amount: i64,
|
||||
by: &str,
|
||||
) -> serde_json::Value {
|
||||
let after = |u: Option<u128>| 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<i64, String> {
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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
|
||||
.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<Vec<Out>> {
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
let (&name, args) = words.split_first()?;
|
||||
Some(match name {
|
||||
"balance" | "bal" => self.balance_command(me, args),
|
||||
"pay" => self.pay(me, args),
|
||||
"baltop" => self.baltop(args),
|
||||
"eco" => self.eco(me, args),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn balance_command(&mut self, me: &Player, args: &[&str]) -> Vec<Out> {
|
||||
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<Out> {
|
||||
let [who, amount] = args else {
|
||||
return vec![Out::Error("Usage: /pay <player> <amount>".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<Out> {
|
||||
let page = match args {
|
||||
[] => 1,
|
||||
[p] => match p.parse::<usize>() {
|
||||
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<Out> {
|
||||
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 <player> <amount>".into(),
|
||||
)];
|
||||
};
|
||||
let amount = match amount.parse::<i64>() {
|
||||
Ok(a) if (0..=MAX_AMOUNT).contains(&a) => a,
|
||||
_ => {
|
||||
return vec![Out::Error(
|
||||
"The amount must be a whole number from 0.".into(),
|
||||
)];
|
||||
}
|
||||
};
|
||||
let Some((uuid, account)) = self.find(who) else {
|
||||
return vec![Out::Error(format!("No player named {who} has an account."))];
|
||||
};
|
||||
let name = account.name.clone();
|
||||
let delta = match *verb {
|
||||
"give" => amount,
|
||||
"take" => -amount,
|
||||
"set" => amount - account.balance,
|
||||
_ => {
|
||||
return vec![Out::Error(
|
||||
"Usage: /eco give|take|set <player> <amount>".into(),
|
||||
)];
|
||||
}
|
||||
};
|
||||
let by = uuid_string(me.uuid);
|
||||
match self.adjust(&format!("eco_{verb}"), uuid, delta, &by) {
|
||||
Ok(after) => vec![Out::Reply(format!("{name} now has {}.", self.money(after)))],
|
||||
Err(e) => vec![Out::Error(e)],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A positive whole amount, `1250` or `1,250`.
|
||||
pub fn parse_amount(s: &str) -> Option<i64> {
|
||||
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::<i64>()
|
||||
.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<serde_json::Value> {
|
||||
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 <player> <amount>"),
|
||||
] {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<u128>,
|
||||
pub amount: i64,
|
||||
pub at: i64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Games {
|
||||
pub config: Config,
|
||||
pub offers: Vec<Offer>,
|
||||
/// When each player last played, for the cooldown.
|
||||
last: HashMap<u128, i64>,
|
||||
}
|
||||
|
||||
const USAGE: &str = "Usage: /coinflip <amount> [player], /coinflip accept <player>, /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<Out> {
|
||||
let limit = self.config.offer_seconds * 1000;
|
||||
let (old, keep): (Vec<Offer>, Vec<Offer>) =
|
||||
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<Vec<Out>> {
|
||||
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<String> = 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<Out> {
|
||||
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<Out> {
|
||||
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<Out> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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|<item> [count]`, `/buy <item> [count]`.
|
||||
// Only plain items (no damage, no name or enchantment) are traded, and only
|
||||
// by players in survival or adventure mode. Items move first, then money,
|
||||
// both logged; a sale the log refuses gives the items back. No game types:
|
||||
// the glue lends the acting player's items as a `Bag`. Added to the game as
|
||||
// `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<String, Price>,
|
||||
}
|
||||
|
||||
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<Vec<Out>> {
|
||||
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<Out> {
|
||||
let id = match args {
|
||||
[] => match bag.held() {
|
||||
Some((id, ..)) => id,
|
||||
None => {
|
||||
return vec![Out::Error(
|
||||
"Hold an item, or name one: /worth <item>".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<Out> {
|
||||
let usage = || vec![Out::Error("Usage: /sell hand|all|<item> [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<Out> {
|
||||
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 <item> [count]".into())],
|
||||
},
|
||||
_ => return vec![Out::Error("Usage: /buy <item> [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<u32> {
|
||||
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<String, u32>,
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<T: serde::de::DeserializeOwned + Default>(path: &Path) -> Result<T, String> {
|
||||
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<T: serde::Serialize>(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<File>,
|
||||
}
|
||||
|
||||
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<u128> {
|
||||
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<u32> = read_json(&path).unwrap();
|
||||
assert!(missing.is_empty());
|
||||
write_atomic(&path, b"{ not json").unwrap();
|
||||
assert!(read_json::<Vec<u32>>(&path).is_err());
|
||||
write_json(&path, &vec![1u32, 2]).unwrap();
|
||||
assert_eq!(read_json::<Vec<u32>>(&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);
|
||||
}
|
||||
}
|
||||
@@ -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<usize> {
|
||||
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<S> {
|
||||
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<i32>,
|
||||
}
|
||||
|
||||
impl<S> Side<S> {
|
||||
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<S> {
|
||||
pub sides: [Side<S>; 2],
|
||||
}
|
||||
|
||||
impl<S> Trade<S> {
|
||||
/// The side of `uuid` (0 or 1).
|
||||
pub fn side_of(&self, uuid: u128) -> Option<usize> {
|
||||
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<S> {
|
||||
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<S> {
|
||||
requests: Vec<Request>,
|
||||
pub open: Vec<Trade<S>>,
|
||||
}
|
||||
|
||||
impl<S> Default for Trades<S> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
requests: Vec::new(),
|
||||
open: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Clone + PartialEq> Trades<S> {
|
||||
pub fn trade_of(&self, uuid: u128) -> Option<&Trade<S>> {
|
||||
self.open.iter().find(|t| t.side_of(uuid).is_some())
|
||||
}
|
||||
fn index_of(&self, uuid: u128) -> Option<usize> {
|
||||
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<Out>, Vec<Act>) {
|
||||
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 <player>`, `/trade accept|deny [player]`, `/trade cancel`,
|
||||
/// `/trade money <amount>`; 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<Out>, Vec<Act>)> {
|
||||
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 <player>, /trade accept|deny [player], /trade cancel, /trade money <amount>";
|
||||
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 <amount> (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<usize>,
|
||||
inventory: Option<(usize, S)>,
|
||||
) -> (Vec<Out>, Vec<Act>) {
|
||||
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<S>]) -> 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<Cell<S>> {
|
||||
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<S>) {
|
||||
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<S> = 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<Option<S>> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
"""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);
|
||||
- the market (Steve in survival, items given from the console): /worth, /sell <item>, /sell hand, /buy, with the
|
||||
items gone from or in his inventory as his client sees it; a creative player can't sell;
|
||||
- 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.
|
||||
"""
|
||||
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",
|
||||
"difficulty": "peaceful", "spawn-monsters": "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 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 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 = {}
|
||||
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 keep_inventory true")
|
||||
time.sleep(1)
|
||||
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)
|
||||
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:
|
||||
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)
|
||||
config = json.loads((data / "config.json").read_text())
|
||||
results["config_keys"] = sorted(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)
|
||||
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
|
||||
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
|
||||
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)
|
||||
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()
|
||||
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"] = 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", "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}],
|
||||
[{"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(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))
|
||||
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)
|
||||
@@ -0,0 +1,13 @@
|
||||
//! 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_games.rs"]
|
||||
pub mod smp_games;
|
||||
#[path = "../mixins/smp/smp_market.rs"]
|
||||
pub mod smp_market;
|
||||
#[path = "../mixins/smp/smp_store.rs"]
|
||||
pub mod smp_store;
|
||||
#[path = "../mixins/smp/smp_trade.rs"]
|
||||
pub mod smp_trade;
|
||||
Reference in new issue
Block a user