Compare commits

...
Author SHA1 Message Date
Ludovic MarieandCayMac97 b1f2b343af Add crystal_optimizer 1.0.0
Submitted by @CayMac97 through the form.

Submission: uhsmZ_C-tzCN2JSB56gCZgP6U36Y9p_DJXCskvj1fC8
Co-authored-by: CayMac97 <[email protected]>
2026-10-31 21:25:57 +01:00
Ludovic Marie c6c309d01b Merge pull request #105 from horizon-rust/feat/smp-shop
SMP 0.12.0: /shop menu, daily selling limits, no profitable recipe loop, /money and /eco give *
2026-10-31 21:22:47 +01:00
Ludovic Marie a258ef03d9 Merge pull request #102 from horizon-rust/feat/smp-presence
SMP 0.11.0: first-join welcome, daily reward, AFK out of the sleep count, a command rate limit
2026-10-31 21:14:56 +01:00
Ludovic Marie acf5d8896a Merge pull request #101 from horizon-rust/feat/smp-polish
SMP 0.10.0: /back after a death or a teleport, /playtime, /claim show
2026-10-31 21:14:42 +01:00
Ludovic Marie 4dcb8fed29 Merge pull request #97 from horizon-rust/feat/smp-board
SMP 0.9.0: a sidebar of the richest players, /sidebar on|off, the Tab list's header and footer
2026-10-31 21:14:17 +01:00
Ludovic MarieandClaude Opus 5.5 f56752d74f SMP 0.12.0: /shop menu, daily selling limits, no profitable recipe loop, /money and /eco give *
- /shop: the market as a 6-row chest menu: categories (config.json `shop`), a click buys one, a shift-click 64,
  a click on a stack of yours sells it, "Sell all"; balance, today's limit and help in the bottom row. Through the
  market's own rules; items move by the console's clear/give, measured before and after.
- Market: buy always dearer than sell (else the sale closes), no recipe chain makes a sold item for less than it
  sells (smp_recipes, Bellman-Ford over the recipes; the tests walk every vanilla recipe when the data is there),
  daily limits per player (2,000 coins, 640 of each item, market.json), checked arithmetic, item ids validated.
- 56 default prices in 7 categories; a config still holding the 18 old defaults gets the new ones.
- /money give|take|set (= /eco), /eco give * <amount> for everyone on; recipients are told; all logged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:13:11 +01:00
Ludovic Marie 455a8b800f Merge branch 'feat/smp-presence' into feat/smp-shop 2026-10-31 20:47:27 +01:00
Ludovic Marie 238d9cd1f4 Merge branch 'feat/smp-polish' into feat/smp-presence 2026-10-31 20:47:18 +01:00
Ludovic Marie ddab3fa269 Merge branch 'feat/smp-board' into feat/smp-polish 2026-10-31 20:47:08 +01:00
Ludovic MarieandClaude Opus 5.5 a301fcc469 SMP: pinned to horizon main with #5390 (API 49)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:47:04 +01:00
Ludovic Marie c1ce388d41 WIP shop 2026-10-31 20:45:32 +01:00
Ludovic Marie 8bbf748e1f Merge pull request #72 from horizon-rust/feat/smp-grief
SMP: keep water, fire, pistons, explosions, buckets and blows out of claims
2026-10-31 20:17:02 +01:00
Ludovic Marie 50c23cb7d9 Merge pull request #69 from horizon-rust/feat/smp-claims
SMP: land claims (/claim, /trust)
2026-10-31 20:16:54 +01:00
Ludovic Marie 8d5c1ed7a3 Merge pull request #66 from horizon-rust/feat/smp-places
SMP: places (spawn, homes, warps, /tpa) and /rules
2026-10-31 20:16:44 +01:00
levzzz b6c1af58cd Merge pull request #103 from horizon-rust/registry/veinminer-repo
veinminer 0.3.2 (Nightly 2026-10-31)
2026-11-01 00:06:14 +05:00
Ludovic MarieandClaude Opus 5.5 3cf1b6aa8f SMP 0.11.0: first-join welcome, daily reward, AFK out of the sleep count, a command rate limit
A new presence module, looked at once a second: a player's first join is
announced and they read the welcome and the rules; each player gets a
daily reward (logged as daily_reward); a player who doesn't move, turn
or run a command for afk_minutes, or types /afk, leaves the sleep count
(native_sleep_ignored) until they move; and a player runs at most
spam_commands SMP commands in spam_seconds. All in config.json's
presence section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:05:56 +01:00
Ludovic MarieandClaude Opus 5.5 a11db16db3 SMP 0.10.0: /back after a death or a teleport, /playtime, /claim show
/back returns to where the player died or where their last teleport took
them from, with the same warmup and cooldown. /playtime reads the time
played that places already counts. /claim show draws the chunk's border
with dust particles for 10 s, to that player only, coloured by whose
land it is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:05:42 +01:00
Ludovic MarieandClaude Opus 5.5 dca26bf044 SMP: a sidebar of the richest players, /sidebar on|off, and the Tab list's header and footer
The sidebar lists the top 10 balances and the player's own rank and
balance; it is built again when a balance changed (at most every 5 s) or
every minute, and sent only to the players whose lines changed. /sidebar
off hides it, kept in board.json. The Tab list shows the server's name,
its TPS and the players on, every 5 s. An idle tick now costs a counter:
the SMP's work runs once a second, or each tick only while a teleport
warms up, a trade is open or a command's changes wait to be written.
On hooks API 43 (native_sidebar, native_tab_list, native_tick_stats). 0.9.0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:05:04 +01:00
levzzzandClaude Opus 5.5 95b8f15c6b veinminer 0.3.2: Nightly 2026-10-31
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:42:38 +05:00
levzzz be6b796a20 Merge pull request #98 from horizon-rust/remove/mod-menu
Remove mod_menu (superseded by the built-in Mods menu)
2026-10-31 23:35:44 +05:00
levzzzandClaude Opus 5.5 2cd564f16b Remove mod_menu: superseded by Horizon's built-in Mods menu
The owner retires it: 0.1.1 needs an old launcher inventory feed and the game's own Mods screen now does the same job.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:39:59 +05:00
Ludovic Marie b571298b58 Merge branch 'feat/smp-claims' into feat/smp-grief 2026-10-31 16:49:55 +01:00
Ludovic Marie fefd3e8c6b Merge branch 'feat/smp-places' into feat/smp-claims 2026-10-31 16:49:53 +01:00
Ludovic Marie 4364dbfba3 Merge remote-tracking branch 'origin/main' into feat/smp-places 2026-10-31 16:49:46 +01:00
Ludovic MarieandClaude Opus 5.5 aaf6ccefcc SMP claims: pinned to horizon main with #4739 (API 22)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:56:05 +01:00
Ludovic MarieandClaude Opus 5.5 b0e3d98b52 SMP claims: pinned to horizon#4739's head (API 22)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:52:27 +01:00
Ludovic MarieandClaude Opus 5.5 803907aaa5 SMP: keep water, fire, pistons, explosions, buckets and blows out of claims (hooks 22)
On Horizon's API 22 hook points: a fluid or fire never spreads into a
claim from land of another owner (or of a player they don't trust), a
piston never pushes or pulls blocks into one, explosions spare claimed
blocks whoever lit them, buckets are guarded where they fill or empty, and
the claim's frames, armor stands, paintings, vehicles and animals
(`claims.protected_entities`) take no blows from untrusted players. The
spread and piston hooks read the open claims without allocating and never
wait on the SMP's lock. The proof adds a world_guard step on the dedicated
server, with controls outside the claim; the claims step no longer fails
when grass spreads onto its dirt. 0.8.0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:18:36 +01:00
Ludovic MarieandClaude Opus 5.5 ec056f9f02 SMP: land claims (/claim, /trust) on the block hooks
A player claims the chunk they stand in; only they, the players they
trust and operators break or right click blocks there
(native_destroy_block, native_right_click_block), told on the action
bar (native_action_bar, API 16). Chunks a player may hold grow with
the time played, counted by places. claims.json, written atomically.
Proved on a dedicated server: a lever, a dirt block and a placement
refused to Steve in Alex's chunk, then allowed once trusted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:08:36 +01:00
Ludovic MarieandClaude Opus 5.5 63f2fa9f01 SMP places: pinned to horizon main with #4610 (API 21)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:08:34 +01:00
Ludovic MarieandClaude Opus 5.5 1ec378ac96 SMP places: hook points API 21 (main's mod screens took 18 to 20)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:59:14 +01:00
Ludovic MarieandClaude Opus 5.5 0eb6ca75c3 SMP places: hook points API 18 (main took 16 and 17)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:45:55 +01:00
Ludovic MarieandClaude Opus 5.5 26079e71ed SMP places: hook points API 16, and the proof waits for each answer
#3996's block entities took API 15 on main: horizon#4610's hooks are 16.
say() polls for the command's answer instead of a fixed 1.5 s, which a
busy machine outran.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:39:31 +01:00
Ludovic MarieandClaude Opus 5.5 b9bd159dcf SMP: places (spawn, homes, warps, /tpa) and /rules
Teleports wait a warmup that a move or a hit calls off, then run vanilla's
tp as the server (native_run_command, API 15), so they cross levels.
Homes grow with the time played here, counted by the mod. places.json
holds the spawn, warps, homes and time played, written atomically.
Proved on a dedicated server with two clients: home from the Nether,
called off by a move and by damage, /tpa, warps, operators only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:24:05 +01:00
26 changed files with 5459 additions and 192 deletions

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
id = "crystal_optimizer"
kind = "native-mod"
name = "Marlow's Crystal Optimizer"
summary = "Client-side End Crystal PvP optimizer: instant 0ms break prediction, zero miss penalty, and fast place."
description = """
Marlow's Crystal Optimizer
A native Horizon mod by CayMac, bringing the premier client-side competitive Crystal PvP utility (originally created by Bram1903 for Java) into Horizon:
0ms Instant Break Prediction: Client-side entity prediction eliminates the delay of waiting for server confirmation packets. The End Crystal despawns locally on the exact frame you strike it, allowing seamless placement and ultra-responsive combos even on higher ping.
Zero Miss Penalty (miss_time = 0): Completely removes vanilla Minecraft's artificial 10-tick (500ms) miss delay, so rapid attacks are never blocked or swallowed.
Fast Place: Removes the 4-tick right-click delay when holding Obsidian or End Crystals, enabling maximum placement speed.
Auto-Break on Hold: Seamlessly attacks crystals when holding down the attack button as your crosshair passes over them.
100% Server-Legal: Sends strictly vanilla attack and punch packets with standard reach checks. No Reach hacks, no automated macros, fully compliant with competitive CPvP rules.
Customizable: Configurable in-game via Options > Mods > Marlow's Crystal Optimizer."""
authors = ["CayMac"]
owners = ["CayMac97"]
license = "MIT"
source = "https://github.com/CayMac97/crystal-optimizer"
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
[[versions]]
version = "1.0.0"
published = "2026-10-31"
game = "26.3"
file = "crystal_optimizer-1.0.0.hnmod"
sha256 = "b58052f43a8507fb984c70a12af99853f8a1cc5526e6b127cd2468880310cc84"
signature = """
untrusted comment: signature from minisign secret key\r
RUS411U2KXdTN1bcXcXpUPQprlx2fr+YDTN9GDM9J5GJsxtn8akR1jj8rLTL2nrwpCTbiQLfja6HyW5mST6t/Q0JUkfJexlRiQU=\r
trusted comment: timestamp:1793469433\tfile:crystal_optimizer-1.0.0.hnmod\thashed\r
u0i8WpIVLETk9b+VLS5DIthfr38smS99X5/x5N9jFHmjwK1yQYMpwFWpCoorDv1qvPmYYNN/+TWDoVfun9lKDQ=="""
changelog = "Initial release for 26.3 Horizon. "
-42
View File
@@ -1,42 +0,0 @@
id = "mod_menu"
kind = "mod"
name = "Mod Menu"
summary = "Browse installed mods, including native mods, with versions, enabled states, filters and search."
description = """
An independent, sandboxed mod browser by levzzz5154.
Press **M** while playing, run **`/modmenu`**, or open
**Options → Mods → Mod Menu → Settings**. Use `/modmenu text` to search names,
ids and mod types. Filter all/enabled/disabled mods, browse pages, select a row
for details, and refresh after changing mods in the launcher.
Lists installed sandboxed, content and native source mods in the current instance.
Enabled means the launcher's selected state, not proof a mod is running. This is
a read-only menu: it does not toggle other mods, run commands, or start native builds.
**Requires a launcher with the schema-1 Mod Menu inventory feed**
([Horizon #3112](https://github.com/horizon-rust/horizon/pull/3112)). On a launcher
without it, the menu shows an explicit update message. The launcher supplies display
metadata through this mod's own sandbox storage; the mod has no general filesystem
or network access. Direct external `--dev-mod` paths and runtime failure status
are not included in the feed.
Tested on Horizon nightly `83f15fa27` (Minecraft 26.3), mod API 0.1 / API level 0.
Other Horizon revisions have unverified support. This `.hmod` does not require Rust,
Git, Horizon Mixins or any native build to install.
Independently written, not a port of another project's Mod Menu. No Minecraft code
or artwork is included. MIT licensed; source, tests and feed specification are linked below.
"""
authors = ["levzzz5154"]
owners = ["levzzz5154"]
license = "MIT"
source = "https://github.com/levzzz5154/horizon-mod-menu"
[[versions]]
version = "0.1.1"
published = "2026-10-28"
game = "26.3"
file = "mod_menu-0.1.1.hmod"
sha256 = "3833b79bbb9607c5cab6dbdc4a6d3e31754c2ed183b1c6bf431f3615b93ef3b5"
changelog = "First release: installed mods, enabled states, native/content/sandboxed types, paging, filters, search and refresh. Requires the launcher inventory feed."
+12 -5
View File
@@ -12,11 +12,10 @@ and confirm the native-code warning. A supported launcher downloads its own Rust
and builds a separate game using Horizon Mixins. Requires Git and Horizon's platform build
dependencies, and runs with your account's permissions.
Tested against Horizon nightly `83f15fa276c44711276b6ceb5f0fe0c9c534dc66`
(`nightly/build-2026-10-28-3`) with Horizon Mixins 0.5.0 at
`0500ff24aa5933fca95a877ef41125353c4b4fe3`. The launcher builds these exact pinned revisions.
Other Horizon revisions have not been validated. Ordinary Play does not compile mods;
rebuild after changing the enabled native-mod combination.
Each version is pinned to one Horizon Nightly and Horizon Mixins revision, named in its
changelog; the launcher builds those exact revisions. Other Horizon revisions have not
been validated. Ordinary Play does not compile mods; rebuild after changing the enabled
native-mod combination.
Use `/veinminer toggle`, `/veinminer on`, `/veinminer off` or `/veinminer status`.
`/veinminer reload` requires operator permission. Survival mining only; install on the
@@ -37,3 +36,11 @@ game = "26.3"
file = "veinminer-0.3.0.hnmod"
sha256 = "ec308c02c65bad64043828a19ef6254d1eb9d422bb1123cd065cb9e6e7b126c8"
changelog = "Native launcher bundle, pinned to nightly 83f15fa27 and Horizon Mixins 0.5.0."
[[versions]]
version = "0.3.2"
published = "2026-10-31"
game = "26.3"
file = "veinminer-0.3.2.hnmod"
sha256 = "31a4249da1b35972ae8b34201373c86352f4adc2f5cb3e015749d1fbfecf00a0"
changelog = "Pinned to Nightly 2026-10-31 (d91cab6) and Horizon Mixins 4342a0b. Reads each level's height from the loaded world, so data-pack dimension heights are respected."
+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "horizon-mod-smp"
version = "0.5.0"
version = "0.12.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "horizon-mod-smp"
version = "0.5.0"
version = "0.12.0"
edition = "2024"
license = "MIT"
publish = false
+106 -9
View File
@@ -8,15 +8,19 @@ Players install nothing: it speaks only what a stock Horizon client already show
lines, vanilla chest menus the server fills with ordinary items (the trade menu); later sounds, titles, the action
bar, boss bars and the sidebar).
This version: the economy, the server market, player trades, the coinflip and the lottery.
This version: the economy, the server market and its `/shop` menu, player trades, the coinflip, the lottery, places (spawn, homes, warps,
teleport requests, the rules), land claims, and the scoreboard (a sidebar of the richest players, the Tab list's
header and footer).
| Command | Does |
|---|---|
| `/balance [player]`, `/bal` | your balance, or another player's |
| `/pay <player> <amount>` | gives money to a player who has an account (anyone who has played since the mod was on) |
| `/baltop [page]` | the richest players, 10 a page |
| `/eco give\|take\|set <player> <amount>` | operators of level 3: corrections, logged |
| `/worth [item]` | what the market pays and asks for an item (the one in your hand without a name) |
| `/eco give\|take\|set <player> <amount>`, `/money ...` | operators of level 3: corrections, logged (`/money` is the same command); the player given money is told |
| `/eco give * <amount>` | operators of level 3: to every player on, one logged change each |
| `/shop` | the market as a chest menu: categories, a click buys one, a shift-click 64, a click on a stack of yours sells it, "Sell all" |
| `/worth [item]` | what the market pays and asks for an item (the one in your hand without a name), and what you may still sell today |
| `/sell hand [count]`, `/sell <item> [count]`, `/sell all` | sells to the market: the stack in your hand, an item, or everything it buys |
| `/buy <item> [count]` | buys from the market, only what fits in your inventory |
| `/trade <player>` | asks a player to trade (60 s to answer) |
@@ -26,11 +30,61 @@ This version: the economy, the server market, player trades, the coinflip and th
| `/coinflip <amount> [player]` | stakes an amount for anyone (or one player) to match; off unless `gambling.enabled` |
| `/lottery`, `/lottery buy <tickets>` | the pot, your tickets and the time to the draw; buys tickets (off unless `gambling.enabled`) |
| `/coinflip accept <player>`, `/coinflip cancel`, `/coinflip list` | takes an offer (a fair coin decides, the winner gets both stakes), withdraws yours, lists those waiting |
| `/spawn` | to the server's spawn; `/setspawn` (operators of level 3) sets it where you stand |
| `/home [name]`, `/sethome [name]`, `/delhome [name]`, `/homes` | your homes (`home` when unnamed): go, set or move one where you stand, delete, list |
| `/warp <name>`, `/warps` | to a warp, the list; `/setwarp <name>`, `/delwarp <name>` for operators of level 3 |
| `/tpa <player>`, `/tpaccept [player]`, `/tpdeny [player]` | asks to go to a player (60 s to answer); answers a request |
| `/rules` | the server's rules |
| `/back` | back to where you died, or where your last teleport took you from (warmup and cooldown as any teleport) |
| `/afk` | marks you away from the keyboard until you move, turn or run a command |
| `/playtime [player]` | how long you (or a player who is on) have played here |
| `/claim`, `/claim info`, `/unclaim` | claims the chunk you stand in (all its height), says whose it is, frees it (operators of level 3: anyone's) |
| `/claim show` | draws the border of the chunk you stand in for 10 s, for you only: green yours or a trusting owner's, red someone else's, white free |
| `/claims` | your chunks, how many you may hold, and who you trust |
| `/trust <player>`, `/untrust <player>` | lets a player (or no longer) build and use things on all your land |
| `/sidebar off`, `/sidebar on`, `/sidebar` | hides the richest players' sidebar for you (remembered), shows it again |
The market trades only plain items (not worn, named or enchanted), and only with players in survival or adventure
mode. Items move first, then money, both logged; a sale the log refuses gives the items back, and a purchase checks the
money and the room before anything moves.
**No way to get rich from the market.** The market makes money only from what players gather, and only so much a day:
- Buying always costs more than selling: an item whose `sell` is not below its `buy` isn't bought (the server log
says so at the start).
- No chain of recipes turns what the market sells into something it buys for more: the cheapest way to get each item
from the market (bought, or crafted, smelted, cut from bought items, through any number of recipes) is worked out,
and an item the market would buy for more than that isn't bought. The tests walk every vanilla recipe
(`SMP_VANILLA_DATA=<.local/horizon-client/26.3/data/minecraft> cargo test`); the server checks the iron, gold,
copper, diamond, emerald, coal, redstone, lapis, wheat, wood and food chains at each start.
- Each player sells at most `daily_sell_coins` (2,000 coins) and `daily_sell_items` (640 of each item) a UTC day,
`/worth` and the shop show what is left, and `market.json` keeps the day's count across restarts.
- Amounts are checked: no price above 1,000,000,000,000, no sum that overflows.
- The coinflip and the lottery give the winner the stakes (less `fee_percent`, 0 by default): they move money
between players and never make any.
Default prices (coins, buy / sell; `-` not traded that way):
| Category | Items |
|---|---|
| Farming | wheat 3/1, wheat seeds 2/-, carrot 3/1, potato 3/1, beetroot 3/1, sugar cane 3/1, pumpkin 8/3, melon slice 2/1, cactus 3/1, bone meal 3/- |
| Mining | coal 8/2, copper ingot 6/2, iron ingot 30/8, gold ingot 40/10, redstone 4/1, lapis 8/2, quartz 8/2, emerald 80/20, diamond 400/100 |
| Wood | the eight logs 4/1 |
| Mob drops | rotten flesh -/1, bone 3/1, string 3/1, gunpowder 8/2, spider eye 4/1, slime ball 10/2, blaze rod 30/8, leather 6/2, feather 2/1 |
| Building | cobblestone 1/-, stone 2/-, dirt 1/-, sand 2/-, gravel 2/-, glass 3/-, bricks 4/-, stone bricks 3/- |
| Food | bread 5/-, cooked beef 6/-, cooked porkchop 6/-, cooked chicken 5/-, baked potato 4/-, apple 4/-, golden carrot 40/- |
| Tools | torch 1/-, ladder 2/-, crafting table 4/-, chest 6/-, furnace 6/- |
A `config.json` still holding the 18 prices of versions 0.1 to 0.11, never edited, gets these at the first start of
0.12.0; edited prices are kept. Only items that stack to 64 belong in the market: a shift-click buys 64, and what
doesn't fit falls at the player's feet for them.
**The shop.** `/shop` opens a 6-row chest menu on the market's prices: the categories of `config.json`'s `shop` (an
item in none goes under "Other"), each item named with its prices. A click buys one, a shift-click 64; a click on a
stack in your own inventory, below the menu, sells it; "Sell all" sells everything the market buys. The bottom row
shows your balance and today's selling limit. It goes through the market's own rules (survival or adventure only,
items then money, logged, the daily limits); the items move by the console's `clear` and `give`, measured on the
inventory before and after, so the money follows what really moved.
**Trades.** Once accepted, both players see a 6-row chest menu: their own offer on the left (16 stacks), the other's
on the right, each side's money, their confirm and cancel buttons, and whether the other has confirmed. A click on a
stack in your inventory, below the menu, adds it to your offer; a click on it in your offer takes it back. The menu
@@ -52,6 +106,41 @@ from a round's first ticket to its draw). Tickets are held like coinflip stakes;
random among all sold (more tickets, more chances), its owner gets the pot (less `fee_percent`), and everyone hears
it. A round with a single player, or one the server stopped with, gives every ticket back.
**Places.** A teleport waits `warmup_seconds` (3) in `config.json`'s `places`; a player who moves or is hurt meanwhile
stays where they are, and `cooldown_seconds` (10) passes between two of one player's teleports. `/tpa` takes the
asker to where the other player is when the warmup ends. Homes grow with the time played on the server since the mod
was on (`homes`: `[hours, homes]`, by default 1 home, 2 from 5 hours, 3 from 20, 5 from 100). `rules` holds `/rules`'
lines. Teleports are vanilla's `tp`, run as the server (`execute in <level> run tp`), so they cross levels and land
as any teleport does.
**Claims.** In a claimed chunk, only its owner, the players they trust and operators of level 3 break blocks or right
click blocks: doors, chests, levers, buttons, placing a block, a bucket or flint on a block. Anyone else's click does
nothing, and the action bar says whose land it is. The chunks a player may hold grow with the time played here
(`claims.chunks`: `[hours, chunks]`, by default 4, 16 from 5 hours, 36 from 20, 64 from 100); `claims.enabled`
turns them off. The world stays out too: no water or lava flows, no fire spreads or burns, and no piston pushes into
a claim from land that isn't its owner's (or a player's they trust), and explosions break no claimed block, whoever
lit them. Buckets are guarded where they fill or empty, and hitting the claim's item frames, armour stands,
paintings, boats, minecarts and animals does nothing for others (`claims.protected_entities`, entity ids; monsters
and players aren't in it). Not guarded yet: right clicking an entity (rotating a frame's item, an armour stand's
items, shearing, leads), which waits for Horizon's hook (#4648). The server's `spawn-protection` (vanilla's) guards
the spawn.
**The scoreboard.** A sidebar on the right lists the `board.top` (10) richest players with their balance (`125.4k`,
`3.2M`), then a gap and your own rank and balance. It's rebuilt only when a balance changed, at
most every `board.refresh_seconds` (60), and sent only to the players whose lines changed: nothing runs per tick per
player. The Tab list shows `board.tab_header` and `board.tab_footer` (`{server}`, `{tps}`, `{players}` filled in,
every `board.tab_refresh_seconds`, 5). `board.sidebar` and `board.tab` turn either off; `board.title` and
`board.server_name` name them. The sidebar uses one scoreboard objective of its own (`horizon:native_sidebar`) and
leaves the world's scoreboard alone.
**Presence.** A player's first join is announced to everyone (`presence.first_join`, empty for none) and they read
`presence.welcome` and the rules. Each player gets `presence.daily_reward` (50) once a day (UTC) for being on, logged
as `daily_reward` (0 turns it off). A player who hasn't moved, turned or run a command for `presence.afk_minutes` (5),
or typed `/afk`, is AFK: they don't count for sleeping (vanilla's `players_sleeping_percentage`) until they move or
get in a bed; `presence.afk_announce` tells everyone instead of only them. A player runs at most
`presence.spam_commands` (8) of the SMP's commands in `presence.spam_seconds` (5); operators of level 3 aren't
limited. All of it is looked at once a second.
The currency is earned in game only: it can't be bought with real money or cashed out (Mojang's usage guidelines).
## Data
@@ -61,8 +150,13 @@ folder is untouched until the mod first runs on it; it changes no block and no p
| File | Holds |
|---|---|
| `config.json` | `currency` (default `coins`), `starting_balance` (default 0), and `market`: `enabled` and `items`, each item's `buy` (what the market asks) and `sell` (what it pays), 0 closing that side; written with the defaults the first time, and again when a newer version adds keys (your values kept) |
| `config.json` | `currency` (default `coins`), `starting_balance` (default 0), `market`: `enabled`, `items` (each item's `buy`, what the market asks, and `sell`, what it pays, 0 closing that side), `daily_sell_coins` (2000) and `daily_sell_items` (640), 0 for no limit; `shop`: `enabled`, `title`, `categories` (`name`, `icon`, `items`); written with the defaults the first time, and again when a newer version adds keys (your values kept) |
| `economy.json` | balances by player UUID, with the name last seen, and `held`: coinflip stakes and lottery tickets being decided |
| `places.json` | the spawn, the warps, each player's homes, and the seconds each player has played (written once a minute) |
| `market.json` | what each player sold today (coins and items), for the daily limits |
| `presence.json` | the players who have joined (no welcome again), and the day each last got the daily reward |
| `board.json` | the players who hid the sidebar (`/sidebar off`) |
| `claims.json` | each claimed chunk (`<dimension> <chunk x> <chunk z>`) with its owner, and whom each owner trusts |
| `audit.log` | one JSON line per change, written before the change: `time` (ms), `kind` (`open`, `pay`, `eco_give`...), `amount`, `from`/`to` with their balance before, `by` for an operator; a trade's line (`trade`: both players, their items and money) comes before its swap, `trade_failed` after one that didn't go through |
`economy.json` is written whole to a temporary file, flushed and renamed over the old one, at most once a server tick
@@ -72,15 +166,18 @@ commands say so) rather than start from nothing.
## Made on the hook points
It hooks only Horizon's native hook points: `native_register_commands` and `native_perform_command` (API 8), the
acting player's items (API 9), the server menus and `native_exchange_items` (API 10), the server tick and the world
lifecycle. `compatibility.json` names `"hooks": 10`.
acting player's items (API 9), the server menus and `native_exchange_items` (API 10), the players' health and
`native_run_command` (API 21), `native_destroy_block` (API 14), `native_right_click_block`, the acting player and the
action bar (API 21), `native_block_spread`, `native_piston_move`, `native_explosion_detonate`, `native_fill_bucket` and
`native_attack_entity` (API 22), `native_sidebar`, `native_tab_list` and `native_tick_stats` (API 49), the server tick
and the world lifecycle. `compatibility.json` names `"hooks": 49`.
| File | What it is |
|---|---|
| `mixins/smp/horizon-mixin.toml` | The bundle: seven modules and the HEAD hooks |
| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs`, `smp_trade.rs`, `smp_games.rs`, `smp_lottery.rs` | The rules, no game types (a trade's stacks are a type parameter): added to the game as `game::smp_store`, `game::smp_economy`, `game::smp_market`, `game::smp_trade`, `game::smp_games`, `game::smp_lottery`; also this crate's modules (`src/lib.rs`), for their tests |
| `mixins/smp/horizon-mixin.toml` | The bundle: thirteen modules and the HEAD hooks |
| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs`, `smp_recipes.rs`, `smp_shop.rs`, `smp_trade.rs`, `smp_games.rs`, `smp_lottery.rs`, `smp_places.rs`, `smp_claims.rs`, `smp_board.rs`, `smp_presence.rs` | The rules, no game types (a trade's stacks are a type parameter): added to the game as `game::smp_store`, `game::smp_economy`, `game::smp_market`, `game::smp_recipes`, `game::smp_shop`, `game::smp_trade`, `game::smp_games`, `game::smp_lottery`, `game::smp_places`, `game::smp_claims`, `game::smp_board`, `game::smp_presence`; also this crate's modules (`src/lib.rs`), for their tests |
| `mixins/smp/glue.rs` | The state for the open world and the edges to the game's types (`game::smp_glue`) |
| `mixins/smp/{register,command,tick,lifecycle,menu_clicked,menu_closed}.rs` | One small helper per hook |
| `mixins/smp/{register,command,tick,lifecycle,menu_clicked,menu_closed,destroy_block,right_click_block,block_spread,piston_move,explosion_detonate,fill_bucket,attack_entity}.rs` | One small helper per hook |
| `proof.py` | A dedicated server built with the mod and two real clients, on fresh temporary data |
## Build and prove
+2 -2
View File
@@ -1,8 +1,8 @@
{
"game": "26.3",
"hooks": 10,
"hooks": 49,
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "f533c3af7d41d7ecc3b015a9fbb0dbf5eb64ae26",
"horizon_revision": "5272f3ad64bc68e1f80068a201f38d442ea01fbc",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
}
+10
View File
@@ -0,0 +1,10 @@
// A player hits an entity: a claim's frames, armor stands and animals are
// kept from players its owner doesn't trust.
fn smp_attack_entity(
game: &mut crate::game::Game<'_>,
kind: &str,
pos: [f64; 3],
canceled: &mut bool,
) {
crate::game::smp_glue::attack(game, kind, pos, canceled);
}
+14
View File
@@ -0,0 +1,14 @@
// A fluid flows or fire spreads into a block: kept out of the land others
// claimed.
fn smp_block_spread(
dimension: crate::game::LevelDimension,
from: horizon_core::position::BlockPos,
to: horizon_core::position::BlockPos,
canceled: &mut bool,
) {
let dimension = crate::game::smp_glue::dimension_id(dimension);
let (from, to) = ([from.x, from.y, from.z], [to.x, to.y, to.z]);
if !crate::game::smp_glue::world_allows(dimension, from, &[to]) {
*canceled = true;
}
}
+4
View File
@@ -0,0 +1,4 @@
// A player breaks a block: kept out of the land others claimed.
fn smp_destroy_block(game: &mut crate::game::Game<'_>, pos: [i32; 3], canceled: &mut bool) {
crate::game::smp_glue::guard(game, pos, canceled);
}
@@ -0,0 +1,8 @@
// An explosion's blocks: claimed land is spared.
fn smp_explosion_detonate(
game: &crate::game::Game<'_>,
dimension: &str,
blocks: &mut Vec<[i32; 3]>,
) {
crate::game::smp_glue::explosion(game, dimension, blocks);
}
+4
View File
@@ -0,0 +1,4 @@
// A player fills or empties a bucket: kept out of the land others claimed.
fn smp_fill_bucket(game: &mut crate::game::Game<'_>, pos: [i32; 3], canceled: &mut bool) {
crate::game::smp_glue::guard(game, pos, canceled);
}
+727 -24
View File
@@ -1,18 +1,25 @@
// 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, smp_lottery). Added to
// between the game's types and the rules (smp_economy, smp_market, smp_shop,
// smp_trade, smp_games, smp_lottery, smp_places, smp_claims). 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
// tick.rs, lifecycle.rs, menu_clicked.rs, menu_closed.rs, destroy_block.rs,
// right_click_block.rs, block_spread.rs, piston_move.rs,
// explosion_detonate.rs, fill_bucket.rs, attack_entity.rs) call it from the
// hook points.
use crate::game::{
Game,
native_server::{NativeCommand, NativeServerPlayer},
native_server::{NativeCommand, NativeServerPlayer, NativeSidebar},
smp_board::{self, Board, Line},
smp_claims::{self, Claims},
smp_economy::{self, Economy, Out, Player},
smp_games::{self, Games},
smp_lottery::Lottery,
smp_places::{self, Body, Place, Places},
smp_presence::{self, Presence},
native_menus::native_menu_inventory_slot,
smp_market::{self, Bag, Market},
smp_shop::{self, Shop},
smp_store,
smp_trade::{self, Act, Cell, Trades},
};
@@ -26,7 +33,7 @@ use std::{path::PathBuf, sync::Mutex};
/// The mod's id, name and version.
pub const ID: &str = "smp";
pub const NAME: &str = "SMP";
pub const VERSION: &str = "0.5.0";
pub const VERSION: &str = "0.12.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).
@@ -36,7 +43,12 @@ pub struct Config {
#[serde(flatten)]
pub economy: smp_economy::Config,
pub market: smp_market::Config,
pub shop: smp_shop::Config,
pub gambling: smp_games::Config,
pub places: smp_places::Config,
pub claims: smp_claims::Config,
pub board: smp_board::Config,
pub presence: smp_presence::Config,
}
/// The SMP of the open world.
@@ -45,13 +57,35 @@ pub struct Smp {
/// An error when `economy.json` can't be read: the commands say so.
economy: Result<Economy, String>,
market: Market,
shop_config: smp_shop::Config,
/// The `/shop` menus open.
shop: Shop,
trades: Trades<ItemStack>,
games: Games,
lottery: Lottery,
/// An error when `places.json` can't be read: the commands say so.
places: Result<Places, String>,
/// An error when `claims.json` can't be read: nothing is guarded then,
/// and the commands say so.
claims: Result<Claims, String>,
/// An error when `board.json` can't be read: no sidebar then.
board: Result<Board, String>,
/// An error when `presence.json` can't be read: no welcome, reward or
/// AFK then.
presence: Result<Presence, String>,
/// `/claim show`: whose chunk border is drawn (level, chunk x, chunk z)
/// until when.
shows: std::collections::HashMap<u128, (String, i32, i32, i64)>,
}
static STATE: Mutex<Option<Smp>> = Mutex::new(None);
static TICKS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Whether the next tick has work (a teleport warming up, a trade open, a
/// command's changes to write): otherwise only one tick a second does any.
static BUSY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
/// Sleep-count changes decided inside a player's command, for the next
/// second's tick.
static PENDING_SLEEP: Mutex<Vec<smp_presence::Act>> = Mutex::new(Vec::new());
fn open(dir: PathBuf) -> Smp {
let config_path = dir.join("config.json");
@@ -80,23 +114,53 @@ fn open(dir: PathBuf) -> Smp {
(Config::default(), false)
}
};
let mut config = config;
// Prices never edited: today's defaults (the balances are untouched).
let write = smp_market::migrate(&mut config.market) || write;
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 (market, problems) = Market::open(&dir, config.market);
for p in problems {
log::warn!("[smp] {p}");
}
let economy = Economy::open(&dir, config.economy);
if let Err(e) = &economy {
log::error!("[smp] the economy is off: {e}");
}
let places = Places::open(&dir, config.places);
if let Err(e) = &places {
log::error!("[smp] homes and warps are off: {e}");
}
let claims = Claims::open(&dir, config.claims);
if let Err(e) = &claims {
log::error!("[smp] claims are off: {e}");
}
let board = Board::open(&dir, config.board);
if let Err(e) = &board {
log::error!("[smp] the sidebar is off: {e}");
}
// Players the economy knows were here before: no first-join welcome.
let known = economy.as_ref().map(|e| e.accounts().into_iter().map(|a| a.0).collect()).unwrap_or_else(|_| Vec::new());
let presence = Presence::open(&dir, config.presence, known);
if let Err(e) = &presence {
log::error!("[smp] welcomes, daily rewards and AFK are off: {e}");
}
Smp {
dir,
economy,
market: Market {
config: config.market,
},
market,
shop_config: config.shop,
shop: Shop::default(),
trades: Trades::default(),
games: Games::new(config.gambling),
lottery: Lottery::default(),
places,
claims,
board,
presence,
shows: Default::default(),
}
}
@@ -122,6 +186,29 @@ fn save(smp: &mut Smp) {
{
log::error!("[smp] {err}");
}
if let Err(err) = smp.market.save() {
log::error!("[smp] {err}");
}
if let Ok(p) = &mut smp.places
&& let Err(err) = p.save(chrono::Utc::now().timestamp_millis())
{
log::error!("[smp] {err}");
}
if let Ok(c) = &mut smp.claims
&& let Err(err) = c.save()
{
log::error!("[smp] {err}");
}
if let Ok(b) = &mut smp.board
&& let Err(err) = b.save()
{
log::error!("[smp] {err}");
}
if let Ok(p) = &mut smp.presence
&& let Err(err) = p.save()
{
log::error!("[smp] {err}");
}
}
/// The commands, for every player's command tree.
@@ -136,6 +223,7 @@ pub fn commands(commands: &mut Vec<NativeCommand>) {
"sell all",
"sell <item:item> [count:integer]",
"buy <item:item> [count:integer]",
"shop",
"trade <player:word>",
"trade accept [player:word]",
"trade deny [player:word]",
@@ -147,14 +235,44 @@ pub fn commands(commands: &mut Vec<NativeCommand>) {
"coinflip list",
"lottery",
"lottery buy <tickets:integer>",
"spawn",
"home [name:word]",
"sethome [name:word]",
"delhome [name:word]",
"homes",
"warp <name:word>",
"warps",
"tpa <player:word>",
"tpaccept [player:word]",
"tpdeny [player:word]",
"rules",
"back",
"playtime [player:word]",
"claim",
"claim info",
"claim show",
"unclaim",
"claims",
"trust <player:word>",
"untrust <player:word>",
"sidebar",
"sidebar on",
"sidebar off",
"afk",
] {
commands.push(NativeCommand::new(usage));
}
for verb in ["give", "take", "set"] {
commands.push(NativeCommand::op(
&format!("eco {verb} <player:word> <amount:integer>"),
3,
));
for usage in ["setspawn", "setwarp <name:word>", "delwarp <name:word>"] {
commands.push(NativeCommand::op(usage, 3));
}
for name in ["eco", "money"] {
for verb in ["give", "take", "set"] {
commands.push(NativeCommand::op(
&format!("{name} {verb} <player:word> <amount:integer>"),
3,
));
}
commands.push(NativeCommand::op(&format!("{name} give * <amount:integer>"), 3));
}
}
@@ -182,10 +300,29 @@ impl Bag for GameBag<'_, '_> {
}
}
const COMMANDS: [&str; 11] = [
"balance", "bal", "pay", "baltop", "eco", "worth", "sell", "buy", "trade", "coinflip", "lottery",
const COMMANDS: [&str; 13] = [
"balance", "bal", "pay", "baltop", "eco", "money", "worth", "sell", "buy", "shop", "trade", "coinflip",
"lottery",
];
/// The players on, as the places see them.
fn bodies(players: &[NativeServerPlayer]) -> Vec<Body> {
players
.iter()
.map(|p| Body {
uuid: p.uuid,
name: p.name.clone(),
at: Place {
dimension: p.dimension.clone(),
x: p.position[0],
y: p.position[1],
z: p.position[2],
},
health: p.health,
})
.collect()
}
/// A random number below `n` (n > 0), never the world's random: a fresh
/// SipHash key (seeded from the system's randomness) over the time.
fn below(n: u64) -> u64 {
@@ -209,9 +346,18 @@ pub(in crate::game) fn perform(
line: &str,
ran: &mut bool,
) {
if !game.native_on(ID) || !COMMANDS.contains(&line.split(' ').next().unwrap_or_default()) {
let word = line.split(' ').next().unwrap_or_default();
let ours = [
&COMMANDS[..],
&smp_places::COMMANDS,
&smp_claims::COMMANDS,
&smp_board::COMMANDS,
&smp_presence::COMMANDS,
];
if !game.native_on(ID) || !ours.iter().any(|c| c.contains(&word)) {
return;
}
BUSY.store(true, std::sync::atomic::Ordering::Relaxed);
let Some(dir) = game.native_world_dir() else {
return;
};
@@ -233,11 +379,95 @@ pub(in crate::game) fn perform(
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 {
// Any SMP command is activity (no longer AFK), and past the limit waits.
let mut sleeps = Vec::new();
let mut before = Vec::new();
if let Ok(p) = &mut smp.presence {
match p.allow(&me, now) {
Ok((o, a)) => (before, sleeps) = (o, a),
Err(o) => {
drop(guard);
*ran = true;
show(game, vec![o]);
return;
}
}
}
let out = if smp_presence::COMMANDS.contains(&word) {
match &mut smp.presence {
Ok(p) => match p.command(&me, line) {
Some((o, a)) => {
sleeps.extend(a);
o
}
None => Vec::new(),
},
Err(_) => vec![Out::Error(
"AFK is off: presence.json can't be read (see the server log).".into(),
)],
}
} else if smp_board::COMMANDS.contains(&word) {
match &mut smp.board {
Ok(b) => {
let (out, show) = b.command(&me, line).unwrap_or_default();
// Shown again at the next second's update; taken away now.
if show == Some(false) {
game.native_sidebar(me.uuid, None);
}
out
}
Err(_) => vec![Out::Error(
"The sidebar is off: its file can't be read (see the server log).".into(),
)],
}
} else if line.split_whitespace().eq(["claim", "show"]) {
match bodies(&players).into_iter().find(|b| b.uuid == me.uuid) {
Some(b) => {
let (cx, cz) = ((b.at.x.floor() as i32) >> 4, (b.at.z.floor() as i32) >> 4);
smp.shows.insert(me.uuid, (b.at.dimension, cx, cz, now + 10_000));
vec![Out::Reply(
"This chunk's border, for 10 s: green yours, red someone else's, white free.".into(),
)]
}
None => vec![Out::Error("You are not in the world.".into())],
}
} else if smp_claims::COMMANDS.contains(&word) {
let at = bodies(&players).into_iter().find(|b| b.uuid == me.uuid).map(|b| b.at);
let played = smp.places.as_ref().map_or(0, |p| p.played_seconds(me.uuid));
let economy = smp.economy.as_ref().ok();
let find = |n: &str| {
let on = players.iter().find(|p| p.name.eq_ignore_ascii_case(n));
on.map(|p| (p.uuid, p.name.clone()))
.or_else(|| economy?.find(n).map(|(u, a)| (u, a.name.clone())))
};
let name_of = |u: u128| {
let on = players.iter().find(|p| p.uuid == u).map(|p| p.name.clone());
on.or_else(|| economy?.name_of(u).map(str::to_owned))
.unwrap_or_else(|| smp_store::uuid_string(u))
};
match (&mut smp.claims, at) {
(Ok(c), Some(at)) => c.command(&me, &at, played, &find, &name_of, line).unwrap_or_default(),
(Ok(_), None) => vec![Out::Error("You are not in the world.".into())],
(Err(_), _) => vec![Out::Error(
"Claims are off: their file can't be read (see the server log).".into(),
)],
}
} else if smp_places::COMMANDS.contains(&word) {
match &mut smp.places {
Ok(p) => p.command(&me, &bodies(&players), now, line).unwrap_or_default(),
Err(_) => vec![Out::Error(
"Homes and warps are off: their file can't be read (see the server log).".into(),
)],
}
} else if word == "shop" {
open_shop(game, smp, &me)
} else {
match &mut smp.economy {
Ok(e) => {
e.seen(me.uuid, &me.name);
let on: Vec<(u128, String)> = players.iter().map(|p| (p.uuid, p.name.clone())).collect();
let mut bag = GameBag(game);
e.command(&me, line)
e.command(&me, &on, 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)?;
@@ -254,11 +484,107 @@ pub(in crate::game) fn perform(
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);
// Inside the player's command: the sleep count changes at the next
// second's tick (`native_sleep_ignored` works outside a player's hook).
if !sleeps.is_empty() {
PENDING_SLEEP.lock().unwrap_or_else(|e| e.into_inner()).extend(sleeps);
}
before.extend(out);
show(game, before);
}
/// A player breaks or right clicks the block at `pos`: canceled in a chunk
/// claimed by someone who doesn't trust them (`native_destroy_block`,
/// `native_right_click_block`).
pub(in crate::game) fn guard(game: &mut Game<'_>, pos: [i32; 3], canceled: &mut bool) {
if *canceled || !game.native_on(ID) {
return;
}
let (Some(who), Some(dir)) = (game.native_acting_player(), game.native_world_dir()) else {
return;
};
let mut guard = state(dir.join(smp_store::DIR));
let smp = guard.as_mut().expect("opened");
let Ok(claims) = &mut smp.claims else {
return;
};
let me = Player {
uuid: who.uuid,
name: who.name.clone(),
level: who.permission,
};
let economy = smp.economy.as_ref().ok();
let name_of = |u: u128| {
economy
.and_then(|e| e.name_of(u))
.map_or_else(|| smp_store::uuid_string(u), str::to_owned)
};
let now = chrono::Utc::now().timestamp_millis();
if let Err(line) = claims.check(&me, &who.dimension, pos, &name_of, now) {
*canceled = true;
drop(guard);
if let Some(line) = line {
game.native_action_bar(who.uuid, Text::literal(line).with_color(text::RED));
}
}
}
/// A player hits the entity of type `kind` at `pos`: canceled when claims
/// protect that type and the chunk is claimed by someone who doesn't trust
/// them (`native_attack_entity`).
pub(in crate::game) fn attack(game: &mut Game<'_>, kind: &str, pos: [f64; 3], canceled: &mut bool) {
if *canceled || !game.native_on(ID) {
return;
}
let protected = STATE
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
.is_some_and(|s| s.claims.as_ref().is_ok_and(|c| c.protects(kind)));
if protected {
guard(game, pos.map(|v| v.floor() as i32), canceled);
}
}
/// The world's dimension id, as players' places name it.
pub(in crate::game) fn dimension_id(dimension: crate::game::LevelDimension) -> &'static str {
match dimension {
crate::game::LevelDimension::Overworld => "minecraft:overworld",
crate::game::LevelDimension::Nether => "minecraft:the_nether",
crate::game::LevelDimension::End => "minecraft:the_end",
}
}
/// Whether the world may change the blocks `to` from `from` (a fluid, fire,
/// a piston's blocks): not into a claim from land of another owner. Asked
/// on every flow: it only reads the claims already open, and allows while
/// the SMP's state is busy (a hook inside the SMP's own call).
pub(in crate::game) fn world_allows(dimension: &str, from: [i32; 3], to: &[[i32; 3]]) -> bool {
let Ok(state) = STATE.try_lock() else {
return true;
};
match state.as_ref().map(|s| &s.claims) {
Some(Ok(c)) => to.iter().all(|&t| c.spread_allowed(dimension, from, t)),
_ => true,
}
}
/// An explosion is about to break `blocks`: claimed land is spared
/// (`native_explosion_detonate`).
pub(in crate::game) fn explosion(game: &Game<'_>, dimension: &str, blocks: &mut Vec<[i32; 3]>) {
if !game.native_on(ID) {
return;
}
let Ok(state) = STATE.try_lock() else {
return;
};
if let Some(Ok(c)) = state.as_ref().map(|s| &s.claims) {
c.spare(dimension, blocks);
}
}
/// An item named `name` (not in italics, as a name the game gives).
@@ -434,6 +760,10 @@ pub(in crate::game) fn menu_clicked(game: &mut Game<'_>, uuid: u128, window: i32
};
let mut guard = state(dir.join(smp_store::DIR));
let smp = guard.as_mut().expect("opened");
if smp.shop.views.get(&uuid).is_some_and(|v| v.0 == Some(window)) {
shop_clicked(game, smp, uuid, slot, mode == 1);
return;
}
let Some(t) = smp.trades.trade_of(uuid) else {
return;
};
@@ -466,6 +796,10 @@ pub(in crate::game) fn menu_closed(game: &mut Game<'_>, uuid: u128, window: i32)
};
let mut guard = state(dir.join(smp_store::DIR));
let smp = guard.as_mut().expect("opened");
if smp.shop.views.get(&uuid).is_some_and(|v| v.0 == Some(window)) {
smp.shop.views.remove(&uuid);
return;
}
let Some(t) = smp.trades.trade_of(uuid) else {
return;
};
@@ -480,6 +814,224 @@ pub(in crate::game) fn menu_closed(game: &mut Game<'_>, uuid: u128, window: i32)
show(game, out);
}
/// A player's items outside their command (a click in a menu): read from
/// their inventory, moved by the console's `clear` and `give`, each move
/// measured on the inventory before and after.
struct MenuBag<'a, 'g> {
game: &'a mut Game<'g>,
uuid: u128,
name: String,
}
impl MenuBag<'_, '_> {
fn stacks(&self) -> Vec<Option<ItemStack>> {
self.game.native_player_stacks(self.uuid).unwrap_or_default()
}
/// Every stack of `item`, plain or not (`clear` takes any).
fn total(&self, item: &str) -> u32 {
self.stacks().iter().flatten().filter(|s| &*s.item == item).map(|s| s.count).sum()
}
}
fn plain(s: &ItemStack) -> bool {
s.damage == 0 && s.extra.is_none()
}
impl Bag for MenuBag<'_, '_> {
fn held(&self) -> Option<(String, u32, bool)> {
None
}
fn carried(&self) -> Vec<(String, u32)> {
let mut out: Vec<(String, u32)> = Vec::new();
for s in self.stacks().iter().flatten().filter(|s| plain(s)) {
match out.iter_mut().find(|(id, _)| **id == *s.item) {
Some((_, n)) => *n += s.count,
None => out.push((s.item.to_string(), s.count)),
}
}
out
}
/// The plain ones; none while a named or worn one is carried, which
/// `clear` could take in their place.
fn count(&mut self, item: &str) -> u32 {
let stacks = self.stacks();
let of = || stacks.iter().flatten().filter(|s| &*s.item == item);
if of().any(|s| !plain(s)) {
return 0;
}
of().map(|s| s.count).sum()
}
fn take(&mut self, item: &str, count: u32) -> u32 {
if !smp_market::valid_id(item) || count == 0 {
return 0;
}
let before = self.total(item);
self.game.native_run_command(&format!("clear {} {item} {count}", self.name));
before.saturating_sub(self.total(item))
}
fn room(&mut self, item: &str) -> u32 {
self.stacks()
.iter()
.map(|s| match s {
None => smp_shop::STACK,
Some(s) if &*s.item == item && plain(s) => smp_shop::STACK.saturating_sub(s.count),
Some(_) => 0,
})
.sum()
}
/// What doesn't fit (`room` counts 64 a stack) falls at the player's
/// feet for them, as `give` does: given all the same.
fn give(&mut self, item: &str, count: u32) -> u32 {
if !smp_market::valid_id(item) || count == 0 {
return 0;
}
let line = format!("give {} {item} {count}", self.name);
if self.game.native_run_command(&line) { count } else { 0 }
}
}
/// The shop menu's cells as items.
fn shop_items(smp: &Smp, uuid: u128) -> Vec<Option<ItemStack>> {
let economy = smp.economy.as_ref().ok();
let money = |n: i64| economy.map_or_else(|| n.to_string(), |e| e.money(n));
let config = &smp.market.config;
smp.shop
.layout(&smp.shop_config, config, uuid)
.into_iter()
.map(|cell| {
use smp_shop::Cell as C;
Some(match cell {
C::Empty => return None,
C::Filler => button("minecraft:gray_stained_glass_pane", " ", "gray"),
C::Category(icon, name, n) => button(&icon, &format!("{name} ({n} items)"), "yellow"),
C::Item(id, price) => button(&id, &smp_shop::item_label(&id, price, &money), "white"),
C::Back => button("minecraft:arrow", "Back", "white"),
C::Prev => button("minecraft:arrow", "Previous page", "white"),
C::Next => button("minecraft:arrow", "Next page", "white"),
C::Balance => {
let b = economy.map_or(0, |e| e.balance(uuid));
button("minecraft:gold_ingot", &format!("Balance: {}", money(b)), "gold")
}
C::SellAll => button("minecraft:hopper", "Sell everything the market buys", "green"),
C::Limits => {
let line = economy.and_then(|e| smp.market.limits_line(e, uuid));
button("minecraft:clock", &line.unwrap_or_else(|| "No selling limit".into()), "aqua")
}
C::Help => button("minecraft:book", smp_shop::HELP_TEXT, "white"),
C::Close => button("minecraft:barrier", "Close", "red"),
})
})
.collect()
}
/// `/shop`: the menu opens on its first page.
fn open_shop(game: &mut Game<'_>, smp: &mut Smp, me: &Player) -> Vec<Out> {
if !smp.market.config.enabled || !smp.shop_config.enabled {
return vec![Out::Error("The shop is closed.".into())];
}
if smp.trades.trade_of(me.uuid).is_some() {
return vec![Out::Error("Finish or cancel your trade first.".into())];
}
smp.shop.views.insert(me.uuid, (None, smp_shop::Page::Main));
let items = shop_items(smp, me.uuid);
let title = Text::literal(smp.shop_config.title.clone());
let window = game.native_open_menu(me.uuid, smp_shop::ROWS, title, items);
match window {
Some(w) => {
smp.shop.views.insert(me.uuid, (Some(w), smp_shop::Page::Main));
Vec::new()
}
None => {
smp.shop.views.remove(&me.uuid);
vec![Out::Error("The shop couldn't open.".into())]
}
}
}
/// A click in a player's shop menu: a page, a purchase or a sale, then the
/// menu shows the new balance and limits.
fn shop_clicked(game: &mut Game<'_>, smp: &mut Smp, uuid: u128, slot: i32, shift: bool) {
use smp_shop::Click;
let Some(p) = game.native_server_players().into_iter().find(|p| p.uuid == uuid) else {
return;
};
let me = Player {
uuid,
name: p.name.clone(),
level: p.permission,
};
// A click on a stack of the player's own sells it; one on the menu is
// the shop's.
let (sale, click) = match native_menu_inventory_slot(smp_shop::SIZE, slot) {
Some(i) => match game.native_player_stacks(uuid).and_then(|s| s.get(i).cloned().flatten()) {
Some(s) if !plain(&s) => {
let why = "The market only takes plain items (not worn, named or enchanted).";
tell(game, uuid, vec![Out::Error(why.into())]);
return;
}
Some(s) => (Some((s.item.to_string(), s.count)), Click::Nothing),
None => return,
},
None => match usize::try_from(slot) {
Ok(s) => (None, smp.shop.click(&smp.shop_config, &smp.market.config, uuid, s, shift)),
Err(_) => return,
},
};
match (&sale, &click) {
(None, Click::Nothing) => return,
(_, Click::Close) => {
smp.shop.views.remove(&uuid);
game.native_close_menu(uuid);
return;
}
_ => {}
}
let mut out = Vec::new();
let moves = sale.is_some() || matches!(click, Click::Buy(..) | Click::SellAll);
if moves && !matches!(p.game_mode, 0 | 2) {
out.push(Out::Error("The market trades only with players in survival or adventure mode.".into()));
} else if moves {
let Ok(economy) = &mut smp.economy else {
let why = "The economy is off: its file can't be read (see the server log).";
tell(game, uuid, vec![Out::Error(why.into())]);
return;
};
economy.now = chrono::Utc::now().timestamp_millis();
economy.seen(uuid, &me.name);
let mut bag = MenuBag {
game: &mut *game,
uuid,
name: me.name.clone(),
};
out = match (sale, click) {
(Some((id, n)), _) => smp.market.sell(economy, &me, &mut bag, &[&id, &n.to_string()]),
(None, Click::Buy(id, n)) => smp.market.buy(economy, &me, &mut bag, &[&id, &n.to_string()]),
_ => smp.market.sell(economy, &me, &mut bag, &["all"]),
};
// The menu shows the balance.
out.retain(|o| !matches!(o, Out::Reply(s) if s.starts_with("Balance: ")));
}
let items = shop_items(smp, uuid);
game.native_set_menu_items(uuid, items);
tell(game, uuid, out);
}
/// What a menu click answers, to the player who clicked (no command's
/// feedback there).
fn tell(game: &mut Game<'_>, uuid: u128, out: Vec<Out>) {
for o in out {
match o {
Out::Reply(s) => {
game.native_tell(uuid, Text::literal(s).with_color(text::GREEN));
}
Out::Error(s) => {
game.native_tell(uuid, Text::literal(s).with_color(text::RED));
}
o => show(game, vec![o]),
}
}
}
fn show(game: &mut Game<'_>, out: Vec<Out>) {
for o in out {
match o {
@@ -496,18 +1048,50 @@ fn show(game: &mut Game<'_>, out: Vec<Out>) {
/// 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<'_>) {
use std::sync::atomic::Ordering::Relaxed;
let n = TICKS.fetch_add(1, Relaxed);
// An idle tick costs a counter: the rest runs once a second, or each
// tick while something waits on it.
if n % 20 != 0 && !BUSY.load(Relaxed) {
return;
}
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 {
let mut guard = state(dir.join(smp_store::DIR));
let smp = guard.as_mut().expect("opened");
let warming = smp.places.as_ref().is_ok_and(|p| !p.pending.is_empty());
let players = if n % 20 == 0 || warming {
game.native_server_players()
} else {
Vec::new()
};
let mut guard = state(dir.join(smp_store::DIR));
let smp = guard.as_mut().expect("opened");
let now = chrono::Utc::now().timestamp_millis();
let mut out = Vec::new();
// Teleports: each tick while one warms up; requests expire once a second.
if let Ok(p) = &mut smp.places
&& (n % 20 == 0 || warming)
{
if n % 20 == 0 {
let on: Vec<u128> = players.iter().map(|p| p.uuid).collect();
p.played(&on, now);
}
let (o, go) = p.tick(&bodies(&players), now);
out.extend(o);
for t in go {
// `.3`: a whole number would be a block's centre to `tp`.
let line = format!(
"execute in {} run tp {} {:.3} {:.3} {:.3}",
t.to.dimension, t.name, t.to.x, t.to.y, t.to.z
);
out.push(if game.native_run_command(&line) {
Out::Tell(t.uuid, t.done)
} else {
log::warn!("[smp] a teleport failed: {line}");
Out::Tell(t.uuid, "The teleport failed.".into())
});
}
}
if let Ok(e) = &mut smp.economy
&& n % 20 == 0
{
@@ -526,7 +1110,6 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
for uuid in gone {
smp.games.left(e, uuid);
}
let now = chrono::Utc::now().timestamp_millis();
out.extend(smp.lottery.tick(&smp.games.config, e, now, &mut below));
}
// Each trade's offers against the inventories now: a player gone or
@@ -558,11 +1141,131 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
Some(_) => {}
}
}
// Welcomes, daily rewards and AFK, once a second.
let mut sleeps = std::mem::take(&mut *PENDING_SLEEP.lock().unwrap_or_else(|e| e.into_inner()));
if n % 20 == 0
&& let Ok(pr) = &mut smp.presence
{
let seen: Vec<smp_presence::Seen> = players
.iter()
.map(|p| smp_presence::Seen {
uuid: p.uuid,
name: p.name.clone(),
pose: [p.position[0], p.position[1], p.position[2], f64::from(p.yaw), f64::from(p.pitch)],
})
.collect();
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
let rules = smp.places.as_ref().map(|p| p.config.rules.clone()).unwrap_or_default();
let (o, acts) = pr.tick(&seen, &today, now, &rules);
out.extend(o);
let reward = pr.config.daily_reward;
for a in acts {
match a {
smp_presence::Act::Reward(uuid) => {
if let Ok(e) = &mut smp.economy {
match e.adjust("daily_reward", uuid, reward, "") {
Ok(_) => out.push(Out::Tell(uuid, format!("Daily reward: {}.", e.money(reward)))),
Err(err) => log::warn!("[smp] daily reward: {err}"),
}
}
}
a => sleeps.push(a),
}
}
}
// `/claim show`: the border once a second, to that player only.
let mut particles = Vec::new();
if n % 20 == 0 {
smp.shows.retain(|_, s| s.3 > now);
for (uuid, (dimension, cx, cz, _)) in &smp.shows {
let Some(p) = players.iter().find(|p| p.uuid == *uuid && p.dimension == *dimension) else {
continue;
};
let relation = smp.claims.as_ref().ok().and_then(|c| c.relation(dimension, cx * 16, cz * 16, *uuid));
let color = match relation {
Some(true) => "0,1,0",
Some(false) => "1,0,0",
None => "1,1,1",
};
for [x, y, z] in smp_claims::border(*cx, *cz, p.position[1] + 1.0, 2) {
particles.push(format!(
"execute in {dimension} run particle minecraft:dust{{color:[{color}],scale:1.5}} {x:.1} {y:.1} {z:.1} 0 0 0 0 1 force {}",
p.name
));
}
}
}
// The sidebars and the Tab list, once a second, sent only where they
// changed.
let mut sidebars = Vec::new();
let mut tab = None;
if n % 20 == 0
&& let Ok(b) = &mut smp.board
{
let on: Vec<u128> = players.iter().map(|p| p.uuid).collect();
if let Ok(e) = &smp.economy
&& b.due(now, e.revision())
{
b.rebuild(e.accounts(), now, e.revision());
}
sidebars = b.updates(&on);
let to = b.tab_due(now, &on);
if !to.is_empty() {
let (rate, mspt) = game.native_tick_stats();
let tps = f64::from(rate).min(1000.0 / mspt.max(0.001));
tab = Some((to, b.tab_lines(tps, mspt, on.len())));
}
}
let title = smp.board.as_ref().map(|b| b.config.title.clone()).unwrap_or_default();
let busy = smp.places.as_ref().is_ok_and(|p| !p.pending.is_empty()) || !smp.trades.open.is_empty();
BUSY.store(busy, Relaxed);
save(smp);
drop(guard);
for a in sleeps {
if let smp_presence::Act::Sleep(uuid, ignored) = a {
game.native_sleep_ignored(uuid, ignored);
}
}
for line in particles {
game.native_run_command(&line);
}
for (uuid, lines) in sidebars {
game.native_sidebar(uuid, Some(&sidebar(&title, &lines)));
}
if let Some((to, (header, footer))) = tab {
let (header, footer) = (Text::literal(header).with_color(text::GOLD), Text::literal(footer).with_color(text::GRAY));
for uuid in to {
game.native_tab_list(uuid, &header, &footer);
}
}
show(game, out);
}
/// A player's sidebar lines as the game shows them.
fn sidebar(title: &str, lines: &[Line]) -> NativeSidebar {
let lines = lines
.iter()
.map(|l| match l {
Line::Top { rank, name, amount, me } => {
let color = if *me { text::GREEN } else { text::WHITE };
(
Text::literal(format!("{rank}. ")).with_color(text::GRAY).append(Text::literal(name.clone()).with_color(color)),
Some(Text::literal(amount.clone()).with_color(text::GOLD)),
)
}
Line::Gap => (Text::literal(""), None),
Line::Me { rank, amount } => (
Text::literal(if *rank == 0 { "You".to_owned() } else { format!("You: #{rank}") }).with_color(text::GREEN),
Some(Text::literal(amount.clone()).with_color(text::GOLD)),
),
})
.collect();
NativeSidebar {
title: Text::literal(title).with_color(text::GOLD).bold(),
lines,
}
}
/// 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());
+121 -3
View File
@@ -1,12 +1,12 @@
[mod]
id = "smp"
version = "0.5.0"
version = "0.12.0"
priority = 100
# The commit of horizon main it was tested on. It uses only the native hook points (hooks 10 in
# The commit of horizon main it was tested on. It uses only the native hook points (hooks 49 in
# compatibility.json): it builds on any Horizon that has them.
[compatibility]
horizon_revision = "f533c3af7d41d7ecc3b015a9fbb0dbf5eb64ae26"
horizon_revision = "5272f3ad64bc68e1f80068a201f38d442ea01fbc"
[[module]]
id = "store"
@@ -26,6 +26,18 @@ file = "crates/horizon-client/src/game.rs"
name = "smp_market"
source = "smp_market.rs"
[[module]]
id = "recipes"
file = "crates/horizon-client/src/game.rs"
name = "smp_recipes"
source = "smp_recipes.rs"
[[module]]
id = "shop"
file = "crates/horizon-client/src/game.rs"
name = "smp_shop"
source = "smp_shop.rs"
[[module]]
id = "trade"
file = "crates/horizon-client/src/game.rs"
@@ -44,6 +56,30 @@ file = "crates/horizon-client/src/game.rs"
name = "smp_lottery"
source = "smp_lottery.rs"
[[module]]
id = "places"
file = "crates/horizon-client/src/game.rs"
name = "smp_places"
source = "smp_places.rs"
[[module]]
id = "board"
file = "crates/horizon-client/src/game.rs"
name = "smp_board"
source = "smp_board.rs"
[[module]]
id = "presence"
file = "crates/horizon-client/src/game.rs"
name = "smp_presence"
source = "smp_presence.rs"
[[module]]
id = "claims"
file = "crates/horizon-client/src/game.rs"
name = "smp_claims"
source = "smp_claims.rs"
[[module]]
id = "glue"
file = "crates/horizon-client/src/game.rs"
@@ -113,3 +149,85 @@ bindings = [
{ name = "uuid", ty = "u128", value = "uuid" },
{ name = "window", ty = "i32", value = "window" },
]
[[injection]]
id = "destroy_block"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_destroy_block"
helper = "destroy_block.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "pos", ty = "[i32; 3]", value = "pos" },
{ name = "canceled", ty = "&mut bool", value = "&mut *canceled" },
]
[[injection]]
id = "right_click_block"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_right_click_block"
helper = "right_click_block.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "pos", ty = "[i32; 3]", value = "pos" },
{ name = "canceled", ty = "&mut bool", value = "&mut *canceled" },
]
[[injection]]
id = "block_spread"
kind = "HEAD"
target = "horizon_client::game::native_hooks::GameLevel<'_>::native_block_spread"
helper = "block_spread.rs"
bindings = [
{ name = "dimension", ty = "crate::game::LevelDimension", value = "dimension" },
{ name = "from", ty = "horizon_core::position::BlockPos", value = "from" },
{ name = "to", ty = "horizon_core::position::BlockPos", value = "to" },
{ name = "canceled", ty = "&mut bool", value = "&mut *canceled" },
]
[[injection]]
id = "piston_move"
kind = "HEAD"
target = "horizon_client::game::native_hooks::GameLevel<'_>::native_piston_move"
helper = "piston_move.rs"
bindings = [
{ name = "dimension", ty = "crate::game::LevelDimension", value = "dimension" },
{ name = "piston", ty = "horizon_core::position::BlockPos", value = "piston" },
{ name = "direction", ty = "[i32; 3]", value = "direction" },
{ name = "push", ty = "&[horizon_core::position::BlockPos]", value = "push" },
{ name = "destroy", ty = "&[horizon_core::position::BlockPos]", value = "destroy" },
{ name = "canceled", ty = "&mut bool", value = "&mut *canceled" },
]
[[injection]]
id = "explosion_detonate"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_explosion_detonate"
helper = "explosion_detonate.rs"
bindings = [
{ name = "game", ty = "&crate::game::Game<'_>", value = "&*self" },
{ name = "dimension", ty = "&str", value = "dimension" },
{ name = "blocks", ty = "&mut Vec<[i32; 3]>", value = "&mut *blocks" },
]
[[injection]]
id = "fill_bucket"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_fill_bucket"
helper = "fill_bucket.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "pos", ty = "[i32; 3]", value = "pos" },
{ name = "canceled", ty = "&mut bool", value = "&mut *canceled" },
]
[[injection]]
id = "attack_entity"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_attack_entity"
helper = "attack_entity.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "kind", ty = "&str", value = "kind" },
{ name = "pos", ty = "[f64; 3]", value = "pos" },
{ name = "canceled", ty = "&mut bool", value = "&mut *canceled" },
]
+24
View File
@@ -0,0 +1,24 @@
// A piston pushes, pulls or destroys blocks: kept out of the land others
// claimed.
fn smp_piston_move(
dimension: crate::game::LevelDimension,
piston: horizon_core::position::BlockPos,
direction: [i32; 3],
push: &[horizon_core::position::BlockPos],
destroy: &[horizon_core::position::BlockPos],
canceled: &mut bool,
) {
let dimension = crate::game::smp_glue::dimension_id(dimension);
let step = |p: &horizon_core::position::BlockPos| {
[p.x + direction[0], p.y + direction[1], p.z + direction[2]]
};
let touched: Vec<[i32; 3]> = push
.iter()
.flat_map(|p| [[p.x, p.y, p.z], step(p)])
.chain(destroy.iter().map(|p| [p.x, p.y, p.z]))
.collect();
let piston = [piston.x, piston.y, piston.z];
if !crate::game::smp_glue::world_allows(dimension, piston, &touched) {
*canceled = true;
}
}
@@ -0,0 +1,4 @@
// A player right clicks a block: kept out of the land others claimed.
fn smp_right_click_block(game: &mut crate::game::Game<'_>, pos: [i32; 3], canceled: &mut bool) {
crate::game::smp_glue::guard(game, pos, canceled);
}
+439
View File
@@ -0,0 +1,439 @@
// The SMP's screens: a sidebar with the richest players and the player's own
// balance and rank, which a player hides with `/sidebar off` (kept in
// `board.json`), and the Tab list's header and footer (the server's name,
// its TPS and the players on). Nothing happens per tick: the ranking is built
// again when a balance changed (at most once each `min_seconds`) or every
// `refresh_seconds`, and a sidebar is sent only to the players whose lines
// changed; the Tab list goes out every `tab_refresh_seconds`, and to a player
// who just joined. No game types. Added to the game as `game::smp_board`;
// also a module of this crate, for its tests.
use super::smp_economy::{Out, Player};
use super::smp_store;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};
/// The most lines a sidebar shows (vanilla's 15, less the gap and the
/// player's own line).
const MAX_TOP: usize = 13;
/// `config.json`'s `board` section.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// The sidebar of the richest players (each player may hide it).
pub sidebar: bool,
pub title: String,
/// How many of the richest players it lists (at most 13).
pub top: usize,
/// The ranking is built again at least this often.
pub refresh_seconds: i64,
/// After a balance changed, at most this often.
pub min_seconds: i64,
/// The Tab list's header and footer.
pub tab: bool,
/// `{server}`, `{tps}`, `{mspt}`, `{players}` are filled in.
pub tab_header: String,
pub tab_footer: String,
pub server_name: String,
pub tab_refresh_seconds: i64,
}
impl Default for Config {
fn default() -> Self {
Self {
sidebar: true,
title: "Richest players".into(),
top: 10,
refresh_seconds: 60,
min_seconds: 5,
tab: true,
tab_header: "{server}".into(),
tab_footer: "TPS {tps} · {players} online".into(),
server_name: "Horizon SMP".into(),
tab_refresh_seconds: 5,
}
}
}
/// `board.json`.
#[derive(Default, Serialize, Deserialize)]
#[serde(default)]
struct Data {
/// The players who hid the sidebar, by UUID.
hidden: BTreeSet<String>,
}
/// A line of a player's sidebar.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Line {
/// One of the richest: their rank, name and balance; `me` on the
/// player's own line.
Top {
rank: usize,
name: String,
amount: String,
me: bool,
},
/// The empty line before the player's own.
Gap,
/// The player's rank (0: no account yet) and balance.
Me { rank: usize, amount: String },
}
pub struct Board {
pub config: Config,
path: PathBuf,
data: Data,
changed: bool,
/// Every account by balance, richest first: UUID, name, balance.
ranked: Vec<(u128, String, i64)>,
rank_of: HashMap<u128, usize>,
/// When the ranking was built, and over which economy revision.
built_at: Option<i64>,
built_revision: u64,
/// What each player on was last sent.
sent: HashMap<u128, Vec<Line>>,
/// Built since the last `updates`: every player's lines are looked at.
stale: bool,
tab_at: Option<i64>,
tab_sent: HashSet<u128>,
}
/// `1,250`, `125.4k`, `3.2M`: short enough for the sidebar's right side.
pub fn compact(n: i64) -> String {
let a = n.unsigned_abs();
let sign = if n < 0 { "-" } else { "" };
if a < 100_000 {
let digits = a.to_string();
let mut out = String::new();
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
return format!("{sign}{out}");
}
let (value, unit) = match a {
100_000..1_000_000 => (a as f64 / 1e3, "k"),
1_000_000..1_000_000_000 => (a as f64 / 1e6, "M"),
_ => (a as f64 / 1e9, "B"),
};
format!("{sign}{value:.1}{unit}")
}
impl Board {
/// `board.json` in `dir`; an error when it exists but can't be read.
pub fn open(dir: &Path, config: Config) -> Result<Self, String> {
let path = dir.join("board.json");
let data = smp_store::read_json(&path)?;
Ok(Self {
config,
path,
data,
changed: false,
ranked: Vec::new(),
rank_of: HashMap::new(),
built_at: None,
built_revision: 0,
sent: HashMap::new(),
stale: false,
tab_at: None,
tab_sent: HashSet::new(),
})
}
pub fn save(&mut self) -> Result<(), String> {
if self.changed {
smp_store::write_json(&self.path, &self.data)?;
self.changed = false;
}
Ok(())
}
pub fn hidden(&self, uuid: u128) -> bool {
self.data.hidden.contains(&smp_store::uuid_string(uuid))
}
/// Whether the ranking should be built again: the economy changed
/// (`revision`) and `min_seconds` passed, or `refresh_seconds` passed.
pub fn due(&self, now: i64, revision: u64) -> bool {
if !self.config.sidebar {
return false;
}
let Some(at) = self.built_at else {
return true;
};
let since = now - at;
(revision != self.built_revision && since >= self.config.min_seconds * 1000)
|| since >= self.config.refresh_seconds * 1000
}
/// The ranking from the accounts (UUID, name, balance, any order).
pub fn rebuild(&mut self, mut accounts: Vec<(u128, String, i64)>, now: i64, revision: u64) {
accounts.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.1.cmp(&b.1)));
self.rank_of = accounts
.iter()
.enumerate()
.map(|(i, a)| (a.0, i + 1))
.collect();
self.ranked = accounts;
self.built_at = Some(now);
self.built_revision = revision;
self.stale = true;
}
/// `uuid`'s sidebar from the ranking.
pub fn lines(&self, uuid: u128) -> Vec<Line> {
let mut lines: Vec<Line> = self
.ranked
.iter()
.take(self.config.top.min(MAX_TOP))
.enumerate()
.map(|(i, (u, name, balance))| Line::Top {
rank: i + 1,
name: name.clone(),
amount: compact(*balance),
me: *u == uuid,
})
.collect();
let rank = self.rank_of.get(&uuid).copied().unwrap_or(0);
let balance = rank.checked_sub(1).map_or(0, |i| self.ranked[i].2);
lines.push(Line::Gap);
lines.push(Line::Me {
rank,
amount: compact(balance),
});
lines
}
/// The sidebars to send now, to the players `on`: those who joined, and
/// after a rebuild those whose lines changed. Players who hid it get
/// nothing; players gone are forgotten (they get it again on return).
pub fn updates(&mut self, on: &[u128]) -> Vec<(u128, Vec<Line>)> {
self.sent.retain(|u, _| on.contains(u));
self.tab_sent.retain(|u| on.contains(u));
if !self.config.sidebar || self.built_at.is_none() {
return Vec::new();
}
let stale = std::mem::take(&mut self.stale);
let mut out = Vec::new();
for &uuid in on {
if self.hidden(uuid) || (!stale && self.sent.contains_key(&uuid)) {
continue;
}
let lines = self.lines(uuid);
if self.sent.get(&uuid) != Some(&lines) {
self.sent.insert(uuid, lines.clone());
out.push((uuid, lines));
}
}
out
}
/// Whom the Tab list goes to now: everyone each `tab_refresh_seconds`,
/// and players who just joined.
pub fn tab_due(&mut self, now: i64, on: &[u128]) -> Vec<u128> {
if !self.config.tab {
return Vec::new();
}
let all = self
.tab_at
.is_none_or(|t| now - t >= self.config.tab_refresh_seconds * 1000);
if all {
self.tab_at = Some(now);
}
let to: Vec<u128> = on
.iter()
.copied()
.filter(|u| all || !self.tab_sent.contains(u))
.collect();
self.tab_sent.extend(to.iter().copied());
to
}
/// The Tab list's header and footer.
pub fn tab_lines(&self, tps: f64, mspt: f64, players: usize) -> (String, String) {
let fill = |s: &str| {
s.replace("{server}", &self.config.server_name)
.replace("{tps}", &format!("{tps:.1}"))
.replace("{mspt}", &format!("{mspt:.1}"))
.replace("{players}", &players.to_string())
};
(fill(&self.config.tab_header), fill(&self.config.tab_footer))
}
/// `/sidebar [on|off]`: the answer, and the sidebar to show (true) or
/// take away (false). None for another command.
pub fn command(&mut self, me: &Player, line: &str) -> Option<(Vec<Out>, Option<bool>)> {
let words: Vec<&str> = line.split_whitespace().collect();
if words.first() != Some(&"sidebar") {
return None;
}
if !self.config.sidebar {
return Some((
vec![Out::Error("The sidebar is off on this server.".into())],
None,
));
}
let key = smp_store::uuid_string(me.uuid);
let show = match words[1..] {
[] => self.hidden(me.uuid),
["on"] => true,
["off"] => false,
_ => {
return Some((vec![Out::Error("Usage: /sidebar [on|off]".into())], None));
}
};
if show {
self.changed |= self.data.hidden.remove(&key);
self.sent.remove(&me.uuid);
} else {
self.changed |= self.data.hidden.insert(key);
}
let reply = if show {
"The sidebar is on. /sidebar off hides it."
} else {
"The sidebar is off. /sidebar on shows it again."
};
Some((vec![Out::Reply(reply.into())], Some(show)))
}
}
pub const COMMANDS: [&str; 1] = ["sidebar"];
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
fn dir() -> PathBuf {
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("smp-board-{}-{n}", std::process::id()))
}
fn me(uuid: u128) -> Player {
Player {
uuid,
name: format!("P{uuid}"),
level: 0,
}
}
fn accounts(n: u128) -> Vec<(u128, String, i64)> {
(1..=n)
.map(|u| (u, format!("P{u}"), u as i64 * 100))
.collect()
}
#[test]
fn amounts_are_short() {
assert_eq!(compact(0), "0");
assert_eq!(compact(1_250), "1,250");
assert_eq!(compact(99_999), "99,999");
assert_eq!(compact(125_400), "125.4k");
assert_eq!(compact(3_210_000), "3.2M");
assert_eq!(compact(7_000_000_000), "7.0B");
}
#[test]
fn the_sidebar_lists_the_richest_and_the_players_own_rank() {
let d = dir();
let mut b = Board::open(&d, Config::default()).unwrap();
assert!(b.due(0, 0));
b.rebuild(accounts(12), 0, 1);
let lines = b.lines(3);
assert_eq!(lines.len(), 12);
assert_eq!(
lines[0],
Line::Top {
rank: 1,
name: "P12".into(),
amount: "1,200".into(),
me: false
}
);
assert_eq!(lines[10], Line::Gap);
assert_eq!(
lines[11],
Line::Me {
rank: 10,
amount: "300".into()
}
);
// A player with no account yet.
assert_eq!(
b.lines(99)[11],
Line::Me {
rank: 0,
amount: "0".into()
}
);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn sidebars_go_out_on_join_and_on_change_only() {
let d = dir();
let mut b = Board::open(&d, Config::default()).unwrap();
b.rebuild(accounts(3), 0, 1);
let sent = b.updates(&[1, 2]);
assert_eq!(sent.len(), 2);
// Nothing new: nothing sent, even when built again.
assert!(b.updates(&[1, 2]).is_empty());
assert!(!b.due(1_000, 1), "no change, not a minute yet");
assert!(!b.due(1_000, 2), "changed, but within min_seconds");
assert!(b.due(5_000, 2));
b.rebuild(accounts(3), 5_000, 2);
assert!(b.updates(&[1, 2]).is_empty(), "same lines");
// Player 1 gets richer: everyone's top changes.
let mut a = accounts(3);
a[0].2 = 1_000;
b.rebuild(a, 10_000, 3);
assert_eq!(b.updates(&[1, 2]).len(), 2);
// A newcomer gets theirs at once; one who left gets it on return.
assert_eq!(b.updates(&[1, 2, 3]).len(), 1);
b.updates(&[2, 3]);
assert_eq!(b.updates(&[1, 2, 3]).len(), 1);
assert!(b.due(70_000, 3), "a minute passed");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn a_hidden_sidebar_stays_hidden_after_a_restart() {
let d = dir();
let mut b = Board::open(&d, Config::default()).unwrap();
b.rebuild(accounts(3), 0, 1);
let (out, show) = b.command(&me(1), "sidebar off").unwrap();
assert_eq!(show, Some(false));
assert!(matches!(&out[0], Out::Reply(s) if s.contains("off")));
assert_eq!(b.updates(&[1, 2]).len(), 1, "only player 2");
b.save().unwrap();
let mut b = Board::open(&d, Config::default()).unwrap();
assert!(b.hidden(1));
b.rebuild(accounts(3), 0, 1);
assert_eq!(b.updates(&[1]).len(), 0);
// `/sidebar` toggles it back on, and it is sent at the next update.
let (_, show) = b.command(&me(1), "sidebar").unwrap();
assert_eq!(show, Some(true));
assert_eq!(b.updates(&[1]).len(), 1);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn the_tab_list_fills_its_placeholders_and_goes_to_newcomers_at_once() {
let d = dir();
let mut b = Board::open(&d, Config::default()).unwrap();
let (h, f) = b.tab_lines(19.96, 6.3, 42);
assert_eq!(
(h.as_str(), f.as_str()),
("Horizon SMP", "TPS 20.0 · 42 online")
);
assert_eq!(b.tab_due(0, &[1, 2]), vec![1, 2]);
assert!(b.tab_due(1_000, &[1, 2]).is_empty());
assert_eq!(b.tab_due(2_000, &[1, 2, 3]), vec![3]);
assert_eq!(b.tab_due(5_000, &[1, 2, 3]), vec![1, 2, 3]);
let _ = std::fs::remove_dir_all(d);
}
}
+688
View File
@@ -0,0 +1,688 @@
// Land claims: a player claims the chunk they stand in (`/claim`), all its
// height, and only they and the players they trust (`/trust`) break blocks
// or right click blocks there (doors, chests, levers, placing, buckets on a
// block), or hit its frames, armor stands and animals. Nothing flows, burns
// or is pushed into it from land of someone else, and explosions spare it.
// The chunks a player may hold grow with the time they played here.
// Operators of level 3 pass everywhere and may `/unclaim` any chunk. Kept in
// `claims.json`, written atomically. No game types. Added to the game as
// `game::smp_claims`; also a module of this crate, for its tests.
use super::smp_economy::{Out, Player};
use super::smp_places::Place;
use super::smp_store;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
/// `config.json`'s `claims` section.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub enabled: bool,
/// Chunks a player may claim, by hours played here: `[hours, chunks]`,
/// the last line reached counts.
pub chunks: Vec<[i64; 2]>,
/// The entities only the owner and trusted players may hit in a claim.
pub protected_entities: Vec<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: true,
chunks: vec![[0, 4], [5, 16], [20, 36], [100, 64]],
protected_entities: PROTECTED
.iter()
.map(|&e| format!("minecraft:{e}"))
.collect(),
}
}
}
/// Frames, stands, vehicles and the animals a player keeps: not monsters.
/// Points along a chunk's four edges, every `step` blocks, at height `y`:
/// where `/claim show` draws its border.
pub fn border(cx: i32, cz: i32, y: f64, step: usize) -> Vec<[f64; 3]> {
let (x0, z0) = (f64::from(cx * 16), f64::from(cz * 16));
let mut points = Vec::new();
for i in (0..=16).step_by(step.max(1)) {
let t = f64::from(i);
points.push([x0 + t, y, z0]);
points.push([x0 + t, y, z0 + 16.0]);
if i != 0 && i != 16 {
points.push([x0, y, z0 + t]);
points.push([x0 + 16.0, y, z0 + t]);
}
}
points
}
const PROTECTED: &[&str] = &[
"item_frame",
"glow_item_frame",
"armor_stand",
"painting",
"leash_knot",
"minecart",
"chest_minecart",
"furnace_minecart",
"hopper_minecart",
"tnt_minecart",
"oak_boat",
"spruce_boat",
"birch_boat",
"jungle_boat",
"acacia_boat",
"dark_oak_boat",
"mangrove_boat",
"cherry_boat",
"pale_oak_boat",
"bamboo_raft",
"oak_chest_boat",
"spruce_chest_boat",
"birch_chest_boat",
"jungle_chest_boat",
"acacia_chest_boat",
"dark_oak_chest_boat",
"mangrove_chest_boat",
"cherry_chest_boat",
"pale_oak_chest_boat",
"bamboo_chest_raft",
"allay",
"armadillo",
"axolotl",
"bee",
"camel",
"cat",
"chicken",
"cow",
"donkey",
"fox",
"frog",
"goat",
"happy_ghast",
"horse",
"llama",
"mooshroom",
"mule",
"ocelot",
"panda",
"parrot",
"pig",
"rabbit",
"sheep",
"sniffer",
"snow_golem",
"iron_golem",
"copper_golem",
"strider",
"turtle",
"villager",
"wolf",
"trader_llama",
"wandering_trader",
];
/// `claims.json`.
#[derive(Default, Serialize, Deserialize)]
#[serde(default)]
struct Data {
/// Claimed chunks, `<dimension> <chunk x> <chunk z>`, to their owner's UUID.
chunks: BTreeMap<String, String>,
/// The players each owner trusts, by UUID.
trust: BTreeMap<String, BTreeSet<String>>,
}
pub struct Claims {
pub config: Config,
path: PathBuf,
data: Data,
changed: bool,
/// `data.chunks` by dimension, then (chunk x, chunk z): asked on every
/// fluid flow, so a lookup allocates nothing.
owners: HashMap<String, HashMap<(i32, i32), u128>>,
/// When each player was last told a chunk isn't theirs.
told: HashMap<u128, i64>,
}
fn key(dimension: &str, cx: i32, cz: i32) -> String {
format!("{dimension} {cx} {cz}")
}
fn chunk_of(at: &Place) -> (i32, i32) {
((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4)
}
impl Claims {
/// `claims.json` in `dir`; an error when it exists but can't be read
/// (never replaced by an empty one).
pub fn open(dir: &Path, config: Config) -> Result<Self, String> {
let path = dir.join("claims.json");
let data: Data = smp_store::read_json(&path)?;
let mut owners: HashMap<String, HashMap<(i32, i32), u128>> = HashMap::new();
for (k, owner) in &data.chunks {
let mut w = k.split(' ');
let (Some(d), Some(x), Some(z), Some(u)) = (
w.next(),
w.next().and_then(|x| x.parse().ok()),
w.next().and_then(|z| z.parse().ok()),
smp_store::parse_uuid(owner),
) else {
return Err(format!("{}: a bad claim `{k}`", path.display()));
};
owners.entry(d.to_owned()).or_default().insert((x, z), u);
}
Ok(Self {
config,
path,
data,
changed: false,
owners,
told: HashMap::new(),
})
}
pub fn save(&mut self) -> Result<(), String> {
if self.changed {
smp_store::write_json(&self.path, &self.data)?;
self.changed = false;
}
Ok(())
}
/// Who holds the chunk of this block, if anyone.
pub fn owner(&self, dimension: &str, x: i32, z: i32) -> Option<u128> {
self.chunk_owner(dimension, x >> 4, z >> 4)
}
fn chunk_owner(&self, dimension: &str, cx: i32, cz: i32) -> Option<u128> {
self.owners.get(dimension)?.get(&(cx, cz)).copied()
}
/// Whether the world may change the block at `to` from `from` (a fluid,
/// fire, a piston): yes, unless `to` is claimed by someone other than
/// `from`'s owner or a player they trust.
pub fn spread_allowed(&self, dimension: &str, from: [i32; 3], to: [i32; 3]) -> bool {
if !self.config.enabled {
return true;
}
let Some(owner) = self.owner(dimension, to[0], to[2]) else {
return true;
};
match self.owner(dimension, from[0], from[2]) {
Some(source) => source == owner || self.trusts(owner, source),
None => false,
}
}
/// Takes out of an explosion's blocks those in a claim: explosions
/// spare claimed land, whoever lit them.
pub fn spare(&self, dimension: &str, blocks: &mut Vec<[i32; 3]>) {
if self.config.enabled && self.owners.get(dimension).is_some_and(|c| !c.is_empty()) {
blocks.retain(|b| self.owner(dimension, b[0], b[2]).is_none());
}
}
/// Whose land a block is to `who`: None when free, true when theirs or
/// trusted to them, false when someone else's.
pub fn relation(&self, dimension: &str, x: i32, z: i32, who: u128) -> Option<bool> {
let owner = self.owner(dimension, x, z)?;
Some(owner == who || self.trusts(owner, who))
}
/// Whether entities of type `kind` (`minecraft:cow`) are kept from
/// players a claim's owner doesn't trust.
pub fn protects(&self, kind: &str) -> bool {
self.config.protected_entities.iter().any(|e| e == kind)
}
fn trusts(&self, owner: u128, who: u128) -> bool {
self.data
.trust
.get(&smp_store::uuid_string(owner))
.is_some_and(|t| t.contains(&smp_store::uuid_string(who)))
}
/// Whether `who` may break or use the block at `pos`. When not, the line
/// to tell them: at most one each 2 s, so holding a click doesn't flood
/// their chat.
pub fn check(
&mut self,
who: &Player,
dimension: &str,
pos: [i32; 3],
name_of: &dyn Fn(u128) -> String,
now: i64,
) -> Result<(), Option<String>> {
if !self.config.enabled || who.level >= 3 {
return Ok(());
}
let Some(owner) = self.owner(dimension, pos[0], pos[2]) else {
return Ok(());
};
if owner == who.uuid || self.trusts(owner, who.uuid) {
return Ok(());
}
let last = self.told.insert(who.uuid, now);
if last.is_some_and(|t| now - t < 2_000) {
return Err(None);
}
Err(Some(format!("This land is claimed by {}.", name_of(owner))))
}
/// How many chunks `uuid` holds.
fn held(&self, uuid: u128) -> usize {
self.owners
.values()
.flat_map(|c| c.values())
.filter(|&&u| u == uuid)
.count()
}
/// How many chunks a player may hold after `played` seconds here.
pub fn limit(&self, played: i64) -> usize {
self.config
.chunks
.iter()
.filter(|[h, _]| played / 3600 >= *h)
.map(|[_, n]| (*n).max(0) as usize)
.next_back()
.unwrap_or(0)
}
/// The claims' commands; None for another command. `at` is where the
/// player stands; `find` a player's UUID and name from a name (on or
/// known); `name_of` a name from a UUID.
pub fn command(
&mut self,
me: &Player,
at: &Place,
played: i64,
find: &dyn Fn(&str) -> Option<(u128, String)>,
name_of: &dyn Fn(u128) -> String,
line: &str,
) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
let command = *words.first()?;
if !COMMANDS.contains(&command) {
return None;
}
if !self.config.enabled {
return Some(vec![Out::Error("Claims are off on this server.".into())]);
}
let (cx, cz) = chunk_of(at);
let here = at.dimension.as_str();
let blocks = format!(
"x {} to {}, z {} to {}",
cx * 16,
cx * 16 + 15,
cz * 16,
cz * 16 + 15
);
let me_key = smp_store::uuid_string(me.uuid);
Some(match (command, &words[1..]) {
("claim", []) => match self.chunk_owner(here, cx, cz) {
Some(u) if u == me.uuid => vec![Out::Error("This chunk is already yours.".into())],
Some(u) => vec![Out::Error(format!("This chunk is {}'s.", name_of(u)))],
None => {
let (held, limit) = (self.held(me.uuid), self.limit(played));
if held >= limit {
vec![Out::Error(format!(
"You hold {held} of {limit} chunks: play longer for more, or /unclaim one."
))]
} else {
self.data.chunks.insert(key(here, cx, cz), me_key);
let chunks = self.owners.entry(here.to_owned()).or_default();
chunks.insert((cx, cz), me.uuid);
self.changed = true;
vec![Out::Reply(format!(
"Claimed this chunk ({blocks}), {} of {limit}. /trust <player> lets a friend build.",
held + 1
))]
}
}
},
("claim", ["info"]) => vec![Out::Reply(match self.chunk_owner(here, cx, cz) {
Some(u) => format!("This chunk ({blocks}) is {}'s.", name_of(u)),
None => format!("This chunk ({blocks}) is free: /claim it."),
})],
("unclaim", []) => match self.chunk_owner(here, cx, cz) {
Some(u) if u == me.uuid || me.level >= 3 => {
if let Some(chunks) = self.owners.get_mut(here) {
chunks.remove(&(cx, cz));
}
self.data.chunks.remove(&key(here, cx, cz));
self.changed = true;
vec![Out::Reply(format!("Unclaimed this chunk ({blocks})."))]
}
Some(u) => vec![Out::Error(format!("This chunk is {}'s.", name_of(u)))],
None => vec![Out::Error("This chunk isn't claimed.".into())],
},
("claims", []) => {
let mine: Vec<String> = self
.data
.chunks
.iter()
.filter(|(_, o)| **o == me_key)
.map(|(k, _)| {
let w: Vec<&str> = k.split(' ').collect();
let n = |s: &str| s.parse::<i32>().unwrap_or(0) * 16;
format!(
"{} at x {} z {}",
w[0].trim_start_matches("minecraft:"),
n(w[1]),
n(w[2])
)
})
.collect();
let trusted: Vec<String> = self
.data
.trust
.get(&me_key)
.into_iter()
.flatten()
.filter_map(|u| smp_store::parse_uuid(u))
.map(name_of)
.collect();
vec![
Out::Reply(format!(
"Your chunks ({} of {}): {}",
mine.len(),
self.limit(played),
if mine.is_empty() {
"none, /claim where you stand".into()
} else {
mine.join("; ")
}
)),
Out::Reply(format!(
"Trusted: {}",
if trusted.is_empty() {
"nobody".into()
} else {
trusted.join(", ")
}
)),
]
}
("trust", [name]) | ("untrust", [name]) => match find(name) {
None => vec![Out::Error(format!(
"No player named {name} has played here."
))],
Some((u, _)) if u == me.uuid => vec![Out::Error("That's you.".into())],
Some((u, name)) => {
let set = self.data.trust.entry(me_key.clone()).or_default();
let changed = if command == "trust" {
set.insert(smp_store::uuid_string(u))
} else {
set.remove(&smp_store::uuid_string(u))
};
if set.is_empty() {
self.data.trust.remove(&me_key);
}
self.changed |= changed;
vec![Out::Reply(match (command, changed) {
("trust", true) => {
format!("{name} may now build and open things on your land.")
}
("trust", false) => format!("You already trust {name}."),
(_, true) => format!("{name} no longer may build on your land."),
_ => format!("You didn't trust {name}."),
})]
}
},
_ => vec![Out::Error(
"Usage: /claim, /claim info, /claim show, /unclaim, /claims, /trust <player>, /untrust <player>"
.into(),
)],
})
}
}
/// The claims' commands.
pub const COMMANDS: [&str; 5] = ["claim", "unclaim", "claims", "trust", "untrust"];
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
const A: u128 = 1;
const B: u128 = 2;
fn dir() -> PathBuf {
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("smp-claims-{}-{n}", std::process::id()))
}
fn at(x: f64, z: f64) -> Place {
Place {
dimension: "minecraft:overworld".into(),
x,
y: 64.0,
z,
}
}
fn player(uuid: u128, level: u8) -> Player {
Player {
uuid,
name: if uuid == A { "Alex" } else { "Steve" }.into(),
level,
}
}
fn find(n: &str) -> Option<(u128, String)> {
match n.to_ascii_lowercase().as_str() {
"alex" => Some((A, "Alex".into())),
"steve" => Some((B, "Steve".into())),
_ => None,
}
}
fn name_of(u: u128) -> String {
if u == A { "Alex" } else { "Steve" }.into()
}
#[test]
fn a_border_runs_along_the_chunks_edges() {
let points = border(1, -1, 70.0, 2);
// 9 points on each edge, the corners once.
assert_eq!(points.len(), 32);
assert!(points.contains(&[16.0, 70.0, -16.0]) && points.contains(&[32.0, 70.0, 0.0]));
assert!(
points
.iter()
.all(|p| p[0] == 16.0 || p[0] == 32.0 || p[2] == -16.0 || p[2] == 0.0)
);
let d = dir();
let mut c = Claims::open(&d, Config::default()).unwrap();
c.command(&player(A, 0), &at(8.0, 8.0), 0, &find, &name_of, "claim")
.unwrap();
let ow = "minecraft:overworld";
assert_eq!(c.relation(ow, 3, 3, A), Some(true));
assert_eq!(c.relation(ow, 3, 3, B), Some(false));
assert_eq!(c.relation(ow, 20, 3, B), None);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn the_world_stays_out_of_a_claim() {
let d = dir();
let mut c = Claims::open(&d, Config::default()).unwrap();
c.command(&player(A, 0), &at(8.0, 8.0), 0, &find, &name_of, "claim")
.unwrap();
let ow = "minecraft:overworld";
// Into Alex's chunk from outside: refused; inside it and out of it: allowed.
assert!(!c.spread_allowed(ow, [16, 64, 8], [15, 64, 8]));
assert!(c.spread_allowed(ow, [14, 64, 8], [15, 64, 8]));
assert!(c.spread_allowed(ow, [15, 64, 8], [16, 64, 8]));
assert!(c.spread_allowed("minecraft:the_nether", [16, 64, 8], [15, 64, 8]));
// From Steve's chunk next door: allowed once Alex trusts Steve.
c.command(&player(B, 0), &at(24.0, 8.0), 0, &find, &name_of, "claim")
.unwrap();
assert!(!c.spread_allowed(ow, [16, 64, 8], [15, 64, 8]));
c.command(
&player(A, 0),
&at(8.0, 8.0),
0,
&find,
&name_of,
"trust steve",
)
.unwrap();
assert!(c.spread_allowed(ow, [16, 64, 8], [15, 64, 8]));
assert!(!c.spread_allowed(ow, [15, 64, 8], [16, 64, 8]));
// Explosions spare both claims, not the land around them.
let mut blocks = vec![[0, 64, 0], [20, 64, 0], [40, 64, 0], [-1, 64, 0]];
c.spare(ow, &mut blocks);
assert_eq!(blocks, vec![[40, 64, 0], [-1, 64, 0]]);
assert!(c.protects("minecraft:item_frame") && c.protects("minecraft:cow"));
assert!(!c.protects("minecraft:zombie") && !c.protects("minecraft:player"));
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn a_claim_keeps_others_out_until_trusted() {
let d = dir();
let mut c = Claims::open(&d, Config::default()).unwrap();
let out = c
.command(&player(A, 0), &at(-3.5, 20.0), 0, &find, &name_of, "claim")
.unwrap();
assert!(
matches!(&out[0], Out::Reply(s) if s.contains("x -16 to -1, z 16 to 31")),
"{out:?}"
);
let ow = "minecraft:overworld";
assert_eq!(
c.check(&player(A, 0), ow, [-16, 0, 31], &name_of, 0),
Ok(())
);
assert_eq!(
c.check(&player(B, 0), ow, [-1, 100, 16], &name_of, 0),
Err(Some("This land is claimed by Alex.".into()))
);
// Told once each 2 s; the next chunk and the Nether are free.
assert_eq!(
c.check(&player(B, 0), ow, [-1, 100, 16], &name_of, 1_000),
Err(None)
);
assert_eq!(c.check(&player(B, 0), ow, [0, 64, 16], &name_of, 0), Ok(()));
assert_eq!(
c.check(
&player(B, 0),
"minecraft:the_nether",
[-1, 64, 16],
&name_of,
0
),
Ok(())
);
// Operators pass.
assert_eq!(
c.check(&player(B, 3), ow, [-1, 64, 16], &name_of, 0),
Ok(())
);
c.command(
&player(A, 0),
&at(0.0, 0.0),
0,
&find,
&name_of,
"trust steve",
)
.unwrap();
assert_eq!(
c.check(&player(B, 0), ow, [-1, 64, 16], &name_of, 9_000),
Ok(())
);
let out = c
.command(
&player(B, 0),
&at(-3.5, 20.0),
0,
&find,
&name_of,
"unclaim",
)
.unwrap();
assert!(
matches!(&out[0], Out::Error(s) if s.contains("Alex's")),
"trust isn't ownership"
);
c.save().unwrap();
let mut again = Claims::open(&d, Config::default()).unwrap();
assert_eq!(again.owner(ow, -1, 16), Some(A));
again
.command(
&player(A, 0),
&at(0.0, 0.0),
0,
&find,
&name_of,
"untrust Steve",
)
.unwrap();
assert!(
again
.check(&player(B, 0), ow, [-1, 64, 16], &name_of, 20_000)
.is_err()
);
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn chunks_grow_with_time_played() {
let d = dir();
let mut c = Claims::open(&d, Config::default()).unwrap();
for x in 0..4 {
let out = c
.command(
&player(A, 0),
&at(x as f64 * 16.0, 0.0),
0,
&find,
&name_of,
"claim",
)
.unwrap();
assert!(matches!(&out[0], Out::Reply(_)), "{out:?}");
}
let out = c
.command(&player(A, 0), &at(64.0, 0.0), 0, &find, &name_of, "claim")
.unwrap();
assert!(
matches!(&out[0], Out::Error(s) if s.contains("4 of 4")),
"{out:?}"
);
let out = c
.command(
&player(A, 0),
&at(64.0, 0.0),
5 * 3600,
&find,
&name_of,
"claim",
)
.unwrap();
assert!(
matches!(&out[0], Out::Reply(s) if s.contains("5 of 16")),
"{out:?}"
);
let out = c
.command(&player(B, 0), &at(0.0, 0.0), 0, &find, &name_of, "claim")
.unwrap();
assert_eq!(out, vec![Out::Error("This chunk is Alex's.".into())]);
// An operator may free any chunk.
let out = c
.command(&player(B, 3), &at(0.0, 0.0), 0, &find, &name_of, "unclaim")
.unwrap();
assert!(matches!(&out[0], Out::Reply(_)));
let out = c
.command(&player(A, 0), &at(0.0, 0.0), 0, &find, &name_of, "claims")
.unwrap();
assert!(
matches!(&out[0], Out::Reply(s) if s.starts_with("Your chunks (4 of 4)")),
"{out:?}"
);
let _ = std::fs::remove_dir_all(d);
}
}
+170 -26
View File
@@ -80,6 +80,9 @@ pub struct Economy {
path: PathBuf,
audit: Audit,
dirty: bool,
/// Counts the changes to the accounts, so the sidebar knows when to
/// look again.
revision: u64,
/// Milliseconds since 1970, for the log (the glue sets it each call).
pub now: i64,
}
@@ -97,6 +100,7 @@ impl Economy {
path,
audit: Audit::new(dir.join("audit.log")),
dirty: false,
revision: 0,
now: 0,
};
// Games the last run left undecided: their stakes go back.
@@ -109,6 +113,27 @@ impl Economy {
Ok(economy)
}
fn touch(&mut self) {
self.dirty = true;
self.revision += 1;
}
/// Changes to the accounts so far (in this run).
pub fn revision(&self) -> u64 {
self.revision
}
/// Every account: UUID, name, balance.
pub fn accounts(&self) -> Vec<(u128, String, i64)> {
self.accounts
.accounts
.iter()
.filter_map(|(k, a)| {
Some((super::smp_store::parse_uuid(k)?, a.name.clone(), a.balance))
})
.collect()
}
/// Money `uuid` has held for a game.
pub fn held(&self, uuid: u128) -> i64 {
self.accounts
@@ -139,7 +164,7 @@ impl Economy {
.expect("checked")
.balance -= amount;
*self.accounts.held.entry(key).or_default() += amount;
self.dirty = true;
self.touch();
Ok(())
}
@@ -155,7 +180,7 @@ impl Economy {
self.take_held(&key, amount);
let account = self.accounts.accounts.entry(key).or_default();
account.balance = account.balance.saturating_add(amount);
self.dirty = true;
self.touch();
Ok(())
}
@@ -187,7 +212,7 @@ impl Economy {
.entry(uuid_string(winner))
.or_default();
account.balance = account.balance.saturating_add(prize);
self.dirty = true;
self.touch();
Ok(())
}
@@ -226,7 +251,7 @@ impl Economy {
Some(a) if a.name == name => {}
Some(a) => {
a.name = name.to_owned();
self.dirty = true;
self.touch();
}
None => {
let start = self.config.starting_balance.clamp(0, MAX_AMOUNT);
@@ -239,7 +264,7 @@ impl Economy {
balance: start,
},
);
self.dirty = true;
self.touch();
}
}
}
@@ -254,6 +279,15 @@ impl Economy {
.and_then(|(k, a)| Some((super::smp_store::parse_uuid(k)?, a)))
}
/// The name last seen of a player who has an account.
pub fn name_of(&self, uuid: u128) -> Option<&str> {
let a = self
.accounts
.accounts
.get(&super::smp_store::uuid_string(uuid))?;
Some(&a.name)
}
/// `1,250 coins`.
pub fn money(&self, amount: i64) -> String {
let digits = amount.unsigned_abs().to_string();
@@ -339,7 +373,7 @@ impl Economy {
.get_mut(&uuid_string(to))
.expect("checked")
.balance = to_after;
self.dirty = true;
self.touch();
Ok(())
}
@@ -369,7 +403,7 @@ impl Economy {
.get_mut(&key)
.expect("checked")
.balance = after;
self.dirty = true;
self.touch();
Ok(after)
}
@@ -392,20 +426,67 @@ impl Economy {
all
}
/// Runs `/balance`, `/bal`, `/pay`, `/baltop` or `/eco`; None for another
/// command.
pub fn command(&mut self, me: &Player, line: &str) -> Option<Vec<Out>> {
/// Runs `/balance`, `/bal`, `/pay`, `/baltop` or the operators' `/eco`
/// (and its other name `/money`); None for another command. `online`:
/// the players on, for `/eco give * <amount>`.
pub fn command(
&mut self,
me: &Player,
online: &[(u128, String)],
line: &str,
) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
let (&name, args) = words.split_first()?;
Some(match name {
"balance" | "bal" => self.balance_command(me, args),
"pay" => self.pay(me, args),
"baltop" => self.baltop(args),
"eco" => self.eco(me, args),
"eco" | "money" => match args {
["give", "*", amount] => self.give_everyone(me, online, amount),
_ => self.eco(me, args),
},
_ => return None,
})
}
/// `/eco give * <amount>`: to every player on, each change logged.
fn give_everyone(&mut self, me: &Player, online: &[(u128, String)], amount: &str) -> Vec<Out> {
if me.level < 3 {
return vec![Out::Error("Only operators of level 3 may use /eco.".into())];
}
let Some(amount) = parse_amount(amount) else {
return vec![Out::Error(
"The amount must be a positive whole number.".into(),
)];
};
let by = uuid_string(me.uuid);
let mut out = Vec::new();
let mut given = 0;
for (uuid, name) in online {
self.seen(*uuid, name);
match self.adjust("eco_give", *uuid, amount, &by) {
Ok(_) => {
given += 1;
if *uuid != me.uuid {
out.push(Out::Tell(
*uuid,
format!("An operator gave you {}.", self.money(amount)),
));
}
}
Err(e) => out.push(Out::Error(format!("{name}: {e}"))),
}
}
out.insert(
0,
Out::Reply(format!(
"Gave {} to {given} player(s) on.",
self.money(amount)
)),
);
out
}
fn balance_command(&mut self, me: &Player, args: &[&str]) -> Vec<Out> {
match args {
[] => vec![Out::Reply(format!(
@@ -474,7 +555,7 @@ impl Economy {
}
let [verb, who, amount] = args else {
return vec![Out::Error(
"Usage: /eco give|take|set <player> <amount>".into(),
"Usage: /eco give|take|set <player> <amount>, /eco give * <amount>".into(),
)];
};
let amount = match amount.parse::<i64>() {
@@ -495,13 +576,22 @@ impl Economy {
"set" => amount - account.balance,
_ => {
return vec![Out::Error(
"Usage: /eco give|take|set <player> <amount>".into(),
"Usage: /eco give|take|set <player> <amount>, /eco give * <amount>".into(),
)];
}
};
let by = uuid_string(me.uuid);
match self.adjust(&format!("eco_{verb}"), uuid, delta, &by) {
Ok(after) => vec![Out::Reply(format!("{name} now has {}.", self.money(after)))],
Ok(after) => {
let mut out = vec![Out::Reply(format!("{name} now has {}.", self.money(after)))];
if *verb == "give" && uuid != me.uuid && amount > 0 {
out.push(Out::Tell(
uuid,
format!("An operator gave you {}.", self.money(amount)),
));
}
out
}
Err(e) => vec![Out::Error(e)],
}
}
@@ -559,7 +649,9 @@ mod tests {
#[test]
fn pay_moves_money_and_tells_both() {
let (mut e, dir) = economy("pay");
let out = e.command(&player(ALEX, "Alex", 0), "pay steve 30").unwrap();
let out = e
.command(&player(ALEX, "Alex", 0), &[], "pay steve 30")
.unwrap();
assert_eq!(
out,
[
@@ -598,7 +690,7 @@ mod tests {
("pay Steve", "Usage: /pay <player> <amount>"),
] {
assert_eq!(
e.command(&alex, line).unwrap(),
e.command(&alex, &[], line).unwrap(),
[Out::Error(error.into())],
"{line}"
);
@@ -615,9 +707,10 @@ mod tests {
#[test]
fn balances_survive_a_restart_and_a_damaged_file_stops_the_economy() {
let (mut e, dir) = economy("restart");
e.command(&player(ALEX, "Alex", 0), "pay Steve 1,000")
e.command(&player(ALEX, "Alex", 0), &[], "pay Steve 1,000")
.unwrap();
e.command(&player(ALEX, "Alex", 0), &[], "pay Steve 25")
.unwrap();
e.command(&player(ALEX, "Alex", 0), "pay Steve 25").unwrap();
e.save().unwrap();
let again = Economy::open(&dir, Config::default()).unwrap();
assert_eq!((again.balance(ALEX), again.balance(STEVE)), (75, 125));
@@ -626,25 +719,73 @@ mod tests {
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn money_is_eco_and_give_star_pays_everyone_on() {
let (mut e, dir) = economy("money");
let op = player(ALEX, "Alex", 3);
assert_eq!(
e.command(&op, &[], "money give Steve 7").unwrap()[0],
Out::Reply("Steve now has 107 coins.".into())
);
let online = [
(ALEX, "Alex".to_owned()),
(STEVE, "Steve".to_owned()),
(99, "Newcomer".to_owned()),
];
let out = e.command(&op, &online, "eco give * 1,000").unwrap();
assert_eq!(
out[0],
Out::Reply("Gave 1,000 coins to 3 player(s) on.".into()),
"{out:?}"
);
assert!(out.contains(&Out::Tell(99, "An operator gave you 1,000 coins.".into())));
assert!(
!out.iter()
.any(|o| matches!(o, Out::Tell(u, _) if *u == ALEX))
);
assert_eq!(
(e.balance(ALEX), e.balance(STEVE), e.balance(99)),
(1100, 1107, 1100)
);
let gives = log_lines(&dir)
.iter()
.filter(|l| l["kind"] == "eco_give")
.count();
assert_eq!(gives, 4);
assert_eq!(
e.command(&player(STEVE, "Steve", 0), &online, "money give * 5")
.unwrap(),
[Out::Error("Only operators of level 3 may use /eco.".into())]
);
assert!(matches!(
&e.command(&op, &online, "eco give * -5").unwrap()[0],
Out::Error(_)
));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn eco_is_for_level_3_and_never_goes_below_zero() {
let (mut e, dir) = economy("eco");
let alex = player(ALEX, "Alex", 2);
assert_eq!(
e.command(&alex, "eco give Steve 5").unwrap(),
e.command(&alex, &[], "eco give Steve 5").unwrap(),
[Out::Error("Only operators of level 3 may use /eco.".into())]
);
let op = player(ALEX, "Alex", 3);
assert_eq!(
e.command(&op, "eco give Steve 5").unwrap(),
[Out::Reply("Steve now has 105 coins.".into())]
e.command(&op, &[], "eco give Steve 5").unwrap(),
[
Out::Reply("Steve now has 105 coins.".into()),
Out::Tell(STEVE, "An operator gave you 5 coins.".into())
]
);
assert_eq!(
e.command(&op, "eco take Steve 200").unwrap(),
e.command(&op, &[], "eco take Steve 200").unwrap(),
[Out::Error("Only 105 coins there.".into())]
);
assert_eq!(
e.command(&op, "eco set Steve 1234567").unwrap(),
e.command(&op, &[], "eco set Steve 1234567").unwrap(),
[Out::Reply("Steve now has 1,234,567 coins.".into())]
);
let log = log_lines(&dir);
@@ -659,12 +800,15 @@ mod tests {
for i in 0..12u128 {
e.seen(100 + i, &format!("P{i:02}"));
}
e.command(&player(ALEX, "Alex", 0), "pay Steve 50").unwrap();
let first = e.command(&player(ALEX, "Alex", 0), "baltop").unwrap();
e.command(&player(ALEX, "Alex", 0), &[], "pay Steve 50")
.unwrap();
let first = e.command(&player(ALEX, "Alex", 0), &[], "baltop").unwrap();
assert_eq!(first[0], Out::Reply("Richest players (1/2):".into()));
assert_eq!(first[1], Out::Reply("1. Steve: 150 coins".into()));
assert_eq!(first.len(), 11);
let last = e.command(&player(ALEX, "Alex", 0), "baltop 2").unwrap();
let last = e
.command(&player(ALEX, "Alex", 0), &[], "baltop 2")
.unwrap();
assert_eq!(
last.last().unwrap(),
&Out::Reply("14. Alex: 50 coins".into())
+582 -66
View File
@@ -1,15 +1,24 @@
// The server market: the server buys and sells vanilla items at the prices
// of `config.json`'s `market`, the economy's way to earn without changing
// survival. `/worth`, `/sell hand|all|<item> [count]`, `/buy <item> [count]`.
// Only plain items (no damage, no name or enchantment) are traded, and only
// by players in survival or adventure mode. Items move first, then money,
// both logged; a sale the log refuses gives the items back. No game types:
// the glue lends the acting player's items as a `Bag`. Added to the game as
// survival. `/worth`, `/sell hand|all|<item> [count]`, `/buy <item> [count]`,
// and the `/shop` menu (smp_shop) on the same prices. Only plain items (no
// damage, no name or enchantment) are traded, and only by players in survival
// or adventure mode. Items move first, then money, both logged; a sale the
// log refuses gives the items back. Nobody gets rich from it: buying always
// costs more than selling (an item priced otherwise isn't bought), no chain
// of recipes turns bought items into dearer ones (smp_recipes; such an item
// isn't bought either), and what a player sells is capped each UTC day, in
// coins and per item (`market.json` counts it). No game types: the glue
// lends the player's items as a `Bag`. Added to the game as
// `game::smp_market`; also a module of this crate, for its tests.
use super::smp_economy::{Economy, MAX_AMOUNT, Out, Player, parse_amount};
use super::smp_store::{read_json, uuid_string, write_json};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::{collections::BTreeMap, path::PathBuf};
/// A UTC day, in milliseconds.
const DAY_MS: i64 = 86_400_000;
/// What the server pays for one item (`sell`) and asks for one (`buy`); 0
/// closes that side.
@@ -25,41 +34,94 @@ pub struct Price {
#[serde(default)]
pub struct Config {
pub enabled: bool,
/// By item id, `minecraft:diamond`.
/// By item id, `minecraft:diamond`. Items that stack to 64.
pub items: BTreeMap<String, Price>,
/// The most a player earns selling to the market in a UTC day (0: no
/// limit).
pub daily_sell_coins: i64,
/// The most of one item a player sells to the market in a UTC day (0:
/// no limit).
pub daily_sell_items: u32,
}
/// The default prices: (item, buy, sell). Farm, mine and mob produce the
/// market buys cheaply; blocks, food and tools it only sells.
pub const PRICES: &[(&str, i64, i64)] = &[
// Farming.
("wheat", 3, 1),
("wheat_seeds", 2, 0),
("carrot", 3, 1),
("potato", 3, 1),
("beetroot", 3, 1),
("sugar_cane", 3, 1),
("pumpkin", 8, 3),
("melon_slice", 2, 1),
("cactus", 3, 1),
("bone_meal", 3, 0),
// Mining.
("coal", 8, 2),
("copper_ingot", 6, 2),
("iron_ingot", 30, 8),
("gold_ingot", 40, 10),
("redstone", 4, 1),
("lapis_lazuli", 8, 2),
("quartz", 8, 2),
("emerald", 80, 20),
("diamond", 400, 100),
// Wood.
("oak_log", 4, 1),
("spruce_log", 4, 1),
("birch_log", 4, 1),
("jungle_log", 4, 1),
("acacia_log", 4, 1),
("dark_oak_log", 4, 1),
("mangrove_log", 4, 1),
("cherry_log", 4, 1),
// Mob drops.
("rotten_flesh", 0, 1),
("bone", 3, 1),
("string", 3, 1),
("gunpowder", 8, 2),
("spider_eye", 4, 1),
("slime_ball", 10, 2),
("blaze_rod", 30, 8),
("leather", 6, 2),
("feather", 2, 1),
// Building.
("cobblestone", 1, 0),
("stone", 2, 0),
("dirt", 1, 0),
("sand", 2, 0),
("gravel", 2, 0),
("glass", 3, 0),
("bricks", 4, 0),
("stone_bricks", 3, 0),
// Food.
("bread", 5, 0),
("cooked_beef", 6, 0),
("cooked_porkchop", 6, 0),
("cooked_chicken", 5, 0),
("baked_potato", 4, 0),
("apple", 4, 0),
("golden_carrot", 40, 0),
// Tools.
("torch", 1, 0),
("ladder", 2, 0),
("crafting_table", 4, 0),
("chest", 6, 0),
("furnace", 6, 0),
];
impl Default for Config {
/// A small starting list the owner edits: farm and mine produce the
/// server buys, a few things it sells, buying always dearer than selling.
/// `PRICES`, buying always dearer than selling, and the daily limits.
fn default() -> Self {
let list: [(&str, i64, i64); 18] = [
("minecraft:wheat", 0, 1),
("minecraft:carrot", 0, 1),
("minecraft:potato", 0, 1),
("minecraft:sugar_cane", 0, 1),
("minecraft:pumpkin", 0, 3),
("minecraft:melon_slice", 0, 1),
("minecraft:cactus", 0, 1),
("minecraft:oak_log", 4, 1),
("minecraft:coal", 8, 2),
("minecraft:iron_ingot", 30, 8),
("minecraft:gold_ingot", 40, 10),
("minecraft:redstone", 4, 1),
("minecraft:lapis_lazuli", 8, 2),
("minecraft:emerald", 80, 20),
("minecraft:diamond", 400, 100),
("minecraft:bread", 5, 0),
("minecraft:cooked_beef", 6, 0),
("minecraft:torch", 1, 0),
];
Self {
enabled: true,
items: list
items: PRICES
.iter()
.map(|(id, buy, sell)| {
(
(*id).to_owned(),
format!("minecraft:{id}"),
Price {
buy: *buy,
sell: *sell,
@@ -67,10 +129,128 @@ impl Default for Config {
)
})
.collect(),
daily_sell_coins: 2000,
daily_sell_items: 640,
}
}
}
/// The default prices of SMP 0.1 to 0.11: a config that still holds
/// exactly these (never edited) gets today's defaults (`migrate`).
const OLD_PRICES: [(&str, i64, i64); 18] = [
("wheat", 0, 1),
("carrot", 0, 1),
("potato", 0, 1),
("sugar_cane", 0, 1),
("pumpkin", 0, 3),
("melon_slice", 0, 1),
("cactus", 0, 1),
("oak_log", 4, 1),
("coal", 8, 2),
("iron_ingot", 30, 8),
("gold_ingot", 40, 10),
("redstone", 4, 1),
("lapis_lazuli", 8, 2),
("emerald", 80, 20),
("diamond", 400, 100),
("bread", 5, 0),
("cooked_beef", 6, 0),
("torch", 1, 0),
];
/// A config whose prices are still the old defaults, untouched: today's
/// defaults instead. Whether it changed (the file is written again).
pub fn migrate(config: &mut Config) -> bool {
let old: BTreeMap<String, Price> = OLD_PRICES
.iter()
.map(|(id, buy, sell)| {
(
format!("minecraft:{id}"),
Price {
buy: *buy,
sell: *sell,
},
)
})
.collect();
if config.items != old {
return false;
}
config.items = Config::default().items;
true
}
/// What one player sold today.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Sold {
/// The UTC day (days since 1970) the counts are for.
pub day: i64,
pub coins: i64,
pub items: BTreeMap<String, u32>,
}
/// `market.json`: what each player sold today, by UUID.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Ledger {
pub sold: BTreeMap<String, Sold>,
}
/// A config's prices made safe: an amount out of range, or a sale price not
/// below the buying price, closes that item's sale; so does an item a chain
/// of recipes makes for less than it sells for. What it closed, to log.
pub fn validate(config: &mut Config) -> Vec<String> {
let mut closed = Vec::new();
config.daily_sell_coins = config.daily_sell_coins.clamp(0, MAX_AMOUNT);
for (id, p) in config.items.iter_mut() {
if !valid_id(id) {
closed.push(format!("{id}: not an item id; left out"));
*p = Price::default();
continue;
}
p.buy = p.buy.clamp(0, MAX_AMOUNT);
p.sell = p.sell.clamp(0, MAX_AMOUNT);
if p.sell > 0 && p.buy > 0 && p.sell >= p.buy {
closed.push(format!(
"{id}: sells for {} but buys for {}; the market won't buy it",
p.sell, p.buy
));
p.sell = 0;
}
}
for l in super::smp_recipes::loops(&config.items, &super::smp_recipes::chains()) {
closed.push(format!(
"{}: crafted from the market's items for {:.2}, sells for {}; the market won't buy it",
l.item, l.cost, l.sell
));
if let Some(p) = config.items.get_mut(&l.item) {
p.sell = 0;
}
}
config.items.retain(|_, p| p.buy > 0 || p.sell > 0);
closed
}
/// `minecraft:iron_ingot`: lowercase letters, digits, `_`, `.`, `-`, `/`
/// and one `:`, as a command line may carry it.
pub fn valid_id(id: &str) -> bool {
let mut parts = id.split(':');
let (Some(ns), Some(path), None) = (parts.next(), parts.next(), parts.next()) else {
return false;
};
let ok = |s: &str, extra: &str| {
!s.is_empty()
&& s.chars().all(|c| {
c.is_ascii_lowercase()
|| c.is_ascii_digit()
|| "_.-".contains(c)
|| extra.contains(c)
})
};
ok(ns, "") && ok(path, "/")
}
/// The acting player's items, lent by the game for one command.
pub trait Bag {
/// The main hand: item id, count, plain.
@@ -105,13 +285,126 @@ pub fn item_name(id: &str) -> String {
pub struct Market {
pub config: Config,
ledger: Ledger,
/// `market.json`; None in tests that keep nothing.
path: Option<PathBuf>,
dirty: bool,
}
impl Market {
/// The market of `dir` (`world/horizon-smp/`), its config made safe
/// (`validate`), with what to log. A `market.json` that can't be read
/// starts the day's counts from nothing (they only limit, never hold
/// money).
pub fn open(dir: &std::path::Path, mut config: Config) -> (Self, Vec<String>) {
let mut problems: Vec<String> = validate(&mut config)
.into_iter()
.map(|l| format!("market: {l}"))
.collect();
let path = dir.join("market.json");
let ledger = read_json(&path).unwrap_or_else(|e| {
problems.push(format!("{e}: today's sale limits start again"));
Ledger::default()
});
let market = Self {
config,
ledger,
path: Some(path),
dirty: false,
};
(market, problems)
}
/// A market that keeps nothing on disk, its config as given (tests).
#[cfg(test)]
pub fn new(config: Config) -> Self {
Self {
config,
ledger: Ledger::default(),
path: None,
dirty: false,
}
}
pub fn save(&mut self) -> Result<(), String> {
if !self.dirty {
return Ok(());
}
if let Some(path) = &self.path {
write_json(path, &self.ledger)?;
}
self.dirty = false;
Ok(())
}
/// What `uuid` sold today (`now`: milliseconds since 1970).
fn sold(&self, uuid: u128, now: i64) -> Sold {
match self.ledger.sold.get(&uuid_string(uuid)) {
Some(s) if s.day == now.div_euclid(DAY_MS) => s.clone(),
_ => Sold::default(),
}
}
/// The coins `uuid` may still earn selling today (None: no limit).
pub fn coins_left(&self, uuid: u128, now: i64) -> Option<i64> {
let cap = self.config.daily_sell_coins;
(cap > 0).then(|| (cap - self.sold(uuid, now).coins).max(0))
}
/// How many of `id` `uuid` may still sell today (None: no limit).
pub fn items_left(&self, uuid: u128, now: i64, id: &str) -> Option<u32> {
let cap = self.config.daily_sell_items;
(cap > 0)
.then(|| cap.saturating_sub(self.sold(uuid, now).items.get(id).copied().unwrap_or(0)))
}
/// Counts a sale against today's limits.
fn count_sale(&mut self, uuid: u128, now: i64, id: &str, n: u32, coins: i64) {
let day = now.div_euclid(DAY_MS);
let s = self.ledger.sold.entry(uuid_string(uuid)).or_default();
if s.day != day {
*s = Sold {
day,
..Sold::default()
};
}
s.coins = s.coins.saturating_add(coins);
let c = s.items.entry(id.to_owned()).or_default();
*c = c.saturating_add(n);
// Yesterday's counts go.
self.ledger.sold.retain(|_, s| s.day == day);
self.dirty = true;
}
/// The price of `id` (nothing on either side when it isn't traded).
pub fn price(&self, id: &str) -> Price {
self.config.items.get(id).copied().unwrap_or_default()
}
/// Today's limits for `uuid`, as a line (None when there are none).
pub fn limits_line(&self, economy: &Economy, uuid: u128) -> Option<String> {
let now = economy.now;
let coins = self.coins_left(uuid, now).map(|left| {
format!(
"{} left of your {} a day",
economy.money(left),
economy.money(self.config.daily_sell_coins)
)
});
let items = (self.config.daily_sell_items > 0).then(|| {
format!(
"at most {} of each item a day",
self.config.daily_sell_items
)
});
let parts: Vec<String> = coins.into_iter().chain(items).collect();
(!parts.is_empty()).then(|| format!("Selling today: {}.", parts.join(", ")))
}
/// Runs `/worth`, `/sell` or `/buy`; None for another command. `trades`
/// is false for a player in creative or spectator mode.
pub fn command(
&self,
&mut self,
economy: &mut Economy,
me: &Player,
trades: bool,
@@ -127,7 +420,7 @@ impl Market {
return Some(vec![Out::Error("The market is closed.".into())]);
}
if name == "worth" {
return Some(self.worth(economy, bag, args));
return Some(self.worth(economy, me, bag, args));
}
if !trades {
return Some(vec![Out::Error(
@@ -140,11 +433,7 @@ impl Market {
})
}
fn price(&self, id: &str) -> Price {
self.config.items.get(id).copied().unwrap_or_default()
}
fn worth(&self, economy: &Economy, bag: &mut dyn Bag, args: &[&str]) -> Vec<Out> {
fn worth(&self, economy: &Economy, me: &Player, bag: &mut dyn Bag, args: &[&str]) -> Vec<Out> {
let id = match args {
[] => match bag.held() {
Some((id, ..)) => id,
@@ -169,11 +458,22 @@ impl Market {
if parts.is_empty() {
return vec![Out::Reply(format!("The market doesn't trade {name}."))];
}
vec![Out::Reply(format!("{name}: {}.", parts.join(", ")))]
let mut out = vec![Out::Reply(format!("{name}: {}.", parts.join(", ")))];
if p.sell > 0 {
if let Some(left) = self.items_left(me.uuid, economy.now, &id) {
out.push(Out::Reply(format!(
"You may sell {left} more {name} today."
)));
}
out.extend(self.limits_line(economy, me.uuid).map(Out::Reply));
}
out
}
fn sell(
&self,
/// Sells what `args` names (`hand [n]`, `all`, `<item> [n]`), within
/// today's limits.
pub fn sell(
&mut self,
economy: &mut Economy,
me: &Player,
bag: &mut dyn Bag,
@@ -224,23 +524,51 @@ impl Market {
out.push(Out::Error(format!("The market doesn't buy {name}.")));
continue;
}
// Never more than the account can be paid for at once.
let cap = (MAX_AMOUNT / price).min(u32::MAX as i64) as u32;
let have = bag.count(&id).min(at_most).min(cap);
if have == 0 {
// Never more than the account can be paid for at once, nor
// than today's limits leave.
let now = economy.now;
let mut cap = (MAX_AMOUNT / price).min(i64::from(u32::MAX)) as u32;
if let Some(left) = self.items_left(me.uuid, now, &id) {
cap = cap.min(left);
}
if let Some(left) = self.coins_left(me.uuid, now) {
cap = cap.min((left / price).min(i64::from(u32::MAX)) as u32);
}
let carried = bag.count(&id).min(at_most);
if carried == 0 {
out.push(Out::Error(format!("You have no plain {name}.")));
continue;
}
let have = carried.min(cap);
if have == 0 {
out.push(Out::Error(format!(
"You reached today's selling limit for {name}; it starts again at midnight UTC."
)));
continue;
}
let taken = bag.take(&id, have);
let pay = price * i64::from(taken);
if taken == 0 {
continue;
}
let Some(pay) = price.checked_mul(i64::from(taken)) else {
bag.give(&id, taken);
continue;
};
let kind = format!("sell {taken} {id}");
match economy.adjust(&kind, me.uuid, pay, "") {
Ok(_) => {
total += pay;
self.count_sale(me.uuid, now, &id, taken, pay);
total = total.saturating_add(pay);
out.push(Out::Reply(format!(
"Sold {taken} {name} for {}.",
economy.money(pay)
)));
if taken < carried {
out.push(Out::Reply(format!(
"{} {name} kept: today's selling limit.",
carried - taken
)));
}
}
Err(e) => {
let back = bag.give(&id, taken);
@@ -259,7 +587,8 @@ impl Market {
out
}
fn buy(
/// Buys `args` (`<item> [count]`).
pub fn buy(
&self,
economy: &mut Economy,
me: &Player,
@@ -283,7 +612,15 @@ impl Market {
if count > 64 * 36 {
return vec![Out::Error("At most a full inventory at once.".into())];
}
let cost = price * i64::from(count);
if count == 0 {
return vec![Out::Error("Usage: /buy <item> [count]".into())];
}
let Some(cost) = price
.checked_mul(i64::from(count))
.filter(|c| *c <= MAX_AMOUNT)
else {
return vec![Out::Error("That costs too much at once.".into())];
};
if economy.balance(me.uuid) < cost {
return vec![Out::Error(format!(
"{count} {name} cost {}; you have {}.",
@@ -307,7 +644,7 @@ impl Market {
))];
if given < count {
// Checked above; kept so money never goes for nothing.
let refund = price * i64::from(count - given);
let refund = price.saturating_mul(i64::from(count - given));
if economy
.adjust(
&format!("refund {} {id}", count - given),
@@ -378,6 +715,10 @@ mod tests {
const ALEX: u128 = 1;
fn setup(name: &str) -> (Economy, Market, Player, std::path::PathBuf) {
setup_with(name, Config::default())
}
fn setup_with(name: &str, config: Config) -> (Economy, Market, Player, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("smp-market-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let mut e = Economy::open(&dir, EconomyConfig::default()).unwrap();
@@ -387,14 +728,7 @@ mod tests {
name: "Alex".into(),
level: 0,
};
(
e,
Market {
config: Config::default(),
},
me,
dir,
)
(e, Market::new(config), me, dir)
}
fn bag(items: &[(&str, u32)], room: u32) -> TestBag {
@@ -407,7 +741,7 @@ mod tests {
#[test]
fn selling_takes_the_items_then_pays_and_logs_both() {
let (mut e, m, me, dir) = setup("sell");
let (mut e, mut m, me, dir) = setup("sell");
let mut b = bag(
&[
("minecraft:diamond", 3),
@@ -439,7 +773,7 @@ mod tests {
#[test]
fn selling_the_hand_takes_only_plain_items() {
let (mut e, m, me, dir) = setup("hand");
let (mut e, mut m, me, dir) = setup("hand");
let mut b = bag(&[("minecraft:iron_ingot", 10)], 0);
b.held = Some(("minecraft:iron_ingot".into(), 10, true));
let out = m.command(&mut e, &me, true, &mut b, "sell hand 4").unwrap();
@@ -460,7 +794,7 @@ mod tests {
#[test]
fn buying_checks_money_and_room_before_anything_moves() {
let (mut e, m, me, dir) = setup("buy");
let (mut e, mut m, me, dir) = setup("buy");
e.adjust("test", ALEX, 1000, "").unwrap();
let mut b = bag(&[], 3);
let out = m
@@ -483,8 +817,13 @@ mod tests {
.unwrap();
assert_eq!(out[0], Out::Reply("Bought 2 diamond for 800 coins.".into()));
assert_eq!((e.balance(ALEX), b.items["minecraft:diamond"]), (200, 2));
let out = m.command(&mut e, &me, true, &mut b, "buy wheat").unwrap();
assert_eq!(out, [Out::Error("The market doesn't sell wheat.".into())]);
let out = m
.command(&mut e, &me, true, &mut b, "buy rotten_flesh")
.unwrap();
assert_eq!(
out,
[Out::Error("The market doesn't sell rotten flesh.".into())]
);
fs::remove_dir_all(dir).unwrap();
}
@@ -503,10 +842,10 @@ mod tests {
.command(&mut e, &me, false, &mut b, "worth diamond")
.unwrap();
assert_eq!(
worth,
[Out::Reply(
worth[0],
Out::Reply(
"diamond: the market pays 100 coins each, sells it for 400 coins each.".into()
)]
)
);
m.config.enabled = false;
let out = m.command(&mut e, &me, true, &mut b, "sell all").unwrap();
@@ -515,4 +854,181 @@ mod tests {
assert!(m.command(&mut e, &me, true, &mut b, "balance").is_none());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn sales_stop_at_the_daily_limits_and_start_again_the_next_day() {
let config = Config {
daily_sell_coins: 250,
daily_sell_items: 30,
..Config::default()
};
let (mut e, mut m, me, dir) = setup_with("limits", config);
e.now = 5 * DAY_MS + 1000;
let mut b = bag(&[("minecraft:diamond", 5), ("minecraft:wheat", 64)], 0);
// 250 coins a day: two diamonds (200), then 50 wheat would pass the
// coins but not the 30 items of one kind.
let out = m
.command(&mut e, &me, true, &mut b, "sell diamond")
.unwrap();
assert_eq!(
out[0],
Out::Reply("Sold 2 diamond for 200 coins.".into()),
"{out:?}"
);
assert_eq!(
out[1],
Out::Reply("3 diamond kept: today's selling limit.".into())
);
let out = m.command(&mut e, &me, true, &mut b, "sell wheat").unwrap();
assert_eq!(
out[0],
Out::Reply("Sold 30 wheat for 30 coins.".into()),
"{out:?}"
);
let out = m.command(&mut e, &me, true, &mut b, "sell wheat").unwrap();
assert_eq!(
out,
[Out::Error(
"You reached today's selling limit for wheat; it starts again at midnight UTC."
.into()
)]
);
let worth = m
.command(&mut e, &me, true, &mut b, "worth diamond")
.unwrap();
assert!(
worth.contains(&Out::Reply("You may sell 28 more diamond today.".into())),
"{worth:?}"
);
assert!(
worth.contains(&Out::Reply(
"Selling today: 20 coins left of your 250 coins a day, at most 30 of each item a day.".into()
)),
"{worth:?}"
);
assert_eq!((e.balance(ALEX), b.items["minecraft:diamond"]), (230, 3));
// Kept across a restart, in market.json.
m.path = Some(dir.join("market.json"));
m.dirty = true;
m.save().unwrap();
let (mut m, _) = Market::open(&dir, m.config.clone());
let out = m
.command(&mut e, &me, true, &mut b, "sell diamond")
.unwrap();
assert!(
matches!(&out[0], Out::Error(s) if s.contains("limit")),
"{out:?}"
);
// A new UTC day.
e.now = 6 * DAY_MS;
let out = m
.command(&mut e, &me, true, &mut b, "sell diamond")
.unwrap();
assert_eq!(
out[0],
Out::Reply("Sold 2 diamond for 200 coins.".into()),
"{out:?}"
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn prices_that_would_make_money_are_closed() {
let mut config = Config::default();
let set = |c: &mut Config, id: &str, buy, sell| {
c.items
.insert(format!("minecraft:{id}"), Price { buy, sell });
};
set(&mut config, "emerald", 20, 20);
set(&mut config, "iron_block", 0, 300);
set(&mut config, "rotten_flesh", -5, i64::MAX);
config.items.insert(
"minecraft:diamond 1\nop me".into(),
Price { buy: 1, sell: 0 },
);
let closed = validate(&mut config);
assert_eq!(closed.len(), 3, "{closed:?}");
assert_eq!(
config.items["minecraft:emerald"],
Price { buy: 20, sell: 0 }
);
assert!(!config.items.contains_key("minecraft:iron_block"));
assert_eq!(
config.items["minecraft:rotten_flesh"],
Price {
buy: 0,
sell: MAX_AMOUNT
}
);
assert!(config.items.keys().all(|k| valid_id(k)));
// At the largest price: one at a time, without overflow.
let (mut e, mut m, me, dir) = setup_with(
"validate",
Config {
daily_sell_coins: 0,
..config
},
);
let mut b = bag(&[("minecraft:rotten_flesh", 64)], 0);
let out = m
.command(&mut e, &me, true, &mut b, "sell rotten_flesh")
.unwrap();
assert_eq!(
out[0],
Out::Reply(format!("Sold 1 rotten flesh for {}.", e.money(MAX_AMOUNT))),
"{out:?}"
);
assert_eq!(b.items["minecraft:rotten_flesh"], 63);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_purchase_too_dear_to_count_is_refused() {
let mut config = Config::default();
config.items.insert(
"minecraft:diamond".into(),
Price {
buy: MAX_AMOUNT,
sell: 0,
},
);
let (mut e, mut m, me, dir) = setup_with("dear", config);
let mut b = bag(&[], 64 * 36);
let out = m
.command(&mut e, &me, true, &mut b, "buy diamond 2")
.unwrap();
assert_eq!(out, [Out::Error("That costs too much at once.".into())]);
let out = m
.command(&mut e, &me, true, &mut b, "buy diamond 0")
.unwrap();
assert!(matches!(&out[0], Out::Error(_)), "{out:?}");
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn the_old_default_prices_become_the_new_ones_and_edited_ones_stay() {
let mut config = Config {
items: OLD_PRICES
.iter()
.map(|(id, buy, sell)| {
(
format!("minecraft:{id}"),
Price {
buy: *buy,
sell: *sell,
},
)
})
.collect(),
..Config::default()
};
let mut edited = config.clone();
assert!(migrate(&mut config));
assert_eq!(config.items, Config::default().items);
assert!(!migrate(&mut config), "once");
edited.items.get_mut("minecraft:diamond").unwrap().sell = 90;
let before = edited.clone();
assert!(!migrate(&mut edited));
assert_eq!(edited, before);
}
}
+784
View File
@@ -0,0 +1,784 @@
// Places: the server's spawn (`/spawn`, `/setspawn`), each player's homes
// (`/home`, `/sethome`, `/delhome`, `/homes`), warps (`/warp`, `/warps`,
// `/setwarp`, `/delwarp`), teleport requests between players (`/tpa`,
// `/tpaccept`, `/tpdeny`), `/back` (where the player died, or was before
// their last teleport), `/playtime` and `/rules`. A teleport waits `warmup_seconds`,
// and a player who moves or is hurt meanwhile stays where they are; the glue
// moves them with a vanilla `tp` run as the server. The number of homes grows
// with the time a player has played here, counted by the mod. Everything is
// in `places.json`, written atomically. No game types. Added to the game as
// `game::smp_places`; also a module of this crate, for its tests.
use super::smp_economy::{Out, Player};
use super::smp_store;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
/// `config.json`'s `places` section.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// How long a player stands still before a teleport (0: at once).
pub warmup_seconds: i64,
/// How long a `/tpa` request waits for its answer.
pub request_seconds: i64,
/// Between two teleports of one player.
pub cooldown_seconds: i64,
/// Homes a player may set, by hours played here: `[hours, homes]`, the
/// last line reached counts.
pub homes: Vec<[i64; 2]>,
/// `/rules`, a line each.
pub rules: Vec<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
warmup_seconds: 3,
request_seconds: 60,
cooldown_seconds: 10,
homes: vec![[0, 1], [5, 2], [20, 3], [100, 5]],
rules: vec![
"Be respectful to everyone.".into(),
"No griefing, no stealing, no cheats.".into(),
"The currency is earned in game only: never bought or sold for real money.".into(),
],
}
}
}
/// A place in a level.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Place {
/// `minecraft:overworld`, `minecraft:the_nether`, `minecraft:the_end`.
pub dimension: String,
pub x: f64,
pub y: f64,
pub z: f64,
}
/// A player on the server, as the places see them.
#[derive(Clone, Debug, PartialEq)]
pub struct Body {
pub uuid: u128,
pub name: String,
pub at: Place,
pub health: f32,
}
/// `places.json`.
#[derive(Default, Serialize, Deserialize)]
#[serde(default)]
struct Data {
spawn: Option<Place>,
warps: BTreeMap<String, Place>,
/// Homes by player UUID, then by name.
homes: BTreeMap<String, BTreeMap<String, Place>>,
/// Seconds played here, by player UUID.
played: BTreeMap<String, i64>,
}
/// Where a teleport goes.
#[derive(Clone, Debug, PartialEq)]
enum To {
/// A place, and what it is (`home "base"`).
Place(Place, String),
/// A player, wherever they are when the warmup ends.
Player(u128, String),
}
/// A teleport waiting for its warmup.
#[derive(Clone, Debug, PartialEq)]
pub struct Pending {
pub uuid: u128,
to: To,
from: Place,
health: f32,
due: i64,
}
/// A `/tpa` request: `from` asks to go to `to`.
struct Request {
from: u128,
from_name: String,
to: u128,
at: i64,
}
/// A teleport to make now: the player's name and where.
#[derive(Clone, Debug, PartialEq)]
pub struct Teleport {
pub uuid: u128,
pub name: String,
pub to: Place,
/// What the player is told once there.
pub done: String,
}
/// The places' commands.
pub const COMMANDS: [&str; 16] = [
"spawn", "setspawn", "home", "sethome", "delhome", "homes", "warp", "warps", "setwarp",
"delwarp", "tpa", "tpaccept", "tpdeny", "rules", "back", "playtime",
];
pub struct Places {
pub config: Config,
path: PathBuf,
data: Data,
/// Homes, warps or the spawn changed: written at the next save.
changed: bool,
/// Time played changed: written once a minute.
played_at: Option<i64>,
pub pending: Vec<Pending>,
requests: Vec<Request>,
last: HashMap<u128, i64>,
/// Where `/back` goes: the place and whether they died there. Not kept
/// over a restart.
back: HashMap<u128, (Place, bool)>,
/// The players seen dead (health 0) and not respawned yet.
dead: std::collections::HashSet<u128>,
}
/// `2 h 05 min`, `12 min`.
fn duration(seconds: i64) -> String {
let (h, m) = (seconds / 3600, seconds % 3600 / 60);
if h > 0 {
format!("{h} h {m:02} min")
} else {
format!("{m} min")
}
}
/// A home's or a warp's name: 1 to 16 of `a-z 0-9 _ -`.
fn name_ok(name: &str) -> bool {
(1..=16).contains(&name.len())
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "_-".contains(c))
}
fn moved(a: &Place, b: &Place) -> bool {
let d = (a.x - b.x).powi(2) + (a.y - b.y).powi(2) + (a.z - b.z).powi(2);
a.dimension != b.dimension || d > 0.25
}
impl Places {
/// `places.json` in `dir`; an error when it exists but can't be read
/// (never replaced by an empty one).
pub fn open(dir: &Path, config: Config) -> Result<Self, String> {
let path = dir.join("places.json");
let data = smp_store::read_json(&path)?;
Ok(Self {
config,
path,
data,
changed: false,
played_at: None,
pending: Vec::new(),
requests: Vec::new(),
last: HashMap::new(),
back: HashMap::new(),
dead: Default::default(),
})
}
/// Writes `places.json` when homes, warps or the spawn changed, or once
/// a minute when only the time played did.
pub fn save(&mut self, now: i64) -> Result<(), String> {
let played = self.played_at.is_some_and(|t| now - t >= 60_000);
if !self.changed && !played {
return Ok(());
}
smp_store::write_json(&self.path, &self.data)?;
self.changed = false;
self.played_at = None;
Ok(())
}
/// Counts a second played for each of these players.
pub fn played(&mut self, uuids: &[u128], now: i64) {
for &u in uuids {
*self
.data
.played
.entry(smp_store::uuid_string(u))
.or_default() += 1;
}
if !uuids.is_empty() {
self.played_at.get_or_insert(now);
}
}
/// The seconds a player has played here.
pub fn played_seconds(&self, uuid: u128) -> i64 {
self.data
.played
.get(&smp_store::uuid_string(uuid))
.copied()
.unwrap_or(0)
}
/// How many homes a player may have now.
pub fn home_limit(&self, uuid: u128) -> usize {
let hours = self
.data
.played
.get(&smp_store::uuid_string(uuid))
.copied()
.unwrap_or(0)
/ 3600;
self.config
.homes
.iter()
.filter(|[h, _]| hours >= *h)
.map(|[_, n]| (*n).max(0) as usize)
.next_back()
.unwrap_or(0)
}
/// Starts a teleport's warmup, or says why not.
fn start(&mut self, me: &Body, to: To, now: i64) -> Vec<Out> {
if let Some(&t) = self.last.get(&me.uuid) {
let wait = t + self.config.cooldown_seconds * 1000 - now;
if wait > 0 {
return vec![Out::Error(format!(
"Wait {} s before your next teleport.",
(wait + 999) / 1000
))];
}
}
self.pending.retain(|p| p.uuid != me.uuid);
self.pending.push(Pending {
uuid: me.uuid,
to,
from: me.at.clone(),
health: me.health,
due: now + self.config.warmup_seconds.max(0) * 1000,
});
if self.config.warmup_seconds > 0 {
vec![Out::Reply(format!(
"Teleporting in {} s: don't move.",
self.config.warmup_seconds
))]
} else {
Vec::new()
}
}
/// The places' commands; None for another command. `bodies` are the
/// players on, `me` among them.
pub fn command(
&mut self,
me: &Player,
bodies: &[Body],
now: i64,
line: &str,
) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
let command = *words.first()?;
if !COMMANDS.contains(&command) {
return None;
}
let Some(body) = bodies.iter().find(|b| b.uuid == me.uuid) else {
return Some(vec![Out::Error("You are not in the world.".into())]);
};
let key = smp_store::uuid_string(me.uuid);
let named = |n: &str| bodies.iter().find(|b| b.name.eq_ignore_ascii_case(n));
Some(match (command, &words[1..]) {
("back", []) => match self.back.get(&me.uuid).cloned() {
Some((p, died)) => {
let what = if died {
"where you died"
} else {
"where you were"
};
self.start(body, To::Place(p, what.into()), now)
}
None => vec![Out::Error(
"Nowhere to go back to yet: /back follows a death or a teleport.".into(),
)],
},
("playtime", []) => vec![Out::Reply(format!(
"You have played here for {}.",
duration(self.played_seconds(me.uuid))
))],
("playtime", [name]) => match named(name) {
Some(b) => vec![Out::Reply(format!(
"{} has played here for {}.",
b.name,
duration(self.played_seconds(b.uuid))
))],
None => vec![Out::Error(format!("{name} isn't on."))],
},
("rules", []) => {
let mut out = vec![Out::Reply("Rules of this server:".into())];
for (i, r) in self.config.rules.iter().enumerate() {
out.push(Out::Reply(format!("{}. {r}", i + 1)));
}
out
}
("spawn", []) => match self.data.spawn.clone() {
Some(p) => self.start(body, To::Place(p, "the spawn".into()), now),
None => vec![Out::Error(
"No spawn yet: an operator sets it with /setspawn.".into(),
)],
},
("setspawn", []) if me.level >= 3 => {
self.data.spawn = Some(body.at.clone());
self.changed = true;
vec![Out::Reply("The spawn is here now.".into())]
}
("homes", []) => {
let homes = self.data.homes.get(&key);
let names: Vec<&str> = homes
.into_iter()
.flat_map(|h| h.keys())
.map(String::as_str)
.collect();
vec![Out::Reply(format!(
"Your homes ({} of {}): {}",
names.len(),
self.home_limit(me.uuid),
if names.is_empty() {
"none yet, /sethome [name]".into()
} else {
names.join(", ")
}
))]
}
("home", rest) | ("sethome", rest) | ("delhome", rest) if rest.len() <= 1 => {
let name = rest
.first()
.map_or("home".to_owned(), |n| n.to_ascii_lowercase());
if !name_ok(&name) {
return Some(vec![Out::Error(
"A home's name: up to 16 of a-z, 0-9, _ and -.".into(),
)]);
}
let homes = self.data.homes.get(&key);
let has = homes.is_some_and(|h| h.contains_key(&name));
match command {
"home" => match homes.and_then(|h| h.get(&name)).cloned() {
Some(p) => self.start(body, To::Place(p, format!("home \"{name}\"")), now),
None => vec![Out::Error(format!(
"No home \"{name}\": /homes lists yours."
))],
},
"sethome" => {
let count = homes.map_or(0, |h| h.len());
let limit = self.home_limit(me.uuid);
if !has && count >= limit {
vec![Out::Error(format!(
"You have {count} of {limit} homes: play longer for more, or move one (/sethome <name> over it)."
))]
} else {
self.data
.homes
.entry(key)
.or_default()
.insert(name.clone(), body.at.clone());
self.changed = true;
vec![Out::Reply(format!("Home \"{name}\" set here."))]
}
}
_ if has => {
if let Some(h) = self.data.homes.get_mut(&key) {
h.remove(&name);
if h.is_empty() {
self.data.homes.remove(&key);
}
}
self.changed = true;
vec![Out::Reply(format!("Home \"{name}\" deleted."))]
}
_ => vec![Out::Error(format!("No home \"{name}\"."))],
}
}
("warps", []) => {
let names: Vec<&str> = self.data.warps.keys().map(String::as_str).collect();
vec![Out::Reply(if names.is_empty() {
"No warps yet.".into()
} else {
format!("Warps: {}", names.join(", "))
})]
}
("warp", [name]) => match self.data.warps.get(&name.to_ascii_lowercase()).cloned() {
Some(p) => self.start(
body,
To::Place(p, format!("warp \"{}\"", name.to_ascii_lowercase())),
now,
),
None => vec![Out::Error(format!(
"No warp \"{name}\": /warps lists them."
))],
},
("setwarp", [name]) if me.level >= 3 => {
let name = name.to_ascii_lowercase();
if !name_ok(&name) {
return Some(vec![Out::Error(
"A warp's name: up to 16 of a-z, 0-9, _ and -.".into(),
)]);
}
self.data.warps.insert(name.clone(), body.at.clone());
self.changed = true;
vec![Out::Reply(format!("Warp \"{name}\" set here."))]
}
("delwarp", [name]) if me.level >= 3 => {
if self.data.warps.remove(&name.to_ascii_lowercase()).is_some() {
self.changed = true;
vec![Out::Reply(format!("Warp \"{name}\" deleted."))]
} else {
vec![Out::Error(format!("No warp \"{name}\"."))]
}
}
("tpa", [name]) => match named(name) {
None => vec![Out::Error(format!("{name} is not on."))],
Some(t) if t.uuid == me.uuid => vec![Out::Error("That's you.".into())],
Some(t) => {
self.requests.retain(|r| r.from != me.uuid);
self.requests.push(Request {
from: me.uuid,
from_name: me.name.clone(),
to: t.uuid,
at: now,
});
vec![
Out::Reply(format!(
"Asked {} to teleport to them ({} s to answer).",
t.name, self.config.request_seconds
)),
Out::Tell(
t.uuid,
format!(
"{} asks to teleport to you: /tpaccept {0} or /tpdeny {0}.",
me.name
),
),
]
}
},
("tpaccept", rest) | ("tpdeny", rest) if rest.len() <= 1 => {
let found = self.requests.iter().rposition(|r| {
r.to == me.uuid
&& rest
.first()
.is_none_or(|n| r.from_name.eq_ignore_ascii_case(n))
});
let Some(i) = found else {
return Some(vec![Out::Error("No teleport request to answer.".into())]);
};
let r = self.requests.remove(i);
if command == "tpdeny" {
return Some(vec![
Out::Reply(format!("You turned down {}'s request.", r.from_name)),
Out::Tell(r.from, format!("{} turned down your request.", me.name)),
]);
}
let Some(from) = bodies.iter().find(|b| b.uuid == r.from) else {
return Some(vec![Out::Error(format!("{} is gone.", r.from_name))]);
};
let mut out = vec![Out::Reply(format!("{} is on the way.", r.from_name))];
let start = self.start(from, To::Player(me.uuid, me.name.clone()), now);
// The warmup's lines are for the one who moves.
out.extend(start.into_iter().map(|o| match o {
Out::Reply(s) | Out::Error(s) => Out::Tell(r.from, s),
o => o,
}));
out
}
_ => vec![Out::Error(
match command {
"home" | "sethome" | "delhome" => {
"Usage: /home, /sethome, /delhome [name]; /homes"
}
"warp" | "setwarp" | "delwarp" => "Usage: /warp <name>; /warps",
"tpa" => "Usage: /tpa <player>",
"tpaccept" | "tpdeny" => "Usage: /tpaccept [player], /tpdeny [player]",
_ => "You may not use this command.",
}
.into(),
)],
})
}
/// Each tick: warmups that ended become teleports, those of players who
/// moved, were hurt or left are called off; requests nobody answered
/// expire.
pub fn tick(&mut self, bodies: &[Body], now: i64) -> (Vec<Out>, Vec<Teleport>) {
let mut out = Vec::new();
let mut go = Vec::new();
let wait = self.config.request_seconds * 1000;
self.requests.retain(|r| {
let keep = now - r.at < wait;
if !keep {
out.push(Out::Tell(r.from, "Your teleport request expired.".into()));
}
keep
});
// A death, seen once a second: /back goes there.
for b in bodies {
if b.health <= 0.0 {
if self.dead.insert(b.uuid) {
self.back.insert(b.uuid, (b.at.clone(), true));
}
} else {
self.dead.remove(&b.uuid);
}
}
for p in std::mem::take(&mut self.pending) {
let Some(me) = bodies.iter().find(|b| b.uuid == p.uuid) else {
continue;
};
if moved(&p.from, &me.at) {
out.push(Out::Tell(p.uuid, "Teleport called off: you moved.".into()));
} else if me.health < p.health || me.health <= 0.0 {
out.push(Out::Tell(
p.uuid,
"Teleport called off: you were hurt.".into(),
));
} else if now < p.due {
self.pending.push(p);
} else {
let (to, what) = match &p.to {
To::Place(place, what) => (Some(place.clone()), what.clone()),
To::Player(u, name) => (
bodies.iter().find(|b| b.uuid == *u).map(|b| b.at.clone()),
name.clone(),
),
};
match to {
Some(to) => {
self.last.insert(p.uuid, now);
self.back.insert(p.uuid, (p.from.clone(), false));
go.push(Teleport {
uuid: p.uuid,
name: me.name.clone(),
to,
done: format!("Teleported to {what}."),
});
}
None => out.push(Out::Tell(
p.uuid,
format!("Teleport called off: {what} is gone."),
)),
}
}
}
(out, go)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
const A: u128 = 1;
const B: u128 = 2;
fn dir() -> PathBuf {
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("smp-places-{}-{n}", std::process::id()))
}
fn place(x: f64) -> Place {
Place {
dimension: "minecraft:overworld".into(),
x,
y: 64.0,
z: 0.0,
}
}
fn body(uuid: u128, x: f64) -> Body {
Body {
uuid,
name: if uuid == A { "Alex" } else { "Steve" }.into(),
at: place(x),
health: 20.0,
}
}
fn player(uuid: u128, level: u8) -> Player {
Player {
uuid,
name: if uuid == A { "Alex" } else { "Steve" }.into(),
level,
}
}
#[test]
fn a_home_is_reached_after_the_warmup_unless_the_player_moves() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
let here = [body(A, 10.0)];
let out = p.command(&player(A, 0), &here, 0, "sethome base").unwrap();
assert_eq!(out, vec![Out::Reply("Home \"base\" set here.".into())]);
let there = [body(A, 500.0)];
p.command(&player(A, 0), &there, 0, "home base").unwrap();
assert!(p.tick(&there, 2_999).1.is_empty(), "the warmup");
let (_, go) = p.tick(&there, 3_000);
assert_eq!(go[0].to, place(10.0));
assert_eq!(go[0].name, "Alex");
// The cooldown, then a move calls the next one off.
let out = p
.command(&player(A, 0), &there, 5_000, "home base")
.unwrap();
assert!(
matches!(&out[0], Out::Error(s) if s.contains("Wait")),
"{out:?}"
);
p.command(&player(A, 0), &there, 13_000, "home base")
.unwrap();
let (out, go) = p.tick(&[body(A, 501.0)], 14_000);
assert!(go.is_empty());
assert_eq!(
out,
vec![Out::Tell(A, "Teleport called off: you moved.".into())]
);
// Saved and read back.
p.save(0).unwrap();
let again = Places::open(&d, Config::default()).unwrap();
assert_eq!(
again.data.homes[&smp_store::uuid_string(A)]["base"],
place(10.0)
);
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn back_goes_where_the_player_died_or_was_before_a_teleport() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
let out = p
.command(&player(A, 0), &[body(A, 0.0)], 0, "back")
.unwrap();
assert!(matches!(&out[0], Out::Error(s) if s.contains("Nowhere")));
// Dead at x 40: seen by the next tick, once.
let mut dead = body(A, 40.0);
dead.health = 0.0;
p.tick(&[dead.clone()], 1_000);
dead.at = place(41.0);
p.tick(&[dead], 2_000);
let alive = [body(A, 0.0)];
p.tick(&alive, 3_000);
p.command(&player(A, 0), &alive, 3_000, "back").unwrap();
let (_, go) = p.tick(&alive, 6_000);
assert_eq!(go[0].to, place(40.0));
assert_eq!(go[0].done, "Teleported to where you died.");
// Then /back goes to where that teleport started.
let there = [body(A, 40.0)];
p.command(&player(A, 0), &there, 20_000, "back").unwrap();
let (_, go) = p.tick(&there, 23_000);
assert_eq!(go[0].to, place(0.0));
assert_eq!(go[0].done, "Teleported to where you were.");
p.played(&[A], 0);
let out = p.command(&player(A, 0), &there, 0, "playtime").unwrap();
assert_eq!(
out,
vec![Out::Reply("You have played here for 0 min.".into())]
);
assert_eq!(duration(7_500), "2 h 05 min");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn homes_grow_with_time_played() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
let here = [body(A, 0.0)];
p.command(&player(A, 0), &here, 0, "sethome").unwrap();
let out = p.command(&player(A, 0), &here, 0, "sethome two").unwrap();
assert!(
matches!(&out[0], Out::Error(s) if s.contains("1 of 1")),
"{out:?}"
);
// Moving a home is always allowed.
let out = p.command(&player(A, 0), &here, 0, "sethome home").unwrap();
assert!(matches!(&out[0], Out::Reply(_)));
p.save(0).unwrap();
for _ in 0..5 * 3600 {
p.played(&[A], 0);
}
assert_eq!(p.home_limit(A), 2);
// Time played is written once a minute.
p.save(59_999).unwrap();
assert_eq!(
Places::open(&d, Config::default()).unwrap().home_limit(A),
1
);
p.save(60_000).unwrap();
let out = p.command(&player(A, 0), &here, 0, "sethome two").unwrap();
assert!(matches!(&out[0], Out::Reply(_)), "{out:?}");
let again = Places::open(&d, Config::default()).unwrap();
assert_eq!(again.home_limit(A), 2);
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn a_request_moves_the_asker_to_the_one_who_accepts() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
let both = [body(A, 0.0), body(B, 100.0)];
let out = p.command(&player(A, 0), &both, 0, "tpa steve").unwrap();
assert!(
matches!(&out[1], Out::Tell(B, s) if s.contains("/tpaccept Alex")),
"{out:?}"
);
let out = p.command(&player(B, 0), &both, 1_000, "tpaccept").unwrap();
assert_eq!(
out[1],
Out::Tell(A, "Teleporting in 3 s: don't move.".into())
);
// Steve walked on meanwhile: Alex lands where Steve is then.
let later = [body(A, 0.0), body(B, 120.0)];
let (_, go) = p.tick(&later, 4_000);
assert_eq!((go[0].uuid, go[0].to.x), (A, 120.0));
// A hurt player stays; an unanswered request expires.
p.command(&player(A, 0), &later, 20_000, "tpa steve")
.unwrap();
p.command(&player(B, 0), &later, 20_000, "tpaccept alex")
.unwrap();
let mut hurt = later.clone();
hurt[0].health = 15.0;
let (out, go) = p.tick(&hurt, 21_000);
assert!(go.is_empty());
assert_eq!(
out,
vec![Out::Tell(A, "Teleport called off: you were hurt.".into())]
);
p.command(&player(A, 0), &later, 30_000, "tpa steve")
.unwrap();
let (out, _) = p.tick(&later, 90_000);
assert_eq!(
out,
vec![Out::Tell(A, "Your teleport request expired.".into())]
);
let out = p.command(&player(B, 0), &later, 90_000, "tpdeny").unwrap();
assert!(matches!(&out[0], Out::Error(_)));
}
#[test]
fn spawn_and_warps_are_set_by_operators() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
let here = [body(A, 7.0)];
let out = p.command(&player(A, 0), &here, 0, "spawn").unwrap();
assert!(matches!(&out[0], Out::Error(s) if s.contains("/setspawn")));
let out = p.command(&player(A, 0), &here, 0, "setspawn").unwrap();
assert!(matches!(&out[0], Out::Error(_)), "level 0");
p.command(&player(A, 3), &here, 0, "setspawn").unwrap();
p.command(&player(A, 3), &here, 0, "setwarp Shop").unwrap();
let out = p.command(&player(A, 0), &here, 0, "warps").unwrap();
assert_eq!(out, vec![Out::Reply("Warps: shop".into())]);
p.command(&player(A, 0), &[body(A, 50.0)], 0, "spawn")
.unwrap();
let (_, go) = p.tick(&[body(A, 50.0)], 3_000);
assert_eq!(
(go[0].to.x, go[0].done.as_str()),
(7.0, "Teleported to the spawn.")
);
let out = p.command(&player(A, 0), &here, 0, "rules").unwrap();
assert_eq!(out.len(), 4);
let _ = std::fs::remove_dir_all(d);
}
}
+382
View File
@@ -0,0 +1,382 @@
// Who is on, and how: a welcome for a player's first join (a line to
// everyone, the welcome and the rules to the newcomer), a reward once a day
// for being on, AFK players (no move, no turn and no command for
// `afk_minutes`, or `/afk`) left out of the sleep count, and a limit on how
// fast one player runs the SMP's commands. Looked at once a second, never
// per tick. No game types. Added to the game as `game::smp_presence`; also
// a module of this crate, for its tests.
use super::smp_economy::{Out, Player};
use super::smp_store;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
pub const COMMANDS: [&str; 1] = ["afk"];
/// `config.json`'s `presence` section.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// The line everyone sees when a player joins for the first time
/// (`{player}` filled in); empty: none.
pub first_join: String,
/// What the newcomer reads, before the rules.
pub welcome: Vec<String>,
/// Paid once a day (UTC) to each player who is on; 0: none.
pub daily_reward: i64,
/// Minutes without a move, a turn or a command before a player is AFK;
/// 0: only `/afk`.
pub afk_minutes: i64,
/// Whether everyone hears that a player is AFK (else only them).
pub afk_announce: bool,
/// At most this many SMP commands in `spam_seconds` a player (operators
/// of level 3 aside); 0: no limit.
pub spam_commands: usize,
pub spam_seconds: i64,
}
impl Default for Config {
fn default() -> Self {
Self {
first_join: "{player} joined for the first time: welcome!".into(),
welcome: vec![
"Welcome to the server, {player}!".into(),
"Set a home with /sethome, claim your land with /claim, see the richest with /baltop.".into(),
],
daily_reward: 50,
afk_minutes: 5,
afk_announce: false,
spam_commands: 8,
spam_seconds: 5,
}
}
}
/// `presence.json`.
#[derive(Default, Serialize, Deserialize)]
#[serde(default)]
struct Data {
/// The players who have joined, by UUID.
known: BTreeSet<String>,
/// The last day (UTC, `yyyy-mm-dd`) each player got the daily reward.
rewarded: BTreeMap<String, String>,
}
/// A player on, as the presence sees them: where they are and look.
pub struct Seen {
pub uuid: u128,
pub name: String,
/// x, y, z, yaw, pitch.
pub pose: [f64; 5],
}
/// What the game does for the presence.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Act {
/// Pays the daily reward (logged as `daily_reward`).
Reward(u128),
/// Leaves the player out of the sleep count, or counts them again.
Sleep(u128, bool),
}
pub struct Presence {
pub config: Config,
path: PathBuf,
data: Data,
dirty: bool,
/// Each player on: their pose a second ago, and when they last moved,
/// turned or ran a command (ms).
on: HashMap<u128, ([f64; 5], i64)>,
afk: HashSet<u128>,
/// Each player's last commands (ms), for the limit.
recent: HashMap<u128, VecDeque<i64>>,
}
impl Presence {
/// Opens `presence.json`. Without it, the players `known` already (the
/// economy's accounts) aren't welcomed as newcomers.
pub fn open(
dir: &Path,
config: Config,
known: impl IntoIterator<Item = u128>,
) -> Result<Self, String> {
let path = dir.join("presence.json");
let fresh = !path.exists();
let mut data: Data = smp_store::read_json(&path)?;
if fresh {
data.known = known.into_iter().map(smp_store::uuid_string).collect();
}
Ok(Self {
config,
path,
data,
dirty: fresh,
on: HashMap::new(),
afk: HashSet::new(),
recent: HashMap::new(),
})
}
pub fn save(&mut self) -> Result<(), String> {
if self.dirty {
smp_store::write_json(&self.path, &self.data)?;
self.dirty = false;
}
Ok(())
}
/// Once a second: who joined, who left, who moved; `today` is the UTC
/// date, `rules` `/rules`' lines.
pub fn tick(
&mut self,
players: &[Seen],
today: &str,
now: i64,
rules: &[String],
) -> (Vec<Out>, Vec<Act>) {
let (mut out, mut acts) = (Vec::new(), Vec::new());
let gone: Vec<u128> = self
.on
.keys()
.filter(|u| !players.iter().any(|p| p.uuid == **u))
.copied()
.collect();
for uuid in gone {
self.on.remove(&uuid);
self.recent.remove(&uuid);
if self.afk.remove(&uuid) {
acts.push(Act::Sleep(uuid, false));
}
}
for p in players {
let key = smp_store::uuid_string(p.uuid);
let Some((pose, active)) = self.on.get_mut(&p.uuid) else {
// Joined.
self.on.insert(p.uuid, (p.pose, now));
if self.data.known.insert(key.clone()) {
self.dirty = true;
let fill = |l: &str| l.replace("{player}", &p.name);
if !self.config.first_join.is_empty() {
out.push(Out::All(fill(&self.config.first_join)));
}
out.extend(
self.config
.welcome
.iter()
.map(|l| Out::Tell(p.uuid, fill(l))),
);
if !rules.is_empty() {
out.push(Out::Tell(p.uuid, "The rules:".into()));
out.extend(
rules
.iter()
.enumerate()
.map(|(i, r)| Out::Tell(p.uuid, format!("{}. {r}", i + 1))),
);
}
}
continue;
};
let moved = pose.iter().zip(&p.pose).enumerate().any(|(i, (a, b))| {
// A hundredth of a block, half a degree.
(a - b).abs() > if i < 3 { 0.01 } else { 0.5 }
});
*pose = p.pose;
if moved {
*active = now;
if self.afk.remove(&p.uuid) {
out.extend(self.back(p.uuid, &p.name, &mut acts));
}
} else if self.config.afk_minutes > 0
&& now - *active >= self.config.afk_minutes * 60_000
&& self.afk.insert(p.uuid)
{
out.push(self.away(p.uuid, &p.name, &mut acts));
}
}
// Each player's first second on today, or the day turning while on.
if self.config.daily_reward > 0 {
for p in players {
let key = smp_store::uuid_string(p.uuid);
if self.data.rewarded.get(&key).map(String::as_str) != Some(today) {
self.data.rewarded.insert(key, today.to_owned());
self.dirty = true;
acts.push(Act::Reward(p.uuid));
}
}
}
(out, acts)
}
fn away(&mut self, uuid: u128, name: &str, acts: &mut Vec<Act>) -> Out {
acts.push(Act::Sleep(uuid, true));
if self.config.afk_announce {
Out::All(format!("{name} is AFK."))
} else {
Out::Tell(
uuid,
"You are AFK: you don't count for sleeping until you move.".into(),
)
}
}
fn back(&mut self, uuid: u128, name: &str, acts: &mut Vec<Act>) -> Vec<Out> {
acts.push(Act::Sleep(uuid, false));
vec![if self.config.afk_announce {
Out::All(format!("{name} is back."))
} else {
Out::Tell(uuid, "You are back.".into())
}]
}
/// Before each of a player's SMP commands: counts it as activity, and
/// refuses it past the limit.
pub fn allow(&mut self, me: &Player, now: i64) -> Result<(Vec<Out>, Vec<Act>), Out> {
let (limit, window) = (self.config.spam_commands, self.config.spam_seconds * 1000);
if limit > 0 && me.level < 3 {
let recent = self.recent.entry(me.uuid).or_default();
while recent.front().is_some_and(|t| now - t >= window) {
recent.pop_front();
}
if recent.len() >= limit {
return Err(Out::Error(format!(
"Slow down: at most {limit} commands every {} s.",
self.config.spam_seconds
)));
}
recent.push_back(now);
}
if let Some((_, active)) = self.on.get_mut(&me.uuid) {
*active = now;
}
let mut acts = Vec::new();
let out = if self.afk.remove(&me.uuid) {
self.back(me.uuid, &me.name, &mut acts)
} else {
Vec::new()
};
Ok((out, acts))
}
/// `/afk`: AFK until the player moves, turns or runs a command.
pub fn command(&mut self, me: &Player, line: &str) -> Option<(Vec<Out>, Vec<Act>)> {
if line.trim() != "afk" {
return None;
}
let mut acts = Vec::new();
if !self.afk.insert(me.uuid) {
return Some((vec![Out::Reply("You are already AFK.".into())], acts));
}
let out = self.away(me.uuid, &me.name, &mut acts);
Some((vec![out], acts))
}
}
#[cfg(test)]
mod tests {
use super::*;
const A: u128 = 1;
const B: u128 = 2;
fn seen(uuid: u128, x: f64) -> Seen {
Seen {
uuid,
name: if uuid == A { "Alex" } else { "Steve" }.into(),
pose: [x, 64.0, 0.0, 0.0, 0.0],
}
}
fn me(uuid: u128) -> Player {
Player {
uuid,
name: "Alex".into(),
level: 0,
}
}
fn open(known: Vec<u128>) -> (Presence, PathBuf) {
let dir = std::env::temp_dir().join(format!(
"smp-presence-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
(Presence::open(&dir, Config::default(), known).unwrap(), dir)
}
#[test]
fn a_first_join_is_welcomed_once_and_known_players_are_not() {
let (mut p, dir) = open(vec![B]);
let rules = vec!["Be kind.".to_owned()];
let (out, _) = p.tick(&[seen(A, 0.0), seen(B, 0.0)], "2026-10-31", 0, &rules);
let all: Vec<_> = out.iter().filter(|o| matches!(o, Out::All(_))).collect();
assert_eq!(all.len(), 1, "{out:?}");
assert!(matches!(all[0], Out::All(l) if l.starts_with("Alex joined for the first time")));
assert!(
out.iter()
.any(|o| matches!(o, Out::Tell(A, l) if l == "1. Be kind."))
);
assert!(!out.iter().any(|o| matches!(o, Out::Tell(B, _))));
p.save().unwrap();
// Back after leaving, or after a restart: no welcome.
p.tick(&[], "2026-10-31", 1_000, &rules);
let (out, _) = p.tick(&[seen(A, 0.0)], "2026-10-31", 2_000, &rules);
assert!(out.is_empty(), "{out:?}");
let mut again = Presence::open(&dir, Config::default(), []).unwrap();
let (out, _) = again.tick(&[seen(A, 0.0)], "2026-10-31", 0, &rules);
assert!(out.is_empty(), "{out:?}");
}
#[test]
fn the_daily_reward_comes_once_a_day() {
let (mut p, _) = open(vec![]);
let (_, acts) = p.tick(&[seen(A, 0.0)], "2026-10-31", 0, &[]);
assert_eq!(acts, vec![Act::Reward(A)]);
p.tick(&[], "2026-10-31", 1_000, &[]);
let (_, acts) = p.tick(&[seen(A, 0.0)], "2026-10-31", 2_000, &[]);
assert!(acts.is_empty(), "{acts:?}");
// Midnight with the player on.
let (_, acts) = p.tick(&[seen(A, 0.0)], "2026-11-01", 3_000, &[]);
assert_eq!(acts, vec![Act::Reward(A)]);
}
#[test]
fn a_still_player_goes_afk_and_comes_back_by_moving_or_a_command() {
let (mut p, _) = open(vec![A]);
p.config.daily_reward = 0;
p.tick(&[seen(A, 0.0)], "d", 0, &[]);
let (_, acts) = p.tick(&[seen(A, 0.0)], "d", 299_000, &[]);
assert!(acts.is_empty());
let (out, acts) = p.tick(&[seen(A, 0.0)], "d", 300_000, &[]);
assert_eq!(acts, vec![Act::Sleep(A, true)]);
assert!(matches!(&out[..], [Out::Tell(A, _)]));
let (_, acts) = p.tick(&[seen(A, 0.0)], "d", 301_000, &[]);
assert!(acts.is_empty(), "once");
let (_, acts) = p.tick(&[seen(A, 1.0)], "d", 302_000, &[]);
assert_eq!(acts, vec![Act::Sleep(A, false)]);
// /afk, then a command.
let (_, acts) = p.command(&me(A), "afk").unwrap();
assert_eq!(acts, vec![Act::Sleep(A, true)]);
let (_, acts) = p.allow(&me(A), 303_000).unwrap();
assert_eq!(acts, vec![Act::Sleep(A, false)]);
// Leaving while AFK counts them again.
p.command(&me(A), "afk");
let (_, acts) = p.tick(&[], "d", 304_000, &[]);
assert_eq!(acts, vec![Act::Sleep(A, false)]);
}
#[test]
fn commands_past_the_limit_wait() {
let (mut p, _) = open(vec![A]);
for i in 0..8 {
assert!(p.allow(&me(A), i * 100).is_ok());
}
assert!(p.allow(&me(A), 900).is_err());
assert!(p.allow(&me(A), 5_000).is_ok(), "the first left the window");
let op = Player { level: 3, ..me(A) };
assert!(p.allow(&op, 5_001).is_ok());
}
}
+454
View File
@@ -0,0 +1,454 @@
// Whether the market's prices let anyone get rich by crafting: buying items,
// crafting or smelting them into others, and selling those for more than
// they cost. The cheapest way to get each item from the market is found over
// the recipes (Bellman-Ford: a recipe lowers its result's cost to what its
// inputs cost, until nothing changes); an item the market buys for more than
// that cost is a loop. Fuel is left out, so a loop is found even where the
// fuel would eat the profit. The vanilla recipes that matter for the default
// prices are written here as facts (`CHAINS`); the tests also walk every
// recipe of the game's own data when it is there. No game types. Added to
// the game as `game::smp_recipes`; also a module of this crate, for its
// tests.
use super::smp_market::Price;
use std::collections::BTreeMap;
/// A recipe: `count` of `result` from the ingredients, each a choice of
/// items (any one of them) and how many of it.
#[derive(Clone, Debug, PartialEq)]
pub struct Recipe {
pub result: String,
pub count: u32,
pub inputs: Vec<(Vec<String>, u32)>,
}
/// An item the market buys for more than it costs to make from what the
/// market sells.
#[derive(Clone, Debug, PartialEq)]
pub struct Loop {
pub item: String,
/// What the market pays for one.
pub sell: i64,
/// The least one costs, bought or crafted.
pub cost: f64,
}
/// A written recipe: result, count, [(ingredient, how many)].
type Chain = (&'static str, u32, &'static [(&'static str, u32)]);
/// The compression and food chains around the default prices.
const CHAINS: &[Chain] = &[
("iron_block", 1, &[("iron_ingot", 9)]),
("iron_ingot", 9, &[("iron_block", 1)]),
("iron_nugget", 9, &[("iron_ingot", 1)]),
("iron_ingot", 1, &[("iron_nugget", 9)]),
("iron_ingot", 1, &[("raw_iron", 1)]),
("raw_iron_block", 1, &[("raw_iron", 9)]),
("raw_iron", 9, &[("raw_iron_block", 1)]),
("gold_block", 1, &[("gold_ingot", 9)]),
("gold_ingot", 9, &[("gold_block", 1)]),
("gold_nugget", 9, &[("gold_ingot", 1)]),
("gold_ingot", 1, &[("gold_nugget", 9)]),
("gold_ingot", 1, &[("raw_gold", 1)]),
("copper_block", 1, &[("copper_ingot", 9)]),
("copper_ingot", 9, &[("copper_block", 1)]),
("copper_ingot", 1, &[("raw_copper", 1)]),
("diamond_block", 1, &[("diamond", 9)]),
("diamond", 9, &[("diamond_block", 1)]),
("emerald_block", 1, &[("emerald", 9)]),
("emerald", 9, &[("emerald_block", 1)]),
("coal_block", 1, &[("coal", 9)]),
("coal", 9, &[("coal_block", 1)]),
("redstone_block", 1, &[("redstone", 9)]),
("redstone", 9, &[("redstone_block", 1)]),
("lapis_block", 1, &[("lapis_lazuli", 9)]),
("lapis_lazuli", 9, &[("lapis_block", 1)]),
("quartz_block", 1, &[("quartz", 4)]),
("hay_block", 1, &[("wheat", 9)]),
("wheat", 9, &[("hay_block", 1)]),
("bread", 1, &[("wheat", 3)]),
("melon", 1, &[("melon_slice", 9)]),
("melon_seeds", 1, &[("melon_slice", 1)]),
("pumpkin_seeds", 4, &[("pumpkin", 1)]),
("sugar", 1, &[("sugar_cane", 1)]),
("paper", 3, &[("sugar_cane", 3)]),
("bone_meal", 3, &[("bone", 1)]),
("bone_block", 1, &[("bone_meal", 9)]),
("bone_meal", 9, &[("bone_block", 1)]),
("slime_block", 1, &[("slime_ball", 9)]),
("slime_ball", 9, &[("slime_block", 1)]),
("white_wool", 1, &[("string", 4)]),
("dried_kelp_block", 1, &[("dried_kelp", 9)]),
("oak_planks", 4, &[("oak_log", 1)]),
("spruce_planks", 4, &[("spruce_log", 1)]),
("birch_planks", 4, &[("birch_log", 1)]),
("stick", 4, &[("oak_planks", 2)]),
("torch", 4, &[("coal", 1), ("stick", 1)]),
("charcoal", 1, &[("oak_log", 1)]),
("stone", 1, &[("cobblestone", 1)]),
("stone_bricks", 4, &[("stone", 4)]),
("glass", 1, &[("sand", 1)]),
("cooked_beef", 1, &[("beef", 1)]),
("cooked_porkchop", 1, &[("porkchop", 1)]),
("cooked_chicken", 1, &[("chicken", 1)]),
("baked_potato", 1, &[("potato", 1)]),
("golden_carrot", 1, &[("carrot", 1), ("gold_nugget", 8)]),
("blaze_powder", 2, &[("blaze_rod", 1)]),
("leather", 1, &[("rabbit_hide", 4)]),
("tnt", 1, &[("gunpowder", 5), ("sand", 4)]),
];
/// `CHAINS` as recipes.
pub fn chains() -> Vec<Recipe> {
CHAINS
.iter()
.map(|(result, count, inputs)| Recipe {
result: format!("minecraft:{result}"),
count: *count,
inputs: inputs
.iter()
.map(|(i, n)| (vec![format!("minecraft:{i}")], *n))
.collect(),
})
.collect()
}
/// The least each item costs from the market: bought, or crafted from what
/// the market sells (absent: no way at all).
pub fn costs(prices: &BTreeMap<String, Price>, recipes: &[Recipe]) -> BTreeMap<String, f64> {
let mut cost: BTreeMap<String, f64> = prices
.iter()
.filter(|(_, p)| p.buy > 0)
.map(|(id, p)| (id.clone(), p.buy as f64))
.collect();
// Each round can only lower a cost; as many rounds as recipes is
// Bellman-Ford's bound, and the loop stops as soon as nothing changes.
for _ in 0..=recipes.len() {
let mut changed = false;
for r in recipes {
if r.count == 0 {
continue;
}
let mut total = 0.0;
let mut makeable = true;
for (choices, n) in &r.inputs {
let best = choices
.iter()
.filter_map(|c| cost.get(c))
.fold(f64::INFINITY, |a, &b| a.min(b));
if !best.is_finite() {
makeable = false;
break;
}
total += best * f64::from(*n);
}
if !makeable {
continue;
}
let each = total / f64::from(r.count);
let old = cost.get(&r.result).copied().unwrap_or(f64::INFINITY);
if each < old - 1e-9 {
cost.insert(r.result.clone(), each);
changed = true;
}
}
if !changed {
break;
}
}
cost
}
/// The items the market buys for more than they cost to get from it.
pub fn loops(prices: &BTreeMap<String, Price>, recipes: &[Recipe]) -> Vec<Loop> {
let cost = costs(prices, recipes);
prices
.iter()
.filter(|(_, p)| p.sell > 0)
.filter_map(|(id, p)| {
let c = *cost.get(id)?;
(p.sell as f64 > c + 1e-9).then(|| Loop {
item: id.clone(),
sell: p.sell,
cost: c,
})
})
.collect()
}
#[cfg(test)]
/// An ingredient of a recipe file: an item, a `#tag`, or a list of either,
/// as the items it allows (`tags` maps a tag to its items, tags in tags
/// resolved).
fn choices(v: &serde_json::Value, tags: &BTreeMap<String, Vec<String>>) -> Vec<String> {
match v {
serde_json::Value::String(s) => match s.strip_prefix('#') {
Some(tag) => tags.get(tag).cloned().unwrap_or_default(),
None => vec![s.clone()],
},
serde_json::Value::Array(a) => a.iter().flat_map(|x| choices(x, tags)).collect(),
serde_json::Value::Object(o) => o
.get("item")
.or_else(|| o.get("tag"))
.map(|x| choices(x, tags))
.unwrap_or_default(),
_ => Vec::new(),
}
}
#[cfg(test)]
/// A vanilla recipe file (`data/minecraft/recipe/*.json`) as a recipe, for
/// the kinds that turn items into items: shaped and shapeless crafting,
/// transmuting, the furnaces and the campfire, the stonecutter and the
/// smithing table. None for the others (dyeing, fireworks...).
pub fn parse(json: &serde_json::Value, tags: &BTreeMap<String, Vec<String>>) -> Option<Recipe> {
let kind = json.get("type")?.as_str()?;
let result = json.get("result")?;
let (result, count) = match result {
serde_json::Value::String(s) => (s.clone(), 1),
r => (
r.get("id")?.as_str()?.to_owned(),
r.get("count").and_then(|c| c.as_u64()).unwrap_or(1) as u32,
),
};
let one = |key: &str| json.get(key).map(|v| (choices(v, tags), 1));
let inputs: Vec<(Vec<String>, u32)> = match kind.strip_prefix("minecraft:")? {
"crafting_shaped" => {
let key = json.get("key")?.as_object()?;
let mut counts: BTreeMap<char, u32> = BTreeMap::new();
for row in json.get("pattern")?.as_array()? {
for c in row.as_str()?.chars().filter(|c| *c != ' ') {
*counts.entry(c).or_default() += 1;
}
}
counts
.into_iter()
.map(|(c, n)| Some((choices(key.get(&c.to_string())?, tags), n)))
.collect::<Option<_>>()?
}
"crafting_shapeless" => json
.get("ingredients")?
.as_array()?
.iter()
.map(|i| (choices(i, tags), 1))
.collect(),
"crafting_transmute" => vec![one("input")?, one("material")?],
"smelting" | "blasting" | "smoking" | "campfire_cooking" | "stonecutting" => {
vec![one("ingredient")?]
}
"smithing_transform" => vec![one("template")?, one("base")?, one("addition")?],
_ => return None,
};
// An ingredient nothing can fill makes the recipe useless here.
if inputs.iter().any(|(c, _)| c.is_empty()) {
return None;
}
Some(Recipe {
result,
count,
inputs,
})
}
#[cfg(test)]
/// Item tags (`data/minecraft/tags/item/*.json`, by name: `logs`) resolved
/// into items, as `minecraft:logs` → items.
pub fn resolve_tags(raw: &BTreeMap<String, Vec<String>>) -> BTreeMap<String, Vec<String>> {
fn expand(name: &str, raw: &BTreeMap<String, Vec<String>>, depth: u32, out: &mut Vec<String>) {
let Some(values) = raw.get(name) else {
return;
};
for v in values {
match v.strip_prefix('#') {
Some(t) if depth < 16 => expand(t, raw, depth + 1, out),
Some(_) => {}
None => out.push(v.clone()),
}
}
}
raw.keys()
.map(|name| {
let mut out = Vec::new();
expand(name, raw, 0, &mut out);
(name.clone(), out)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::super::smp_market::Config;
use super::*;
use std::path::PathBuf;
fn prices(list: &[(&str, i64, i64)]) -> BTreeMap<String, Price> {
list.iter()
.map(|(id, buy, sell)| {
(
format!("minecraft:{id}"),
Price {
buy: *buy,
sell: *sell,
},
)
})
.collect()
}
#[test]
fn a_block_sold_dearer_than_its_ingots_is_a_loop() {
// 9 ingots at 30 make a block the market would buy for 300.
let p = prices(&[("iron_ingot", 30, 8), ("iron_block", 0, 300)]);
let found = loops(&p, &chains());
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].item, "minecraft:iron_block");
assert!((found[0].cost - 270.0).abs() < 1e-9);
// The other way: a cheap block uncrafted into ingots the market buys.
let p = prices(&[("iron_ingot", 30, 8), ("iron_block", 60, 0)]);
let found = loops(&p, &chains());
assert_eq!(found[0].item, "minecraft:iron_ingot", "{found:?}");
}
#[test]
fn bread_from_cheap_wheat_and_planks_from_cheap_logs_are_loops() {
let p = prices(&[("wheat", 1, 0), ("bread", 5, 4)]);
assert_eq!(loops(&p, &chains())[0].item, "minecraft:bread");
let p = prices(&[("oak_log", 4, 1), ("oak_planks", 0, 2)]);
assert_eq!(loops(&p, &chains())[0].item, "minecraft:oak_planks");
// Through two recipes: logs → planks → sticks.
let p = prices(&[("oak_log", 4, 1), ("stick", 0, 1)]);
assert_eq!(loops(&p, &chains())[0].item, "minecraft:stick");
}
#[test]
fn the_default_prices_have_no_loop() {
let config = Config::default();
assert_eq!(loops(&config.items, &chains()), []);
for (id, p) in &config.items {
assert!(
p.buy == 0 || p.sell < p.buy,
"{id} sells for {} and buys for {}",
p.sell,
p.buy
);
}
}
#[test]
fn recipe_files_read_as_recipes() {
let tags = resolve_tags(
&[
(
"minecraft:planks",
vec!["minecraft:oak_planks", "#minecraft:x"],
),
("minecraft:x", vec![]),
(
"minecraft:oak_logs",
vec!["minecraft:oak_log", "minecraft:oak_wood"],
),
]
.into_iter()
.map(|(k, v)| (k.to_owned(), v.into_iter().map(str::to_owned).collect()))
.collect(),
);
let shaped: serde_json::Value = serde_json::from_str(
r##"{"type":"minecraft:crafting_shaped","key":{"#":"minecraft:iron_ingot","S":"#minecraft:planks"},
"pattern":["# #","#S#"," # "],"result":{"id":"minecraft:bucket"}}"##,
)
.unwrap();
let r = parse(&shaped, &tags).unwrap();
assert_eq!(r.result, "minecraft:bucket");
assert_eq!(
r.inputs,
[
(vec!["minecraft:iron_ingot".to_owned()], 5),
(vec!["minecraft:oak_planks".to_owned()], 1)
]
);
let shapeless: serde_json::Value = serde_json::from_str(
r##"{"type":"minecraft:crafting_shapeless","ingredients":["#minecraft:oak_logs"],"result":{"count":4,"id":"minecraft:oak_planks"}}"##,
)
.unwrap();
let r = parse(&shapeless, &tags).unwrap();
assert_eq!((r.count, r.inputs[0].0.len()), (4, 2));
let smelt: serde_json::Value = serde_json::from_str(
r#"{"type":"minecraft:smelting","ingredient":["minecraft:a","minecraft:b"],"result":{"id":"minecraft:c"}}"#,
)
.unwrap();
assert_eq!(parse(&smelt, &tags).unwrap().inputs[0].0.len(), 2);
let special: serde_json::Value =
serde_json::from_str(r#"{"type":"minecraft:crafting_special_repairitem"}"#).unwrap();
assert_eq!(parse(&special, &tags), None);
}
/// The game's own recipe and tag files: `SMP_VANILLA_DATA` (a
/// `data/minecraft` folder), or a Horizon checkout's prepared resources
/// beside this repository. None when neither is there (CI).
fn vanilla_data() -> Option<PathBuf> {
if let Some(p) = std::env::var_os("SMP_VANILLA_DATA") {
return Some(PathBuf::from(p));
}
let home = PathBuf::from(std::env::var_os("HOME")?);
let p = home.join("code/horizon/.local/horizon-client/26.3/data/minecraft");
p.join("recipe").is_dir().then_some(p)
}
#[test]
fn the_default_prices_have_no_loop_through_any_vanilla_recipe() {
let Some(data) = vanilla_data() else {
eprintln!("no vanilla data (SMP_VANILLA_DATA): only the written chains are checked");
return;
};
let read = |p: &std::path::Path| -> serde_json::Value {
serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
};
// Tags in folders too: `tags/item/enchantable/sword.json` is
// `minecraft:enchantable/sword`.
fn walk(dir: &std::path::Path, out: &mut Vec<PathBuf>) {
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
walk(&path, out);
} else if path.extension().is_some_and(|e| e == "json") {
out.push(path);
}
}
}
let root = data.join("tags/item");
let mut files = Vec::new();
walk(&root, &mut files);
let mut raw = BTreeMap::new();
for path in files {
let name = path.strip_prefix(&root).unwrap().with_extension("");
let values = read(&path)["values"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| {
v.as_str()
.or_else(|| v.get("id")?.as_str())
.map(str::to_owned)
})
.collect();
raw.insert(format!("minecraft:{}", name.to_string_lossy()), values);
}
let tags = resolve_tags(&raw);
let mut recipes = Vec::new();
for entry in std::fs::read_dir(data.join("recipe")).unwrap() {
let path = entry.unwrap().path();
if path.extension().is_some_and(|e| e == "json")
&& let Some(r) = parse(&read(&path), &tags)
{
recipes.push(r);
}
}
assert!(recipes.len() > 1000, "{} recipes", recipes.len());
let config = Config::default();
assert_eq!(loops(&config.items, &recipes), []);
// The checker does find a loop through the real recipes.
let mut cheap = config.items.clone();
cheap.insert("minecraft:iron_block".into(), Price { buy: 0, sell: 300 });
assert_eq!(loops(&cheap, &recipes)[0].item, "minecraft:iron_block");
}
}
+489
View File
@@ -0,0 +1,489 @@
// `/shop`: the market (smp_market) as a chest menu. The first page shows the
// categories; a category's page its items, with what the market asks and
// pays (in its name). A click buys one, a shift-click a stack of 64; a click on a stack in
// your own inventory sells it; "Sell all" sells everything the market buys.
// Buying and selling go through the market's own rules (its prices, today's
// limits, items first then money, logged). This file only lays the pages out
// and says what a click means; the glue opens the menu and moves the items.
// No game types. Added to the game as `game::smp_shop`; also a module of this
// crate, for its tests.
use super::smp_market::{Config as MarketConfig, Price, item_name};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Rows of the menu, and its slots.
pub const ROWS: u8 = 6;
pub const SIZE: usize = 54;
/// A page's item slots (the rows above the bottom one).
pub const PER_PAGE: usize = 45;
/// What a shift-click buys.
pub const STACK: u32 = 64;
/// The bottom row's buttons.
const BACK: usize = 45;
const HELP: usize = 46;
const PREV: usize = 47;
const BALANCE: usize = 48;
const SELL_ALL: usize = 49;
const LIMITS: usize = 50;
const NEXT: usize = 51;
const CLOSE: usize = 53;
/// Where the first page puts the categories: the middle of rows 2 to 4.
const CATEGORY_SLOTS: [usize; 21] = [
10, 11, 12, 13, 14, 15, 16, 19, 20, 21, 22, 23, 24, 25, 28, 29, 30, 31, 32, 33, 34,
];
/// A category: its name, the item its button shows, its items.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Category {
pub name: String,
pub icon: String,
pub items: Vec<String>,
}
/// `config.json`'s `shop`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub enabled: bool,
pub title: String,
/// The market's items not in any category go to an "Other" one.
pub categories: Vec<Category>,
}
impl Default for Config {
fn default() -> Self {
let cat = |name: &str, icon: &str, items: &[&str]| Category {
name: name.into(),
icon: format!("minecraft:{icon}"),
items: items.iter().map(|i| format!("minecraft:{i}")).collect(),
};
Self {
enabled: true,
title: "Shop".into(),
categories: vec![
cat(
"Farming",
"wheat",
&[
"wheat",
"wheat_seeds",
"carrot",
"potato",
"beetroot",
"sugar_cane",
"pumpkin",
"melon_slice",
"cactus",
"bone_meal",
],
),
cat(
"Mining",
"diamond",
&[
"coal",
"copper_ingot",
"iron_ingot",
"gold_ingot",
"redstone",
"lapis_lazuli",
"quartz",
"emerald",
"diamond",
],
),
cat(
"Wood",
"oak_log",
&[
"oak_log",
"spruce_log",
"birch_log",
"jungle_log",
"acacia_log",
"dark_oak_log",
"mangrove_log",
"cherry_log",
],
),
cat(
"Mob drops",
"bone",
&[
"rotten_flesh",
"bone",
"string",
"gunpowder",
"spider_eye",
"slime_ball",
"blaze_rod",
"leather",
"feather",
],
),
cat(
"Building",
"bricks",
&[
"cobblestone",
"stone",
"dirt",
"sand",
"gravel",
"glass",
"bricks",
"stone_bricks",
],
),
cat(
"Food",
"bread",
&[
"bread",
"cooked_beef",
"cooked_porkchop",
"cooked_chicken",
"baked_potato",
"apple",
"golden_carrot",
],
),
cat(
"Tools",
"torch",
&["torch", "ladder", "crafting_table", "chest", "furnace"],
),
],
}
}
}
/// Which page a player sees.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Page {
Main,
/// A category (by index in `categories`) and its page from 0.
Category(usize, usize),
}
/// One slot of the menu.
#[derive(Clone, Debug, PartialEq)]
pub enum Cell {
Empty,
/// The bottom row's background.
Filler,
/// A category's button: its icon, name, and how many items it has.
Category(String, String, usize),
/// An item and its price.
Item(String, Price),
Back,
Prev,
Next,
Balance,
SellAll,
Limits,
Help,
Close,
}
/// What a click asks for.
#[derive(Clone, Debug, PartialEq)]
pub enum Click {
/// Nothing (an empty slot, a filler).
Nothing,
/// Show this page.
Show(Page),
/// Buy this many of the item.
Buy(String, u32),
/// Sell everything the market buys.
SellAll,
Close,
}
/// The categories as shown: each with only the items the market trades,
/// those it trades in none of them under "Other", the empty ones left out.
pub fn categories(config: &Config, market: &MarketConfig) -> Vec<Category> {
let traded = |id: &String| {
market
.items
.get(id)
.is_some_and(|p| p.buy > 0 || p.sell > 0)
};
let mut out: Vec<Category> = config
.categories
.iter()
.map(|c| Category {
items: c.items.iter().filter(|i| traded(i)).cloned().collect(),
..c.clone()
})
.collect();
let other: Vec<String> = market
.items
.keys()
.filter(|id| traded(id) && !out.iter().any(|c| c.items.contains(id)))
.cloned()
.collect();
if !other.is_empty() {
out.push(Category {
name: "Other".into(),
icon: "minecraft:chest".into(),
items: other,
});
}
out.retain(|c| !c.items.is_empty());
out
}
/// The players' open shop menus: their window and page.
#[derive(Default)]
pub struct Shop {
pub views: HashMap<u128, (Option<i32>, Page)>,
}
impl Shop {
/// The page `uuid` sees, as cells.
pub fn layout(&self, config: &Config, market: &MarketConfig, uuid: u128) -> Vec<Cell> {
let page = self.views.get(&uuid).map_or(Page::Main, |v| v.1);
let cats = categories(config, market);
let mut cells = vec![Cell::Empty; SIZE];
for c in &mut cells[PER_PAGE..] {
*c = Cell::Filler;
}
cells[HELP] = Cell::Help;
cells[BALANCE] = Cell::Balance;
cells[SELL_ALL] = Cell::SellAll;
cells[LIMITS] = Cell::Limits;
cells[CLOSE] = Cell::Close;
match page {
Page::Main => {
for (slot, c) in CATEGORY_SLOTS.iter().zip(&cats) {
cells[*slot] = Cell::Category(c.icon.clone(), c.name.clone(), c.items.len());
}
}
Page::Category(i, p) => {
cells[BACK] = Cell::Back;
let items = cats.get(i).map(|c| c.items.as_slice()).unwrap_or_default();
for (slot, id) in items.iter().skip(p * PER_PAGE).take(PER_PAGE).enumerate() {
cells[slot] = Cell::Item(
id.clone(),
market.items.get(id).copied().unwrap_or_default(),
);
}
if p > 0 {
cells[PREV] = Cell::Prev;
}
if (p + 1) * PER_PAGE < items.len() {
cells[NEXT] = Cell::Next;
}
}
}
cells
}
/// What a click on the menu's `slot` means (`shift`: a shift-click); a
/// new page is kept as the player's.
pub fn click(
&mut self,
config: &Config,
market: &MarketConfig,
uuid: u128,
slot: usize,
shift: bool,
) -> Click {
let cells = self.layout(config, market, uuid);
let page = self.views.get(&uuid).map_or(Page::Main, |v| v.1);
let click = match (cells.get(slot), page) {
(Some(Cell::Category(_, name, _)), _) => {
let i = categories(config, market)
.iter()
.position(|c| &c.name == name);
i.map_or(Click::Nothing, |i| Click::Show(Page::Category(i, 0)))
}
(Some(Cell::Item(id, price)), _) if price.buy > 0 => {
Click::Buy(id.clone(), if shift { STACK } else { 1 })
}
(Some(Cell::Back), _) => Click::Show(Page::Main),
(Some(Cell::Prev), Page::Category(i, p)) => {
Click::Show(Page::Category(i, p.saturating_sub(1)))
}
(Some(Cell::Next), Page::Category(i, p)) => Click::Show(Page::Category(i, p + 1)),
(Some(Cell::SellAll), _) => Click::SellAll,
(Some(Cell::Close), _) => Click::Close,
_ => Click::Nothing,
};
if let (Click::Show(p), Some(v)) = (&click, self.views.get_mut(&uuid)) {
v.1 = *p;
}
click
}
}
/// An item's button: its name and what the market asks and pays.
pub fn item_label(id: &str, price: Price, money: &dyn Fn(i64) -> String) -> String {
let mut parts = Vec::new();
if price.buy > 0 {
parts.push(format!("buy {}", money(price.buy)));
}
if price.sell > 0 {
parts.push(format!("sell {}", money(price.sell)));
}
format!("{}: {}", title(id), parts.join(", "))
}
/// The help button's text.
pub const HELP_TEXT: &str = "Click: buy 1 · Shift-click: buy 64 · Click a stack of yours: sell it";
/// `minecraft:iron_ingot` as `Iron Ingot`, for a button.
pub fn title(id: &str) -> String {
item_name(id)
.split(' ')
.map(|w| {
let mut c = w.chars();
c.next()
.map(|f| f.to_uppercase().chain(c).collect())
.unwrap_or_default()
})
.collect::<Vec<String>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
const ALEX: u128 = 1;
fn open(shop: &mut Shop) {
shop.views.insert(ALEX, (Some(3), Page::Main));
}
#[test]
fn the_first_page_shows_the_categories_with_the_market_s_items() {
let (config, mut market) = (Config::default(), MarketConfig::default());
// An item in no category: under "Other". One the market doesn't
// trade: nowhere.
market
.items
.insert("minecraft:sponge".into(), Price { buy: 50, sell: 0 });
market.items.remove("minecraft:torch");
let cats = categories(&config, &market);
assert_eq!(cats.last().unwrap().name, "Other");
assert_eq!(cats.last().unwrap().items, ["minecraft:sponge"]);
assert!(
cats.iter()
.all(|c| !c.items.contains(&"minecraft:torch".to_owned()))
);
// Every default item is in a category of its own.
let all = categories(&config, &MarketConfig::default());
assert!(all.iter().all(|c| c.name != "Other"), "{all:?}");
let mut shop = Shop::default();
open(&mut shop);
let cells = shop.layout(&config, &market, ALEX);
assert_eq!(
cells[10],
Cell::Category("minecraft:wheat".into(), "Farming".into(), 10)
);
assert_eq!(
(cells[BALANCE].clone(), cells[CLOSE].clone()),
(Cell::Balance, Cell::Close)
);
assert_eq!(cells[BACK], Cell::Filler);
}
#[test]
fn clicks_open_a_category_buy_one_or_a_stack_and_go_back() {
let (config, market) = (Config::default(), MarketConfig::default());
let mut shop = Shop::default();
open(&mut shop);
// Mining: the second category.
assert_eq!(
shop.click(&config, &market, ALEX, 11, false),
Click::Show(Page::Category(1, 0))
);
let cells = shop.layout(&config, &market, ALEX);
assert_eq!(
cells[0],
Cell::Item("minecraft:coal".into(), Price { buy: 8, sell: 2 })
);
assert_eq!(cells[BACK], Cell::Back);
assert_eq!(
shop.click(&config, &market, ALEX, 0, false),
Click::Buy("minecraft:coal".into(), 1)
);
assert_eq!(
shop.click(&config, &market, ALEX, 0, true),
Click::Buy("minecraft:coal".into(), 64)
);
assert_eq!(
shop.click(&config, &market, ALEX, 40, false),
Click::Nothing
);
assert_eq!(
shop.click(&config, &market, ALEX, SELL_ALL, false),
Click::SellAll
);
assert_eq!(
shop.click(&config, &market, ALEX, BACK, false),
Click::Show(Page::Main)
);
assert_eq!(shop.views[&ALEX].1, Page::Main);
assert_eq!(
shop.click(&config, &market, ALEX, CLOSE, false),
Click::Close
);
}
#[test]
fn an_item_the_market_only_buys_is_not_sold_and_long_categories_have_pages() {
let mut market = MarketConfig::default();
let mut config = Config::default();
// Rotten flesh: the market buys it, never sells it.
let mut shop = Shop::default();
open(&mut shop);
shop.click(&config, &market, ALEX, 13, false);
assert_eq!(shop.views[&ALEX].1, Page::Category(3, 0));
assert_eq!(shop.click(&config, &market, ALEX, 0, false), Click::Nothing);
// 50 items in one category: two pages.
let many: Vec<String> = (0..50).map(|i| format!("minecraft:item_{i}")).collect();
for id in &many {
market.items.insert(id.clone(), Price { buy: 2, sell: 1 });
}
config.categories = vec![Category {
name: "All".into(),
icon: "minecraft:chest".into(),
items: many,
}];
shop.views.insert(ALEX, (Some(3), Page::Category(0, 0)));
let cells = shop.layout(&config, &market, ALEX);
assert_eq!(
(cells[PREV].clone(), cells[NEXT].clone()),
(Cell::Filler, Cell::Next)
);
assert_eq!(
shop.click(&config, &market, ALEX, NEXT, false),
Click::Show(Page::Category(0, 1))
);
let cells = shop.layout(&config, &market, ALEX);
assert_eq!(
cells[0],
Cell::Item("minecraft:item_45".into(), Price { buy: 2, sell: 1 })
);
assert_eq!(cells[5], Cell::Empty);
assert_eq!(cells[PREV], Cell::Prev);
}
#[test]
fn labels_read_well() {
let money = |n: i64| format!("{n} coins");
assert_eq!(
item_label("minecraft:iron_ingot", Price { buy: 30, sell: 8 }, &money),
"Iron Ingot: buy 30 coins, sell 8 coins"
);
assert_eq!(
item_label("minecraft:bone", Price { buy: 0, sell: 1 }, &money),
"Bone: sell 1 coins"
);
}
}
+388 -13
View File
@@ -15,11 +15,15 @@ Steve, `horizon-ctl --offscreen`) join it. Checked from the clients' own chat an
comes back; the money in the game stays the same throughout;
- the lottery: two players' tickets held, one drawn after `lottery_seconds`, the winner announced to everyone and paid
the pot;
- after the server stops cleanly and starts again, the balances are the same, and audit.log holds every change.
- after the server stops cleanly and starts again, the balances are the same, and audit.log holds every change;
- /money and /eco give * for an operator, then the /shop menu: a category, one item bought by a click and a stack by
a shift-click, a stack of the player's own sold by clicking it, "Sell all" stopped by the daily limit (the rest
kept, the limit shown in the menu and in /worth, the day's sales in market.json), the close button.
Everything runs in fresh temporary folders (the server's, each client's home, launcher and XDG folders), no audio.
"""
import json
import math
import os
import shutil
import socket
@@ -98,6 +102,14 @@ class Server:
return self.lines
def new_lines(before, after):
"""The lines `after` adds to `before`: the client keeps only its last 100 chat lines, so the oldest may be gone."""
for n in range(min(len(before), len(after)), -1, -1):
if before[len(before) - n:] == after[:n]:
return after[n:]
return after
class Client:
"""A real client under horizon-ctl, joined to the server as `name`."""
@@ -125,10 +137,16 @@ class Client:
def say(self, line):
"""A command typed in chat; returns the chat lines it brought."""
before = len(self.chat())
before = self.chat()
self.run("chat", line)
time.sleep(1.5)
return self.chat()[before:]
# The answer, however busy the machine: up to 10 s for a first line,
# then a moment for the rest of it.
for _ in range(40):
time.sleep(0.25)
if self.chat() != before:
break
time.sleep(1)
return new_lines(before, self.chat())
def chat(self):
"""Every chat line so far, oldest first (`messages` answers newest first)."""
@@ -214,11 +232,327 @@ def lottery(alex, steve, alex_wins):
assert len(results["draw"]) == 1 and "won 50 coins" in results["draw"][0], results
assert results["draw"][0] in steve.chat()[-3:], "everyone hears the draw"
alex_lotto = results["draw"][0].startswith("Lottery: Alex won")
assert any(f"Balance: {alex_had - 30 + (50 if alex_lotto else 0)} coins" in l for l in alex.say("/balance"))
assert any(f"Balance: {steve_had - 20 + (0 if alex_lotto else 50)} coins" in l for l in steve.say("/balance"))
results["alex_after"] = alex.say("/balance")
assert any(f"Balance: {alex_had - 30 + (50 if alex_lotto else 0)} coins" in l for l in results["alex_after"]), (
results, alex_had, alex.chat()[-12:])
results["steve_after"] = steve.say("/balance")
assert any(f"Balance: {steve_had - 20 + (0 if alex_lotto else 50)} coins" in l for l in results["steve_after"]), (
results, steve_had, steve.chat()[-12:])
return results, alex_lotto
def where(c):
"""The client's dimension, position and health."""
s = c.run("state")
return s.get("dimension"), s["player"]["position"], s["player"]["health"]
def near(a, b):
return sum((x - y) ** 2 for x, y in zip(a, b)) < 1.5 ** 2
def places(server, alex, steve):
"""Spawn, homes, warps, /tpa and /rules; teleports across levels, called off by a move or a hit."""
r = {}
r["setspawn"] = alex.say("/setspawn")
assert any("The spawn is here now." in l for l in r["setspawn"]), r
r["sethome"] = steve.say("/sethome base")
assert any('Home "base" set here.' in l for l in r["sethome"]), r
_, home, _ = where(steve)
r["limit"] = steve.say("/sethome two")
assert any("You have 1 of 1 homes" in l for l in r["limit"]), r
# To the Nether's bedrock roof, then home, across levels.
server.console("execute in minecraft:the_nether run tp Steve 0.5 128 0.5")
time.sleep(5)
assert where(steve)[0] == "minecraft:the_nether", where(steve)
r["home"] = steve.say("/home base")
assert any("Teleporting in 2 s: don't move." in l for l in r["home"]), r
time.sleep(5)
dim, at, _ = where(steve)
r["home_reached"] = [dim, at]
assert dim == "minecraft:overworld" and near(at, home), (r, home)
assert any('Teleported to home "base".' in l for l in steve.chat()[-4:]), steve.chat()[-4:]
# A move or a hit during the warmup calls it off.
steve.say("/spawn")
steve.run("move", "forward", 1)
time.sleep(2)
r["moved"] = steve.chat()[-3:]
assert any("Teleport called off: you moved." in l for l in r["moved"]), r
steve.say("/spawn")
server.console("damage Steve 2")
time.sleep(3)
r["hurt"] = steve.chat()[-3:]
assert any("Teleport called off: you were hurt." in l for l in r["hurt"]), r
# A request: Alex goes to Steve once Steve accepts.
alex.say("/tpa Steve")
assert any("Alex asks to teleport to you" in l for l in steve.chat()[-3:]), steve.chat()[-3:]
steve.say("/tpaccept")
time.sleep(4)
r["tpa"] = [where(alex)[1], where(steve)[1]]
assert near(*r["tpa"]), r
alex.say("/setwarp shop")
r["warps"] = steve.say("/warps")
assert any("Warps: shop" in l for l in r["warps"]), r
r["warp_refused"] = steve.say("/setwarp mine")
assert not any("set here" in l for l in r["warp_refused"]), r
r["rules"] = steve.say("/rules")
assert any("1. Be respectful to everyone." in l for l in r["rules"]), r
return r
def claims(server, alex, steve):
"""Alex claims a chunk: Steve can't break, use or place there until trusted."""
r = {}
_, (ax, ay, az), _ = where(alex)
cx, cz, y = math.floor(ax) >> 4, math.floor(az) >> 4, math.floor(ay)
r["claim"] = alex.say("/claim")
assert any("Claimed this chunk" in l for l in r["claim"]), r
lever, dirt, free = (cx * 16 + 8, y, cz * 16 + 8), (cx * 16 + 10, y, cz * 16 + 8), (cx * 16 + 8, y, cz * 16 + 10)
server.console("setblock %d %d %d minecraft:lever[face=floor]" % lever)
server.console("setblock %d %d %d minecraft:dirt" % dirt)
server.console(f"tp Steve {lever[0] + 0.5} {y} {lever[2] + 3.5}")
server.console("give Steve minecraft:oak_planks 4")
time.sleep(3)
block = lambda pos: json.dumps(steve.run("block", *pos))
steve.run("interact", *lever)
time.sleep(0.5)
# The action bar fades after 3 s: read right after the click (a busy machine may take that long to break).
said = json.dumps(steve.run("messages", 5))
steve.run("break", *dirt, "--timeout", 4, check=False)
steve.run("place", *free, "--item", "minecraft:oak_planks", check=False)
time.sleep(1.5)
r["refused"] = [block(lever), block(dirt), block(free), said + json.dumps(steve.run("messages", 5))]
# Not broken (grass may spread onto the dirt meanwhile).
assert "powered=true" not in r["refused"][0] and "minecraft:air" not in r["refused"][1], r
assert "oak_planks" not in r["refused"][2], r
assert "This land is claimed by Alex." in r["refused"][3], r
r["trust"] = alex.say("/trust Steve")
assert any("Steve may now build" in l for l in r["trust"]), r
steve.run("interact", *lever)
steve.run("break", *dirt, "--timeout", 10, check=False)
time.sleep(1.5)
r["trusted"] = [block(lever), block(dirt)]
assert "powered=true" in r["trusted"][0] and '"block": "minecraft:air"' in r["trusted"][1], r
r["claims"] = alex.say("/claims")
assert any("Your chunks (1 of 4)" in l for l in r["claims"]) and any("Trusted: Steve" in l for l in r["claims"]), r
return r
def world_guard(server, alex, steve):
"""Nothing reaches Alex's chunk from outside: water, fire, a piston, TNT, Steve's bucket and fists."""
r = {}
_, (ax, ay, az), _ = where(alex)
x0, z0, y = (math.floor(ax) >> 4) * 16, (math.floor(az) >> 4) * 16, math.floor(ay)
r["untrust"] = alex.say("/untrust Steve")
block = lambda x, yy, z: json.dumps(steve.run("block", x, yy, z))
for line in ("weather clear", f"tp Alex {x0 + 8.5} {y} {z0 - 12.5}", f"tp Steve {x0 + 4.5} {y} {z0 - 1.5}",
# Water two blocks east of the chunk; fire on netherrack against planks on both sides of its
# west edge (more planks outside, so some burn soon).
f"setblock {x0 + 17} {y} {z0 + 8} minecraft:water",
f"setblock {x0 - 1} {y - 1} {z0 + 8} minecraft:netherrack",
f"setblock {x0} {y} {z0 + 8} minecraft:oak_planks",
f"setblock {x0 - 2} {y} {z0 + 8} minecraft:oak_planks",
f"setblock {x0 - 1} {y} {z0 + 7} minecraft:oak_planks",
f"setblock {x0 - 1} {y} {z0 + 9} minecraft:oak_planks",
f"setblock {x0 - 1} {y + 1} {z0 + 8} minecraft:oak_planks",
f"setblock {x0 - 1} {y} {z0 + 8} minecraft:fire",
# North of the chunk: a piston pushing dirt into it, another pushing away from it.
f"setblock {x0 + 8} {y} {z0 - 2} minecraft:piston[facing=south]",
f"setblock {x0 + 8} {y} {z0 - 1} minecraft:dirt",
f"setblock {x0 + 12} {y} {z0 - 2} minecraft:piston[facing=north]",
f"setblock {x0 + 12} {y} {z0 - 3} minecraft:dirt",
f"setblock {x0 + 8} {y} {z0 - 3} minecraft:redstone_block",
f"setblock {x0 + 12} {y} {z0 - 1} minecraft:redstone_block",
"give Steve minecraft:water_bucket",
f"summon minecraft:cow {x0 + 5.5} {y} {z0 + 0.5} {{NoAI:1b}}"):
server.console(line)
time.sleep(4)
r["water"] = [block(x0 + 16, y, z0 + 8), block(x0 + 15, y, z0 + 8)]
assert "water" in r["water"][0] and "water" not in r["water"][1], r
r["pistons"] = [block(x0 + 8, y, z0 - 2), block(x0 + 8, y, z0 - 1), block(x0 + 12, y, z0 - 2)]
assert "extended=false" in r["pistons"][0] and ("dirt" in r["pistons"][1] or "grass_block" in r["pistons"][1]), r
assert "extended=true" in r["pistons"][2], r
# Steve's bucket on the chunk's ground: no water; then on the ground outside it.
steve.run("look-at-block", x0 + 3, y - 1, z0 + 1, "up")
steve.run("use-item")
time.sleep(1.5)
r["bucket"] = [block(x0 + 3, y, z0 + 1), json.dumps(steve.run("messages", 5))]
assert "water" not in r["bucket"][0] and "This land is claimed by Alex." in r["bucket"][1], r
# Steve's fist on Alex's cow: nothing, until Alex trusts him.
cow = steve.run("entities", 8, "cow")["entities"][0]
steve.run("attack", cow["id"])
time.sleep(1.5)
r["cow"] = [steve.run("entities", 8, "cow")["entities"][0]["health"]]
r["trust"] = alex.say("/trust Steve")
steve.run("attack", cow["id"])
time.sleep(1.5)
r["cow"].append(steve.run("entities", 8, "cow")["entities"][0]["health"])
assert r["cow"][0] == 10 and r["cow"][1] < 10, r
# Fire: the planks outside burn, those inside never.
for _ in range(90):
outside = [block(x0 - 2, y, z0 + 8), block(x0 - 1, y, z0 + 7), block(x0 - 1, y, z0 + 9),
block(x0 - 1, y + 1, z0 + 8)]
if sum("oak_planks" not in b for b in outside) >= 2:
break
time.sleep(1)
r["fire"] = outside + [block(x0, y, z0 + 8)]
assert sum("oak_planks" not in b for b in outside) >= 1 and "oak_planks" in r["fire"][-1], r
# TNT two blocks south of the chunk: a crater outside, not one block of the chunk broken.
inside = lambda: json.dumps(steve.run("blocks", x0 + 4, y - 4, z0 + 12, x0 + 12, y + 2, z0 + 15)["counts"])
r["before_tnt"] = inside()
server.console(f"summon minecraft:tnt {x0 + 8.5} {y} {z0 + 17.5} {{fuse:1}}")
for _ in range(20):
time.sleep(1)
if "grass" not in block(x0 + 8, y - 1, z0 + 17):
break
time.sleep(1)
r["after_tnt"] = [inside(), block(x0 + 8, y - 1, z0 + 17)]
assert r["after_tnt"][0] == r["before_tnt"] and "grass" not in r["after_tnt"][1], r
return r
def board(alex, steve, out):
"""The sidebar of the richest players and the Tab list; /sidebar off hides it, on shows it again."""
r = {}
side = lambda c: c.run("status").get("sidebar")
for _ in range(20):
r["sidebar"] = side(steve)
if r["sidebar"]:
break
time.sleep(0.5)
assert r["sidebar"]["objective"] == "horizon:native_sidebar" and r["sidebar"]["title"] == "Richest players", r
# Alex and Steve, a gap, then Steve's own line.
assert len(r["sidebar"]["scores"]) == 4, r
# Holders starting with '#' would be hidden from the sidebar (only the title would show).
assert not any(o.startswith("#") for o in r["sidebar"]["scores"]), r
steve.run("key", "tab", "down")
time.sleep(1)
steve.run("screenshot", str(Path(out).resolve() / "sidebar-and-tab.png"))
steve.run("key", "tab", "up")
r["off"] = steve.say("/sidebar off")
assert any("The sidebar is off" in l for l in r["off"]), r
time.sleep(1)
assert not side(steve), side(steve)
assert side(alex), "only Steve's is gone"
r["on"] = steve.say("/sidebar")
assert any("The sidebar is on" in l for l in r["on"]), r
for _ in range(10):
if side(steve):
break
time.sleep(0.5)
assert side(steve), "back within a second or two"
steve.say("/sidebar off")
return r
def polish(server, alex, steve, out):
"""/back after a death, /playtime, /claim show."""
r = {}
_, died_at, _ = where(steve)
server.console("kill Steve")
time.sleep(2.5)
steve.run("respawn")
time.sleep(2)
r["died_at"], r["respawned_at"] = died_at, where(steve)[1]
r["back"] = steve.say("/back")
assert any("Teleporting in" in l for l in r["back"]), r
time.sleep(4)
r["back_reached"] = where(steve)[1]
assert near(r["back_reached"], died_at), r
assert any("where you died" in l for l in steve.chat()[-4:]), steve.chat()[-4:]
r["playtime"] = steve.say("/playtime")
assert any("You have played here for" in l for l in r["playtime"]), r
r["playtime_alex"] = steve.say("/playtime Alex")
assert any("Alex has played here for" in l for l in r["playtime_alex"]), r
r["show"] = alex.say("/claim show")
assert any("This chunk's border, for 10 s" in l for l in r["show"]), r
time.sleep(2)
alex.run("screenshot", str(Path(out).resolve() / "claim-show.png"))
return r
def presence(server, alex, steve):
"""/afk keeps Steve out of the sleep count (Alex alone in bed passes the night); moving brings him back; too many
commands at once wait."""
r = {}
r["afk"] = steve.say("/afk")
assert any("You are AFK" in l for l in r["afk"]), r
_, (ax, ay, az), _ = where(alex)
x, y, z = math.floor(ax) + 2, math.floor(ay), math.floor(az)
for line in (f"setblock {x} {y} {z} minecraft:red_bed[part=foot,facing=south]",
f"setblock {x} {y} {z + 1} minecraft:red_bed[part=head,facing=south]", "time set 18000"):
server.console(line)
time.sleep(2)
alex.run("interact", x, y, z)
# Five seconds asleep, then the morning; Steve, AFK and awake, doesn't hold it back.
for _ in range(40):
r["time_of_day"] = alex.run("status")["day_time"] % 24000
if r["time_of_day"] < 12000:
break
time.sleep(0.5)
assert r["time_of_day"] < 12000, r
steve.run("move", "forward", 1)
time.sleep(2.5)
assert any("You are back." in l for l in steve.chat()[-4:]), steve.chat()[-4:]
for _ in range(9):
steve.run("chat", "/bal")
time.sleep(2)
r["spam"] = steve.chat()[-12:]
assert any("Slow down: at most 8 commands every 5 s." in l for l in r["spam"]), r
return r
def shop(server, alex):
"""Alex alone, op, in survival: /money and /eco give *, then the /shop menu: a category, buying one and a stack,
selling a stack of hers, "Sell all" up to the daily limit, closing. Returns the coins it added, and the lines."""
results = {}
# Only what the step gives her: "Sell all" would sell the rest too.
server.console("clear Alex")
time.sleep(1)
results["money"] = alex.say("/money give Alex 2000")
assert any("Alex now has" in l for l in results["money"]), results
results["everyone"] = alex.say("/eco give * 10")
assert any("Gave 10 coins to 1 player(s) on." in l for l in results["everyone"]), results
assert alex.count("minecraft:iron_ingot") == 0
alex.say("/shop")
menu = alex.menu()
assert menu[10]["item"] == "minecraft:wheat" and menu[48]["item"] == "minecraft:gold_ingot", menu
alex.click(300 + 11) # Mining
menu = alex.menu()
assert [menu[i]["item"] for i in range(3)] == ["minecraft:coal", "minecraft:copper_ingot",
"minecraft:iron_ingot"], menu
alex.click(300 + 2) # one iron ingot
assert alex.count("minecraft:iron_ingot") == 1, alex.run("inventory")
results["bought"] = alex.chat()[-2:]
assert any("Bought 1 iron ingot for 30 coins." in l for l in results["bought"]), results
alex.click(300 + 45) # back
alex.click(300 + 14) # Building
alex.run("click-slot", 300 + 0, "shift") # a stack of cobblestone
time.sleep(1)
assert alex.count("minecraft:cobblestone") == 64, alex.run("inventory")
# Her own stack of iron, clicked: sold.
alex.click(alex.slot_of("minecraft:iron_ingot"))
results["sold"] = alex.chat()[-2:]
assert any("Sold 1 iron ingot for 8 coins." in l for l in results["sold"]), results
assert alex.count("minecraft:iron_ingot") == 0
# Sell all, up to the 2,000 coins a day: 19 of 30 diamonds (8 coins earned already).
server.console("give Alex minecraft:diamond 30")
time.sleep(2)
alex.click(300 + 49)
results["sell_all"] = alex.chat()[-3:]
assert any("Sold 19 diamond for 1,900 coins." in l for l in results["sell_all"]), results
assert any("11 diamond kept: today's selling limit." in l for l in results["sell_all"]), results
assert alex.count("minecraft:diamond") == 11, alex.run("inventory")
limits = alex.menu()[50]
results["limits"] = limits
assert "92 coins left of your 2,000 coins a day" in json.dumps(limits), limits
alex.click(300 + 53) # close
assert not alex.menu()
results["worth"] = alex.say("/worth diamond")
assert any("You may sell 621 more diamond today." in l for l in results["worth"]), results
return 2000 + 10 - 30 - 64 + 8 + 1900, results
def trades(server, alex, steve, port, ctl, resources, root):
"""Every way out of a trade, then one that goes through. Alex: 10 diamonds; Steve: 5 emeralds, 5 torches."""
results = {}
@@ -311,14 +645,25 @@ def run(ctl, resources, out):
data = server.folder / "world" / "horizon-smp"
data.mkdir(parents=True)
(data / "config.json").write_text(json.dumps({"gambling": {"enabled": True, "cooldown_seconds": 20,
"lottery_seconds": 30}}))
"lottery_seconds": 30},
"places": {"warmup_seconds": 2, "cooldown_seconds": 0},
# Paid once, after the restart, not to change the sums.
"presence": {"daily_reward": 0}}))
server.start()
# No monster may end a step early: a slime once killed Steve on the flat world.
for line in ("difficulty peaceful", "gamerule spawn_monsters false", "kill @e[type=minecraft:slime]"):
server.console(line)
alex.join(port)
steve.join(port)
time.sleep(2)
config = json.loads((data / "config.json").read_text())
results["config_keys"] = sorted(config)
assert results["config_keys"] == ["currency", "gambling", "market", "starting_balance"], config
assert results["config_keys"] == ["board", "claims", "currency", "gambling", "market", "places", "presence",
"shop", "starting_balance"], config
results["welcome"] = steve.chat()
assert any("Welcome to the server, Steve!" in l for l in results["welcome"]), results["welcome"]
assert any("1. Be respectful to everyone." in l for l in results["welcome"]), results["welcome"]
assert any("Steve joined for the first time" in l for l in alex.chat()), alex.chat()
assert config["gambling"] == {"enabled": True, "min_bet": 10, "max_bet": 1000, "cooldown_seconds": 20,
"offer_seconds": 60, "fee_percent": 0, "ticket_price": 10,
"max_tickets": 100, "lottery_seconds": 30}, config
@@ -367,6 +712,13 @@ def run(ctl, resources, out):
server.console("give Steve minecraft:emerald 5")
time.sleep(2)
results["trades"], steve = trades(server, alex, steve, port, ctl, resources, root)
# Before the coinflips: their last offer must still wait when the server stops.
results["places"] = places(server, alex, steve)
results["claims"] = claims(server, alex, steve)
results["world_guard"] = world_guard(server, alex, steve)
results["board"] = board(alex, steve, out)
results["polish"] = polish(server, alex, steve, out)
results["presence"] = presence(server, alex, steve)
results["coinflip"], alex_wins = coinflips(alex, steve)
results["lottery"], alex_lotto = lottery(alex, steve, alex_wins)
alex_after = 415 + (100 if alex_wins else -100) + (20 if alex_lotto else -30)
@@ -374,7 +726,13 @@ def run(ctl, resources, out):
lines = server.stop()
steve.quit()
alex.quit()
assert not any("panicked" in l for l in lines), lines[-40:]
assert not any("panicked" in l or "teleport failed" in l for l in lines), lines[-40:]
saved = json.loads((data / "places.json").read_text())
assert sorted(saved["warps"]) == ["shop"] and saved["spawn"] and len(saved["homes"]) == 1, saved
saved = json.loads((data / "board.json").read_text())
assert len(saved["hidden"]) == 1, saved
saved = json.loads((data / "claims.json").read_text())
assert len(saved["chunks"]) == 1 and len(saved["trust"]) == 1, saved
economy = json.loads((data / "economy.json").read_text())
balances = {a["name"]: a["balance"] for a in economy["accounts"].values()}
results["economy.json"] = economy
@@ -391,19 +749,36 @@ def run(ctl, resources, out):
assert (swap["a_items"], swap["b_items"], swap["b_money"]) == (
[{"item": "minecraft:diamond", "count": 10, "plain": True}],
[{"item": "minecraft:emerald", "count": 5, "plain": True}], 35), swap
# The daily reward, on: Alex gets it when she joins again.
config = json.loads((data / "config.json").read_text())
config["presence"]["daily_reward"] = 7
(data / "config.json").write_text(json.dumps(config))
server.start()
alex = Client(ctl, resources, "Alex2", root)
alex.name = "Alex"
alex.join(port)
results["after_restart"] = alex.say("/balance")
assert any(f"Balance: {alex_after} coins" in l for l in results["after_restart"]), results
assert any(f"Balance: {alex_after + 7} coins" in l for l in results["after_restart"]), results
assert any("Daily reward: 7 coins." in l for l in alex.chat()), alex.chat()
assert not any("joined for the first time" in l for l in alex.chat()), alex.chat()
added, results["shop"] = shop(server, alex)
alex.quit()
server.stop()
economy = json.loads((data / "economy.json").read_text())
assert "held" not in economy and sum(a["balance"] for a in economy["accounts"].values()) == 815, economy
total = sum(a["balance"] for a in economy["accounts"].values())
assert "held" not in economy and total == 815 + 7 + added, economy
kinds = [json.loads(l)["kind"] for l in (data / "audit.log").read_text().splitlines()]
assert kinds[-1] in ("held_returned", "coinflip_cancel"), kinds[-3:]
results["stake_back"] = kinds[-1]
shop_kinds = ["eco_give", "eco_give", "buy 1 minecraft:iron_ingot", "buy 64 minecraft:cobblestone",
"sell 1 minecraft:iron_ingot", "sell 19 minecraft:diamond"]
assert kinds[-len(shop_kinds):] == shop_kinds, kinds[-8:]
kinds = kinds[:-len(shop_kinds)]
assert kinds[-2] in ("held_returned", "coinflip_cancel") and kinds[-1] == "daily_reward", kinds[-3:]
results["stake_back"] = kinds[-2]
sold = json.loads((data / "market.json").read_text())["sold"]
# Steve's sales of the first run's day, and Alex's.
assert sorted((s["coins"], s["items"]) for s in sold.values()) == [
(320, {"minecraft:diamond": 3, "minecraft:wheat": 20}),
(1908, {"minecraft:diamond": 19, "minecraft:iron_ingot": 1})], sold
print(json.dumps({"proof": "smp", "ok": True, **results}, indent=2))
finally:
(out / "server.log").write_text("\n".join(server.lines))
+12
View File
@@ -1,6 +1,10 @@
//! The SMP's rules, as the bundle adds them to the game: each file is a
//! module of `game` there and of this crate here, under the same name, so
//! `super::smp_store` reaches the same code in both.
#[path = "../mixins/smp/smp_board.rs"]
pub mod smp_board;
#[path = "../mixins/smp/smp_claims.rs"]
pub mod smp_claims;
#[path = "../mixins/smp/smp_economy.rs"]
pub mod smp_economy;
#[path = "../mixins/smp/smp_games.rs"]
@@ -9,6 +13,14 @@ pub mod smp_games;
pub mod smp_lottery;
#[path = "../mixins/smp/smp_market.rs"]
pub mod smp_market;
#[path = "../mixins/smp/smp_places.rs"]
pub mod smp_places;
#[path = "../mixins/smp/smp_presence.rs"]
pub mod smp_presence;
#[path = "../mixins/smp/smp_recipes.rs"]
pub mod smp_recipes;
#[path = "../mixins/smp/smp_shop.rs"]
pub mod smp_shop;
#[path = "../mixins/smp/smp_store.rs"]
pub mod smp_store;
#[path = "../mixins/smp/smp_trade.rs"]