mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 11:05:58 -10:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1f2b343af | ||
|
|
c6c309d01b | ||
|
|
a258ef03d9 | ||
|
|
acf5d8896a | ||
|
|
4dcb8fed29 | ||
|
|
f56752d74f | ||
|
|
455a8b800f | ||
|
|
238d9cd1f4 | ||
|
|
ddab3fa269 | ||
|
|
a301fcc469 | ||
|
|
c1ce388d41 | ||
|
|
8bbf748e1f | ||
|
|
50c23cb7d9 | ||
|
|
8d5c1ed7a3 | ||
|
|
b6c1af58cd | ||
|
|
3cf1b6aa8f | ||
|
|
a11db16db3 | ||
|
|
dca26bf044 | ||
|
|
95b8f15c6b | ||
|
|
be6b796a20 | ||
|
|
2cd564f16b | ||
|
|
b571298b58 | ||
|
|
fefd3e8c6b | ||
|
|
4364dbfba3 | ||
|
|
aaf6ccefcc | ||
|
|
b0e3d98b52 | ||
|
|
803907aaa5 | ||
|
|
ec056f9f02 | ||
|
|
63f2fa9f01 | ||
|
|
1ec378ac96 | ||
|
|
0eb6ca75c3 | ||
|
|
26079e71ed | ||
|
|
b9bd159dcf |
No files matched your search
@@ -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. "
|
||||
@@ -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
@@ -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."
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-smp"
|
||||
version = "0.5.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// A player hits an entity: a claim's frames, armor stands and animals are
|
||||
// kept from players its owner doesn't trust.
|
||||
fn smp_attack_entity(
|
||||
game: &mut crate::game::Game<'_>,
|
||||
kind: &str,
|
||||
pos: [f64; 3],
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
crate::game::smp_glue::attack(game, kind, pos, canceled);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// A fluid flows or fire spreads into a block: kept out of the land others
|
||||
// claimed.
|
||||
fn smp_block_spread(
|
||||
dimension: crate::game::LevelDimension,
|
||||
from: horizon_core::position::BlockPos,
|
||||
to: horizon_core::position::BlockPos,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let dimension = crate::game::smp_glue::dimension_id(dimension);
|
||||
let (from, to) = ([from.x, from.y, from.z], [to.x, to.y, to.z]);
|
||||
if !crate::game::smp_glue::world_allows(dimension, from, &[to]) {
|
||||
*canceled = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// A player breaks a block: kept out of the land others claimed.
|
||||
fn smp_destroy_block(game: &mut crate::game::Game<'_>, pos: [i32; 3], canceled: &mut bool) {
|
||||
crate::game::smp_glue::guard(game, pos, canceled);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// An explosion's blocks: claimed land is spared.
|
||||
fn smp_explosion_detonate(
|
||||
game: &crate::game::Game<'_>,
|
||||
dimension: &str,
|
||||
blocks: &mut Vec<[i32; 3]>,
|
||||
) {
|
||||
crate::game::smp_glue::explosion(game, dimension, blocks);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// A player fills or empties a bucket: kept out of the land others claimed.
|
||||
fn smp_fill_bucket(game: &mut crate::game::Game<'_>, pos: [i32; 3], canceled: &mut bool) {
|
||||
crate::game::smp_glue::guard(game, pos, canceled);
|
||||
}
|
||||
+727
-24
@@ -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());
|
||||
|
||||
@@ -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" },
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
// A piston pushes, pulls or destroys blocks: kept out of the land others
|
||||
// claimed.
|
||||
fn smp_piston_move(
|
||||
dimension: crate::game::LevelDimension,
|
||||
piston: horizon_core::position::BlockPos,
|
||||
direction: [i32; 3],
|
||||
push: &[horizon_core::position::BlockPos],
|
||||
destroy: &[horizon_core::position::BlockPos],
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let dimension = crate::game::smp_glue::dimension_id(dimension);
|
||||
let step = |p: &horizon_core::position::BlockPos| {
|
||||
[p.x + direction[0], p.y + direction[1], p.z + direction[2]]
|
||||
};
|
||||
let touched: Vec<[i32; 3]> = push
|
||||
.iter()
|
||||
.flat_map(|p| [[p.x, p.y, p.z], step(p)])
|
||||
.chain(destroy.iter().map(|p| [p.x, p.y, p.z]))
|
||||
.collect();
|
||||
let piston = [piston.x, piston.y, piston.z];
|
||||
if !crate::game::smp_glue::world_allows(dimension, piston, &touched) {
|
||||
*canceled = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// A player right clicks a block: kept out of the land others claimed.
|
||||
fn smp_right_click_block(game: &mut crate::game::Game<'_>, pos: [i32; 3], canceled: &mut bool) {
|
||||
crate::game::smp_glue::guard(game, pos, canceled);
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
// Land claims: a player claims the chunk they stand in (`/claim`), all its
|
||||
// height, and only they and the players they trust (`/trust`) break blocks
|
||||
// or right click blocks there (doors, chests, levers, placing, buckets on a
|
||||
// block), or hit its frames, armor stands and animals. Nothing flows, burns
|
||||
// or is pushed into it from land of someone else, and explosions spare it.
|
||||
// The chunks a player may hold grow with the time they played here.
|
||||
// Operators of level 3 pass everywhere and may `/unclaim` any chunk. Kept in
|
||||
// `claims.json`, written atomically. No game types. Added to the game as
|
||||
// `game::smp_claims`; also a module of this crate, for its tests.
|
||||
|
||||
use super::smp_economy::{Out, Player};
|
||||
use super::smp_places::Place;
|
||||
use super::smp_store;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// `config.json`'s `claims` section.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub enabled: bool,
|
||||
/// Chunks a player may claim, by hours played here: `[hours, chunks]`,
|
||||
/// the last line reached counts.
|
||||
pub chunks: Vec<[i64; 2]>,
|
||||
/// The entities only the owner and trusted players may hit in a claim.
|
||||
pub protected_entities: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
chunks: vec![[0, 4], [5, 16], [20, 36], [100, 64]],
|
||||
protected_entities: PROTECTED
|
||||
.iter()
|
||||
.map(|&e| format!("minecraft:{e}"))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames, stands, vehicles and the animals a player keeps: not monsters.
|
||||
/// Points along a chunk's four edges, every `step` blocks, at height `y`:
|
||||
/// where `/claim show` draws its border.
|
||||
pub fn border(cx: i32, cz: i32, y: f64, step: usize) -> Vec<[f64; 3]> {
|
||||
let (x0, z0) = (f64::from(cx * 16), f64::from(cz * 16));
|
||||
let mut points = Vec::new();
|
||||
for i in (0..=16).step_by(step.max(1)) {
|
||||
let t = f64::from(i);
|
||||
points.push([x0 + t, y, z0]);
|
||||
points.push([x0 + t, y, z0 + 16.0]);
|
||||
if i != 0 && i != 16 {
|
||||
points.push([x0, y, z0 + t]);
|
||||
points.push([x0 + 16.0, y, z0 + t]);
|
||||
}
|
||||
}
|
||||
points
|
||||
}
|
||||
|
||||
const PROTECTED: &[&str] = &[
|
||||
"item_frame",
|
||||
"glow_item_frame",
|
||||
"armor_stand",
|
||||
"painting",
|
||||
"leash_knot",
|
||||
"minecart",
|
||||
"chest_minecart",
|
||||
"furnace_minecart",
|
||||
"hopper_minecart",
|
||||
"tnt_minecart",
|
||||
"oak_boat",
|
||||
"spruce_boat",
|
||||
"birch_boat",
|
||||
"jungle_boat",
|
||||
"acacia_boat",
|
||||
"dark_oak_boat",
|
||||
"mangrove_boat",
|
||||
"cherry_boat",
|
||||
"pale_oak_boat",
|
||||
"bamboo_raft",
|
||||
"oak_chest_boat",
|
||||
"spruce_chest_boat",
|
||||
"birch_chest_boat",
|
||||
"jungle_chest_boat",
|
||||
"acacia_chest_boat",
|
||||
"dark_oak_chest_boat",
|
||||
"mangrove_chest_boat",
|
||||
"cherry_chest_boat",
|
||||
"pale_oak_chest_boat",
|
||||
"bamboo_chest_raft",
|
||||
"allay",
|
||||
"armadillo",
|
||||
"axolotl",
|
||||
"bee",
|
||||
"camel",
|
||||
"cat",
|
||||
"chicken",
|
||||
"cow",
|
||||
"donkey",
|
||||
"fox",
|
||||
"frog",
|
||||
"goat",
|
||||
"happy_ghast",
|
||||
"horse",
|
||||
"llama",
|
||||
"mooshroom",
|
||||
"mule",
|
||||
"ocelot",
|
||||
"panda",
|
||||
"parrot",
|
||||
"pig",
|
||||
"rabbit",
|
||||
"sheep",
|
||||
"sniffer",
|
||||
"snow_golem",
|
||||
"iron_golem",
|
||||
"copper_golem",
|
||||
"strider",
|
||||
"turtle",
|
||||
"villager",
|
||||
"wolf",
|
||||
"trader_llama",
|
||||
"wandering_trader",
|
||||
];
|
||||
|
||||
/// `claims.json`.
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct Data {
|
||||
/// Claimed chunks, `<dimension> <chunk x> <chunk z>`, to their owner's UUID.
|
||||
chunks: BTreeMap<String, String>,
|
||||
/// The players each owner trusts, by UUID.
|
||||
trust: BTreeMap<String, BTreeSet<String>>,
|
||||
}
|
||||
|
||||
pub struct Claims {
|
||||
pub config: Config,
|
||||
path: PathBuf,
|
||||
data: Data,
|
||||
changed: bool,
|
||||
/// `data.chunks` by dimension, then (chunk x, chunk z): asked on every
|
||||
/// fluid flow, so a lookup allocates nothing.
|
||||
owners: HashMap<String, HashMap<(i32, i32), u128>>,
|
||||
/// When each player was last told a chunk isn't theirs.
|
||||
told: HashMap<u128, i64>,
|
||||
}
|
||||
|
||||
fn key(dimension: &str, cx: i32, cz: i32) -> String {
|
||||
format!("{dimension} {cx} {cz}")
|
||||
}
|
||||
|
||||
fn chunk_of(at: &Place) -> (i32, i32) {
|
||||
((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4)
|
||||
}
|
||||
|
||||
impl Claims {
|
||||
/// `claims.json` in `dir`; an error when it exists but can't be read
|
||||
/// (never replaced by an empty one).
|
||||
pub fn open(dir: &Path, config: Config) -> Result<Self, String> {
|
||||
let path = dir.join("claims.json");
|
||||
let data: Data = smp_store::read_json(&path)?;
|
||||
let mut owners: HashMap<String, HashMap<(i32, i32), u128>> = HashMap::new();
|
||||
for (k, owner) in &data.chunks {
|
||||
let mut w = k.split(' ');
|
||||
let (Some(d), Some(x), Some(z), Some(u)) = (
|
||||
w.next(),
|
||||
w.next().and_then(|x| x.parse().ok()),
|
||||
w.next().and_then(|z| z.parse().ok()),
|
||||
smp_store::parse_uuid(owner),
|
||||
) else {
|
||||
return Err(format!("{}: a bad claim `{k}`", path.display()));
|
||||
};
|
||||
owners.entry(d.to_owned()).or_default().insert((x, z), u);
|
||||
}
|
||||
Ok(Self {
|
||||
config,
|
||||
path,
|
||||
data,
|
||||
changed: false,
|
||||
owners,
|
||||
told: HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save(&mut self) -> Result<(), String> {
|
||||
if self.changed {
|
||||
smp_store::write_json(&self.path, &self.data)?;
|
||||
self.changed = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Who holds the chunk of this block, if anyone.
|
||||
pub fn owner(&self, dimension: &str, x: i32, z: i32) -> Option<u128> {
|
||||
self.chunk_owner(dimension, x >> 4, z >> 4)
|
||||
}
|
||||
|
||||
fn chunk_owner(&self, dimension: &str, cx: i32, cz: i32) -> Option<u128> {
|
||||
self.owners.get(dimension)?.get(&(cx, cz)).copied()
|
||||
}
|
||||
|
||||
/// Whether the world may change the block at `to` from `from` (a fluid,
|
||||
/// fire, a piston): yes, unless `to` is claimed by someone other than
|
||||
/// `from`'s owner or a player they trust.
|
||||
pub fn spread_allowed(&self, dimension: &str, from: [i32; 3], to: [i32; 3]) -> bool {
|
||||
if !self.config.enabled {
|
||||
return true;
|
||||
}
|
||||
let Some(owner) = self.owner(dimension, to[0], to[2]) else {
|
||||
return true;
|
||||
};
|
||||
match self.owner(dimension, from[0], from[2]) {
|
||||
Some(source) => source == owner || self.trusts(owner, source),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes out of an explosion's blocks those in a claim: explosions
|
||||
/// spare claimed land, whoever lit them.
|
||||
pub fn spare(&self, dimension: &str, blocks: &mut Vec<[i32; 3]>) {
|
||||
if self.config.enabled && self.owners.get(dimension).is_some_and(|c| !c.is_empty()) {
|
||||
blocks.retain(|b| self.owner(dimension, b[0], b[2]).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// Whose land a block is to `who`: None when free, true when theirs or
|
||||
/// trusted to them, false when someone else's.
|
||||
pub fn relation(&self, dimension: &str, x: i32, z: i32, who: u128) -> Option<bool> {
|
||||
let owner = self.owner(dimension, x, z)?;
|
||||
Some(owner == who || self.trusts(owner, who))
|
||||
}
|
||||
|
||||
/// Whether entities of type `kind` (`minecraft:cow`) are kept from
|
||||
/// players a claim's owner doesn't trust.
|
||||
pub fn protects(&self, kind: &str) -> bool {
|
||||
self.config.protected_entities.iter().any(|e| e == kind)
|
||||
}
|
||||
|
||||
fn trusts(&self, owner: u128, who: u128) -> bool {
|
||||
self.data
|
||||
.trust
|
||||
.get(&smp_store::uuid_string(owner))
|
||||
.is_some_and(|t| t.contains(&smp_store::uuid_string(who)))
|
||||
}
|
||||
|
||||
/// Whether `who` may break or use the block at `pos`. When not, the line
|
||||
/// to tell them: at most one each 2 s, so holding a click doesn't flood
|
||||
/// their chat.
|
||||
pub fn check(
|
||||
&mut self,
|
||||
who: &Player,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
name_of: &dyn Fn(u128) -> String,
|
||||
now: i64,
|
||||
) -> Result<(), Option<String>> {
|
||||
if !self.config.enabled || who.level >= 3 {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(owner) = self.owner(dimension, pos[0], pos[2]) else {
|
||||
return Ok(());
|
||||
};
|
||||
if owner == who.uuid || self.trusts(owner, who.uuid) {
|
||||
return Ok(());
|
||||
}
|
||||
let last = self.told.insert(who.uuid, now);
|
||||
if last.is_some_and(|t| now - t < 2_000) {
|
||||
return Err(None);
|
||||
}
|
||||
Err(Some(format!("This land is claimed by {}.", name_of(owner))))
|
||||
}
|
||||
|
||||
/// How many chunks `uuid` holds.
|
||||
fn held(&self, uuid: u128) -> usize {
|
||||
self.owners
|
||||
.values()
|
||||
.flat_map(|c| c.values())
|
||||
.filter(|&&u| u == uuid)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// How many chunks a player may hold after `played` seconds here.
|
||||
pub fn limit(&self, played: i64) -> usize {
|
||||
self.config
|
||||
.chunks
|
||||
.iter()
|
||||
.filter(|[h, _]| played / 3600 >= *h)
|
||||
.map(|[_, n]| (*n).max(0) as usize)
|
||||
.next_back()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The claims' commands; None for another command. `at` is where the
|
||||
/// player stands; `find` a player's UUID and name from a name (on or
|
||||
/// known); `name_of` a name from a UUID.
|
||||
pub fn command(
|
||||
&mut self,
|
||||
me: &Player,
|
||||
at: &Place,
|
||||
played: i64,
|
||||
find: &dyn Fn(&str) -> Option<(u128, String)>,
|
||||
name_of: &dyn Fn(u128) -> String,
|
||||
line: &str,
|
||||
) -> Option<Vec<Out>> {
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
let command = *words.first()?;
|
||||
if !COMMANDS.contains(&command) {
|
||||
return None;
|
||||
}
|
||||
if !self.config.enabled {
|
||||
return Some(vec![Out::Error("Claims are off on this server.".into())]);
|
||||
}
|
||||
let (cx, cz) = chunk_of(at);
|
||||
let here = at.dimension.as_str();
|
||||
let blocks = format!(
|
||||
"x {} to {}, z {} to {}",
|
||||
cx * 16,
|
||||
cx * 16 + 15,
|
||||
cz * 16,
|
||||
cz * 16 + 15
|
||||
);
|
||||
let me_key = smp_store::uuid_string(me.uuid);
|
||||
Some(match (command, &words[1..]) {
|
||||
("claim", []) => match self.chunk_owner(here, cx, cz) {
|
||||
Some(u) if u == me.uuid => vec![Out::Error("This chunk is already yours.".into())],
|
||||
Some(u) => vec![Out::Error(format!("This chunk is {}'s.", name_of(u)))],
|
||||
None => {
|
||||
let (held, limit) = (self.held(me.uuid), self.limit(played));
|
||||
if held >= limit {
|
||||
vec![Out::Error(format!(
|
||||
"You hold {held} of {limit} chunks: play longer for more, or /unclaim one."
|
||||
))]
|
||||
} else {
|
||||
self.data.chunks.insert(key(here, cx, cz), me_key);
|
||||
let chunks = self.owners.entry(here.to_owned()).or_default();
|
||||
chunks.insert((cx, cz), me.uuid);
|
||||
self.changed = true;
|
||||
vec![Out::Reply(format!(
|
||||
"Claimed this chunk ({blocks}), {} of {limit}. /trust <player> lets a friend build.",
|
||||
held + 1
|
||||
))]
|
||||
}
|
||||
}
|
||||
},
|
||||
("claim", ["info"]) => vec![Out::Reply(match self.chunk_owner(here, cx, cz) {
|
||||
Some(u) => format!("This chunk ({blocks}) is {}'s.", name_of(u)),
|
||||
None => format!("This chunk ({blocks}) is free: /claim it."),
|
||||
})],
|
||||
("unclaim", []) => match self.chunk_owner(here, cx, cz) {
|
||||
Some(u) if u == me.uuid || me.level >= 3 => {
|
||||
if let Some(chunks) = self.owners.get_mut(here) {
|
||||
chunks.remove(&(cx, cz));
|
||||
}
|
||||
self.data.chunks.remove(&key(here, cx, cz));
|
||||
self.changed = true;
|
||||
vec![Out::Reply(format!("Unclaimed this chunk ({blocks})."))]
|
||||
}
|
||||
Some(u) => vec![Out::Error(format!("This chunk is {}'s.", name_of(u)))],
|
||||
None => vec![Out::Error("This chunk isn't claimed.".into())],
|
||||
},
|
||||
("claims", []) => {
|
||||
let mine: Vec<String> = self
|
||||
.data
|
||||
.chunks
|
||||
.iter()
|
||||
.filter(|(_, o)| **o == me_key)
|
||||
.map(|(k, _)| {
|
||||
let w: Vec<&str> = k.split(' ').collect();
|
||||
let n = |s: &str| s.parse::<i32>().unwrap_or(0) * 16;
|
||||
format!(
|
||||
"{} at x {} z {}",
|
||||
w[0].trim_start_matches("minecraft:"),
|
||||
n(w[1]),
|
||||
n(w[2])
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let trusted: Vec<String> = self
|
||||
.data
|
||||
.trust
|
||||
.get(&me_key)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|u| smp_store::parse_uuid(u))
|
||||
.map(name_of)
|
||||
.collect();
|
||||
vec![
|
||||
Out::Reply(format!(
|
||||
"Your chunks ({} of {}): {}",
|
||||
mine.len(),
|
||||
self.limit(played),
|
||||
if mine.is_empty() {
|
||||
"none, /claim where you stand".into()
|
||||
} else {
|
||||
mine.join("; ")
|
||||
}
|
||||
)),
|
||||
Out::Reply(format!(
|
||||
"Trusted: {}",
|
||||
if trusted.is_empty() {
|
||||
"nobody".into()
|
||||
} else {
|
||||
trusted.join(", ")
|
||||
}
|
||||
)),
|
||||
]
|
||||
}
|
||||
("trust", [name]) | ("untrust", [name]) => match find(name) {
|
||||
None => vec![Out::Error(format!(
|
||||
"No player named {name} has played here."
|
||||
))],
|
||||
Some((u, _)) if u == me.uuid => vec![Out::Error("That's you.".into())],
|
||||
Some((u, name)) => {
|
||||
let set = self.data.trust.entry(me_key.clone()).or_default();
|
||||
let changed = if command == "trust" {
|
||||
set.insert(smp_store::uuid_string(u))
|
||||
} else {
|
||||
set.remove(&smp_store::uuid_string(u))
|
||||
};
|
||||
if set.is_empty() {
|
||||
self.data.trust.remove(&me_key);
|
||||
}
|
||||
self.changed |= changed;
|
||||
vec![Out::Reply(match (command, changed) {
|
||||
("trust", true) => {
|
||||
format!("{name} may now build and open things on your land.")
|
||||
}
|
||||
("trust", false) => format!("You already trust {name}."),
|
||||
(_, true) => format!("{name} no longer may build on your land."),
|
||||
_ => format!("You didn't trust {name}."),
|
||||
})]
|
||||
}
|
||||
},
|
||||
_ => vec![Out::Error(
|
||||
"Usage: /claim, /claim info, /claim show, /unclaim, /claims, /trust <player>, /untrust <player>"
|
||||
.into(),
|
||||
)],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The claims' commands.
|
||||
pub const COMMANDS: [&str; 5] = ["claim", "unclaim", "claims", "trust", "untrust"];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
const A: u128 = 1;
|
||||
const B: u128 = 2;
|
||||
|
||||
fn dir() -> PathBuf {
|
||||
static N: AtomicU32 = AtomicU32::new(0);
|
||||
let n = N.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!("smp-claims-{}-{n}", std::process::id()))
|
||||
}
|
||||
fn at(x: f64, z: f64) -> Place {
|
||||
Place {
|
||||
dimension: "minecraft:overworld".into(),
|
||||
x,
|
||||
y: 64.0,
|
||||
z,
|
||||
}
|
||||
}
|
||||
fn player(uuid: u128, level: u8) -> Player {
|
||||
Player {
|
||||
uuid,
|
||||
name: if uuid == A { "Alex" } else { "Steve" }.into(),
|
||||
level,
|
||||
}
|
||||
}
|
||||
fn find(n: &str) -> Option<(u128, String)> {
|
||||
match n.to_ascii_lowercase().as_str() {
|
||||
"alex" => Some((A, "Alex".into())),
|
||||
"steve" => Some((B, "Steve".into())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn name_of(u: u128) -> String {
|
||||
if u == A { "Alex" } else { "Steve" }.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_border_runs_along_the_chunks_edges() {
|
||||
let points = border(1, -1, 70.0, 2);
|
||||
// 9 points on each edge, the corners once.
|
||||
assert_eq!(points.len(), 32);
|
||||
assert!(points.contains(&[16.0, 70.0, -16.0]) && points.contains(&[32.0, 70.0, 0.0]));
|
||||
assert!(
|
||||
points
|
||||
.iter()
|
||||
.all(|p| p[0] == 16.0 || p[0] == 32.0 || p[2] == -16.0 || p[2] == 0.0)
|
||||
);
|
||||
let d = dir();
|
||||
let mut c = Claims::open(&d, Config::default()).unwrap();
|
||||
c.command(&player(A, 0), &at(8.0, 8.0), 0, &find, &name_of, "claim")
|
||||
.unwrap();
|
||||
let ow = "minecraft:overworld";
|
||||
assert_eq!(c.relation(ow, 3, 3, A), Some(true));
|
||||
assert_eq!(c.relation(ow, 3, 3, B), Some(false));
|
||||
assert_eq!(c.relation(ow, 20, 3, B), None);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_world_stays_out_of_a_claim() {
|
||||
let d = dir();
|
||||
let mut c = Claims::open(&d, Config::default()).unwrap();
|
||||
c.command(&player(A, 0), &at(8.0, 8.0), 0, &find, &name_of, "claim")
|
||||
.unwrap();
|
||||
let ow = "minecraft:overworld";
|
||||
// Into Alex's chunk from outside: refused; inside it and out of it: allowed.
|
||||
assert!(!c.spread_allowed(ow, [16, 64, 8], [15, 64, 8]));
|
||||
assert!(c.spread_allowed(ow, [14, 64, 8], [15, 64, 8]));
|
||||
assert!(c.spread_allowed(ow, [15, 64, 8], [16, 64, 8]));
|
||||
assert!(c.spread_allowed("minecraft:the_nether", [16, 64, 8], [15, 64, 8]));
|
||||
// From Steve's chunk next door: allowed once Alex trusts Steve.
|
||||
c.command(&player(B, 0), &at(24.0, 8.0), 0, &find, &name_of, "claim")
|
||||
.unwrap();
|
||||
assert!(!c.spread_allowed(ow, [16, 64, 8], [15, 64, 8]));
|
||||
c.command(
|
||||
&player(A, 0),
|
||||
&at(8.0, 8.0),
|
||||
0,
|
||||
&find,
|
||||
&name_of,
|
||||
"trust steve",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(c.spread_allowed(ow, [16, 64, 8], [15, 64, 8]));
|
||||
assert!(!c.spread_allowed(ow, [15, 64, 8], [16, 64, 8]));
|
||||
// Explosions spare both claims, not the land around them.
|
||||
let mut blocks = vec![[0, 64, 0], [20, 64, 0], [40, 64, 0], [-1, 64, 0]];
|
||||
c.spare(ow, &mut blocks);
|
||||
assert_eq!(blocks, vec![[40, 64, 0], [-1, 64, 0]]);
|
||||
assert!(c.protects("minecraft:item_frame") && c.protects("minecraft:cow"));
|
||||
assert!(!c.protects("minecraft:zombie") && !c.protects("minecraft:player"));
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_claim_keeps_others_out_until_trusted() {
|
||||
let d = dir();
|
||||
let mut c = Claims::open(&d, Config::default()).unwrap();
|
||||
let out = c
|
||||
.command(&player(A, 0), &at(-3.5, 20.0), 0, &find, &name_of, "claim")
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Reply(s) if s.contains("x -16 to -1, z 16 to 31")),
|
||||
"{out:?}"
|
||||
);
|
||||
let ow = "minecraft:overworld";
|
||||
assert_eq!(
|
||||
c.check(&player(A, 0), ow, [-16, 0, 31], &name_of, 0),
|
||||
Ok(())
|
||||
);
|
||||
assert_eq!(
|
||||
c.check(&player(B, 0), ow, [-1, 100, 16], &name_of, 0),
|
||||
Err(Some("This land is claimed by Alex.".into()))
|
||||
);
|
||||
// Told once each 2 s; the next chunk and the Nether are free.
|
||||
assert_eq!(
|
||||
c.check(&player(B, 0), ow, [-1, 100, 16], &name_of, 1_000),
|
||||
Err(None)
|
||||
);
|
||||
assert_eq!(c.check(&player(B, 0), ow, [0, 64, 16], &name_of, 0), Ok(()));
|
||||
assert_eq!(
|
||||
c.check(
|
||||
&player(B, 0),
|
||||
"minecraft:the_nether",
|
||||
[-1, 64, 16],
|
||||
&name_of,
|
||||
0
|
||||
),
|
||||
Ok(())
|
||||
);
|
||||
// Operators pass.
|
||||
assert_eq!(
|
||||
c.check(&player(B, 3), ow, [-1, 64, 16], &name_of, 0),
|
||||
Ok(())
|
||||
);
|
||||
c.command(
|
||||
&player(A, 0),
|
||||
&at(0.0, 0.0),
|
||||
0,
|
||||
&find,
|
||||
&name_of,
|
||||
"trust steve",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
c.check(&player(B, 0), ow, [-1, 64, 16], &name_of, 9_000),
|
||||
Ok(())
|
||||
);
|
||||
let out = c
|
||||
.command(
|
||||
&player(B, 0),
|
||||
&at(-3.5, 20.0),
|
||||
0,
|
||||
&find,
|
||||
&name_of,
|
||||
"unclaim",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Error(s) if s.contains("Alex's")),
|
||||
"trust isn't ownership"
|
||||
);
|
||||
c.save().unwrap();
|
||||
let mut again = Claims::open(&d, Config::default()).unwrap();
|
||||
assert_eq!(again.owner(ow, -1, 16), Some(A));
|
||||
again
|
||||
.command(
|
||||
&player(A, 0),
|
||||
&at(0.0, 0.0),
|
||||
0,
|
||||
&find,
|
||||
&name_of,
|
||||
"untrust Steve",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
again
|
||||
.check(&player(B, 0), ow, [-1, 64, 16], &name_of, 20_000)
|
||||
.is_err()
|
||||
);
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunks_grow_with_time_played() {
|
||||
let d = dir();
|
||||
let mut c = Claims::open(&d, Config::default()).unwrap();
|
||||
for x in 0..4 {
|
||||
let out = c
|
||||
.command(
|
||||
&player(A, 0),
|
||||
&at(x as f64 * 16.0, 0.0),
|
||||
0,
|
||||
&find,
|
||||
&name_of,
|
||||
"claim",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(&out[0], Out::Reply(_)), "{out:?}");
|
||||
}
|
||||
let out = c
|
||||
.command(&player(A, 0), &at(64.0, 0.0), 0, &find, &name_of, "claim")
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Error(s) if s.contains("4 of 4")),
|
||||
"{out:?}"
|
||||
);
|
||||
let out = c
|
||||
.command(
|
||||
&player(A, 0),
|
||||
&at(64.0, 0.0),
|
||||
5 * 3600,
|
||||
&find,
|
||||
&name_of,
|
||||
"claim",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Reply(s) if s.contains("5 of 16")),
|
||||
"{out:?}"
|
||||
);
|
||||
let out = c
|
||||
.command(&player(B, 0), &at(0.0, 0.0), 0, &find, &name_of, "claim")
|
||||
.unwrap();
|
||||
assert_eq!(out, vec![Out::Error("This chunk is Alex's.".into())]);
|
||||
// An operator may free any chunk.
|
||||
let out = c
|
||||
.command(&player(B, 3), &at(0.0, 0.0), 0, &find, &name_of, "unclaim")
|
||||
.unwrap();
|
||||
assert!(matches!(&out[0], Out::Reply(_)));
|
||||
let out = c
|
||||
.command(&player(A, 0), &at(0.0, 0.0), 0, &find, &name_of, "claims")
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Reply(s) if s.starts_with("Your chunks (4 of 4)")),
|
||||
"{out:?}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,9 @@ pub struct Economy {
|
||||
path: PathBuf,
|
||||
audit: Audit,
|
||||
dirty: bool,
|
||||
/// Counts the changes to the accounts, so the sidebar knows when to
|
||||
/// look again.
|
||||
revision: u64,
|
||||
/// Milliseconds since 1970, for the log (the glue sets it each call).
|
||||
pub now: i64,
|
||||
}
|
||||
@@ -97,6 +100,7 @@ impl Economy {
|
||||
path,
|
||||
audit: Audit::new(dir.join("audit.log")),
|
||||
dirty: false,
|
||||
revision: 0,
|
||||
now: 0,
|
||||
};
|
||||
// Games the last run left undecided: their stakes go back.
|
||||
@@ -109,6 +113,27 @@ impl Economy {
|
||||
Ok(economy)
|
||||
}
|
||||
|
||||
fn touch(&mut self) {
|
||||
self.dirty = true;
|
||||
self.revision += 1;
|
||||
}
|
||||
|
||||
/// Changes to the accounts so far (in this run).
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
|
||||
/// Every account: UUID, name, balance.
|
||||
pub fn accounts(&self) -> Vec<(u128, String, i64)> {
|
||||
self.accounts
|
||||
.accounts
|
||||
.iter()
|
||||
.filter_map(|(k, a)| {
|
||||
Some((super::smp_store::parse_uuid(k)?, a.name.clone(), a.balance))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Money `uuid` has held for a game.
|
||||
pub fn held(&self, uuid: u128) -> i64 {
|
||||
self.accounts
|
||||
@@ -139,7 +164,7 @@ impl Economy {
|
||||
.expect("checked")
|
||||
.balance -= amount;
|
||||
*self.accounts.held.entry(key).or_default() += amount;
|
||||
self.dirty = true;
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -155,7 +180,7 @@ impl Economy {
|
||||
self.take_held(&key, amount);
|
||||
let account = self.accounts.accounts.entry(key).or_default();
|
||||
account.balance = account.balance.saturating_add(amount);
|
||||
self.dirty = true;
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -187,7 +212,7 @@ impl Economy {
|
||||
.entry(uuid_string(winner))
|
||||
.or_default();
|
||||
account.balance = account.balance.saturating_add(prize);
|
||||
self.dirty = true;
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -226,7 +251,7 @@ impl Economy {
|
||||
Some(a) if a.name == name => {}
|
||||
Some(a) => {
|
||||
a.name = name.to_owned();
|
||||
self.dirty = true;
|
||||
self.touch();
|
||||
}
|
||||
None => {
|
||||
let start = self.config.starting_balance.clamp(0, MAX_AMOUNT);
|
||||
@@ -239,7 +264,7 @@ impl Economy {
|
||||
balance: start,
|
||||
},
|
||||
);
|
||||
self.dirty = true;
|
||||
self.touch();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,6 +279,15 @@ impl Economy {
|
||||
.and_then(|(k, a)| Some((super::smp_store::parse_uuid(k)?, a)))
|
||||
}
|
||||
|
||||
/// The name last seen of a player who has an account.
|
||||
pub fn name_of(&self, uuid: u128) -> Option<&str> {
|
||||
let a = self
|
||||
.accounts
|
||||
.accounts
|
||||
.get(&super::smp_store::uuid_string(uuid))?;
|
||||
Some(&a.name)
|
||||
}
|
||||
|
||||
/// `1,250 coins`.
|
||||
pub fn money(&self, amount: i64) -> String {
|
||||
let digits = amount.unsigned_abs().to_string();
|
||||
@@ -339,7 +373,7 @@ impl Economy {
|
||||
.get_mut(&uuid_string(to))
|
||||
.expect("checked")
|
||||
.balance = to_after;
|
||||
self.dirty = true;
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -369,7 +403,7 @@ impl Economy {
|
||||
.get_mut(&key)
|
||||
.expect("checked")
|
||||
.balance = after;
|
||||
self.dirty = true;
|
||||
self.touch();
|
||||
Ok(after)
|
||||
}
|
||||
|
||||
@@ -392,20 +426,67 @@ impl Economy {
|
||||
all
|
||||
}
|
||||
|
||||
/// Runs `/balance`, `/bal`, `/pay`, `/baltop` or `/eco`; None for another
|
||||
/// command.
|
||||
pub fn command(&mut self, me: &Player, line: &str) -> Option<Vec<Out>> {
|
||||
/// Runs `/balance`, `/bal`, `/pay`, `/baltop` or the operators' `/eco`
|
||||
/// (and its other name `/money`); None for another command. `online`:
|
||||
/// the players on, for `/eco give * <amount>`.
|
||||
pub fn command(
|
||||
&mut self,
|
||||
me: &Player,
|
||||
online: &[(u128, String)],
|
||||
line: &str,
|
||||
) -> Option<Vec<Out>> {
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
let (&name, args) = words.split_first()?;
|
||||
Some(match name {
|
||||
"balance" | "bal" => self.balance_command(me, args),
|
||||
"pay" => self.pay(me, args),
|
||||
"baltop" => self.baltop(args),
|
||||
"eco" => self.eco(me, args),
|
||||
"eco" | "money" => match args {
|
||||
["give", "*", amount] => self.give_everyone(me, online, amount),
|
||||
_ => self.eco(me, args),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `/eco give * <amount>`: to every player on, each change logged.
|
||||
fn give_everyone(&mut self, me: &Player, online: &[(u128, String)], amount: &str) -> Vec<Out> {
|
||||
if me.level < 3 {
|
||||
return vec![Out::Error("Only operators of level 3 may use /eco.".into())];
|
||||
}
|
||||
let Some(amount) = parse_amount(amount) else {
|
||||
return vec![Out::Error(
|
||||
"The amount must be a positive whole number.".into(),
|
||||
)];
|
||||
};
|
||||
let by = uuid_string(me.uuid);
|
||||
let mut out = Vec::new();
|
||||
let mut given = 0;
|
||||
for (uuid, name) in online {
|
||||
self.seen(*uuid, name);
|
||||
match self.adjust("eco_give", *uuid, amount, &by) {
|
||||
Ok(_) => {
|
||||
given += 1;
|
||||
if *uuid != me.uuid {
|
||||
out.push(Out::Tell(
|
||||
*uuid,
|
||||
format!("An operator gave you {}.", self.money(amount)),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => out.push(Out::Error(format!("{name}: {e}"))),
|
||||
}
|
||||
}
|
||||
out.insert(
|
||||
0,
|
||||
Out::Reply(format!(
|
||||
"Gave {} to {given} player(s) on.",
|
||||
self.money(amount)
|
||||
)),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
fn balance_command(&mut self, me: &Player, args: &[&str]) -> Vec<Out> {
|
||||
match args {
|
||||
[] => vec![Out::Reply(format!(
|
||||
@@ -474,7 +555,7 @@ impl Economy {
|
||||
}
|
||||
let [verb, who, amount] = args else {
|
||||
return vec![Out::Error(
|
||||
"Usage: /eco give|take|set <player> <amount>".into(),
|
||||
"Usage: /eco give|take|set <player> <amount>, /eco give * <amount>".into(),
|
||||
)];
|
||||
};
|
||||
let amount = match amount.parse::<i64>() {
|
||||
@@ -495,13 +576,22 @@ impl Economy {
|
||||
"set" => amount - account.balance,
|
||||
_ => {
|
||||
return vec![Out::Error(
|
||||
"Usage: /eco give|take|set <player> <amount>".into(),
|
||||
"Usage: /eco give|take|set <player> <amount>, /eco give * <amount>".into(),
|
||||
)];
|
||||
}
|
||||
};
|
||||
let by = uuid_string(me.uuid);
|
||||
match self.adjust(&format!("eco_{verb}"), uuid, delta, &by) {
|
||||
Ok(after) => vec![Out::Reply(format!("{name} now has {}.", self.money(after)))],
|
||||
Ok(after) => {
|
||||
let mut out = vec![Out::Reply(format!("{name} now has {}.", self.money(after)))];
|
||||
if *verb == "give" && uuid != me.uuid && amount > 0 {
|
||||
out.push(Out::Tell(
|
||||
uuid,
|
||||
format!("An operator gave you {}.", self.money(amount)),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
Err(e) => vec![Out::Error(e)],
|
||||
}
|
||||
}
|
||||
@@ -559,7 +649,9 @@ mod tests {
|
||||
#[test]
|
||||
fn pay_moves_money_and_tells_both() {
|
||||
let (mut e, dir) = economy("pay");
|
||||
let out = e.command(&player(ALEX, "Alex", 0), "pay steve 30").unwrap();
|
||||
let out = e
|
||||
.command(&player(ALEX, "Alex", 0), &[], "pay steve 30")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
[
|
||||
@@ -598,7 +690,7 @@ mod tests {
|
||||
("pay Steve", "Usage: /pay <player> <amount>"),
|
||||
] {
|
||||
assert_eq!(
|
||||
e.command(&alex, line).unwrap(),
|
||||
e.command(&alex, &[], line).unwrap(),
|
||||
[Out::Error(error.into())],
|
||||
"{line}"
|
||||
);
|
||||
@@ -615,9 +707,10 @@ mod tests {
|
||||
#[test]
|
||||
fn balances_survive_a_restart_and_a_damaged_file_stops_the_economy() {
|
||||
let (mut e, dir) = economy("restart");
|
||||
e.command(&player(ALEX, "Alex", 0), "pay Steve 1,000")
|
||||
e.command(&player(ALEX, "Alex", 0), &[], "pay Steve 1,000")
|
||||
.unwrap();
|
||||
e.command(&player(ALEX, "Alex", 0), &[], "pay Steve 25")
|
||||
.unwrap();
|
||||
e.command(&player(ALEX, "Alex", 0), "pay Steve 25").unwrap();
|
||||
e.save().unwrap();
|
||||
let again = Economy::open(&dir, Config::default()).unwrap();
|
||||
assert_eq!((again.balance(ALEX), again.balance(STEVE)), (75, 125));
|
||||
@@ -626,25 +719,73 @@ mod tests {
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn money_is_eco_and_give_star_pays_everyone_on() {
|
||||
let (mut e, dir) = economy("money");
|
||||
let op = player(ALEX, "Alex", 3);
|
||||
assert_eq!(
|
||||
e.command(&op, &[], "money give Steve 7").unwrap()[0],
|
||||
Out::Reply("Steve now has 107 coins.".into())
|
||||
);
|
||||
let online = [
|
||||
(ALEX, "Alex".to_owned()),
|
||||
(STEVE, "Steve".to_owned()),
|
||||
(99, "Newcomer".to_owned()),
|
||||
];
|
||||
let out = e.command(&op, &online, "eco give * 1,000").unwrap();
|
||||
assert_eq!(
|
||||
out[0],
|
||||
Out::Reply("Gave 1,000 coins to 3 player(s) on.".into()),
|
||||
"{out:?}"
|
||||
);
|
||||
assert!(out.contains(&Out::Tell(99, "An operator gave you 1,000 coins.".into())));
|
||||
assert!(
|
||||
!out.iter()
|
||||
.any(|o| matches!(o, Out::Tell(u, _) if *u == ALEX))
|
||||
);
|
||||
assert_eq!(
|
||||
(e.balance(ALEX), e.balance(STEVE), e.balance(99)),
|
||||
(1100, 1107, 1100)
|
||||
);
|
||||
let gives = log_lines(&dir)
|
||||
.iter()
|
||||
.filter(|l| l["kind"] == "eco_give")
|
||||
.count();
|
||||
assert_eq!(gives, 4);
|
||||
assert_eq!(
|
||||
e.command(&player(STEVE, "Steve", 0), &online, "money give * 5")
|
||||
.unwrap(),
|
||||
[Out::Error("Only operators of level 3 may use /eco.".into())]
|
||||
);
|
||||
assert!(matches!(
|
||||
&e.command(&op, &online, "eco give * -5").unwrap()[0],
|
||||
Out::Error(_)
|
||||
));
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eco_is_for_level_3_and_never_goes_below_zero() {
|
||||
let (mut e, dir) = economy("eco");
|
||||
let alex = player(ALEX, "Alex", 2);
|
||||
assert_eq!(
|
||||
e.command(&alex, "eco give Steve 5").unwrap(),
|
||||
e.command(&alex, &[], "eco give Steve 5").unwrap(),
|
||||
[Out::Error("Only operators of level 3 may use /eco.".into())]
|
||||
);
|
||||
let op = player(ALEX, "Alex", 3);
|
||||
assert_eq!(
|
||||
e.command(&op, "eco give Steve 5").unwrap(),
|
||||
[Out::Reply("Steve now has 105 coins.".into())]
|
||||
e.command(&op, &[], "eco give Steve 5").unwrap(),
|
||||
[
|
||||
Out::Reply("Steve now has 105 coins.".into()),
|
||||
Out::Tell(STEVE, "An operator gave you 5 coins.".into())
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
e.command(&op, "eco take Steve 200").unwrap(),
|
||||
e.command(&op, &[], "eco take Steve 200").unwrap(),
|
||||
[Out::Error("Only 105 coins there.".into())]
|
||||
);
|
||||
assert_eq!(
|
||||
e.command(&op, "eco set Steve 1234567").unwrap(),
|
||||
e.command(&op, &[], "eco set Steve 1234567").unwrap(),
|
||||
[Out::Reply("Steve now has 1,234,567 coins.".into())]
|
||||
);
|
||||
let log = log_lines(&dir);
|
||||
@@ -659,12 +800,15 @@ mod tests {
|
||||
for i in 0..12u128 {
|
||||
e.seen(100 + i, &format!("P{i:02}"));
|
||||
}
|
||||
e.command(&player(ALEX, "Alex", 0), "pay Steve 50").unwrap();
|
||||
let first = e.command(&player(ALEX, "Alex", 0), "baltop").unwrap();
|
||||
e.command(&player(ALEX, "Alex", 0), &[], "pay Steve 50")
|
||||
.unwrap();
|
||||
let first = e.command(&player(ALEX, "Alex", 0), &[], "baltop").unwrap();
|
||||
assert_eq!(first[0], Out::Reply("Richest players (1/2):".into()));
|
||||
assert_eq!(first[1], Out::Reply("1. Steve: 150 coins".into()));
|
||||
assert_eq!(first.len(), 11);
|
||||
let last = e.command(&player(ALEX, "Alex", 0), "baltop 2").unwrap();
|
||||
let last = e
|
||||
.command(&player(ALEX, "Alex", 0), &[], "baltop 2")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
last.last().unwrap(),
|
||||
&Out::Reply("14. Alex: 50 coins".into())
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
// The server market: the server buys and sells vanilla items at the prices
|
||||
// of `config.json`'s `market`, the economy's way to earn without changing
|
||||
// survival. `/worth`, `/sell hand|all|<item> [count]`, `/buy <item> [count]`.
|
||||
// Only plain items (no damage, no name or enchantment) are traded, and only
|
||||
// by players in survival or adventure mode. Items move first, then money,
|
||||
// both logged; a sale the log refuses gives the items back. No game types:
|
||||
// the glue lends the acting player's items as a `Bag`. Added to the game as
|
||||
// survival. `/worth`, `/sell hand|all|<item> [count]`, `/buy <item> [count]`,
|
||||
// and the `/shop` menu (smp_shop) on the same prices. Only plain items (no
|
||||
// damage, no name or enchantment) are traded, and only by players in survival
|
||||
// or adventure mode. Items move first, then money, both logged; a sale the
|
||||
// log refuses gives the items back. Nobody gets rich from it: buying always
|
||||
// costs more than selling (an item priced otherwise isn't bought), no chain
|
||||
// of recipes turns bought items into dearer ones (smp_recipes; such an item
|
||||
// isn't bought either), and what a player sells is capped each UTC day, in
|
||||
// coins and per item (`market.json` counts it). No game types: the glue
|
||||
// lends the player's items as a `Bag`. Added to the game as
|
||||
// `game::smp_market`; also a module of this crate, for its tests.
|
||||
|
||||
use super::smp_economy::{Economy, MAX_AMOUNT, Out, Player, parse_amount};
|
||||
use super::smp_store::{read_json, uuid_string, write_json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
|
||||
/// A UTC day, in milliseconds.
|
||||
const DAY_MS: i64 = 86_400_000;
|
||||
|
||||
/// What the server pays for one item (`sell`) and asks for one (`buy`); 0
|
||||
/// closes that side.
|
||||
@@ -25,41 +34,94 @@ pub struct Price {
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub enabled: bool,
|
||||
/// By item id, `minecraft:diamond`.
|
||||
/// By item id, `minecraft:diamond`. Items that stack to 64.
|
||||
pub items: BTreeMap<String, Price>,
|
||||
/// The most a player earns selling to the market in a UTC day (0: no
|
||||
/// limit).
|
||||
pub daily_sell_coins: i64,
|
||||
/// The most of one item a player sells to the market in a UTC day (0:
|
||||
/// no limit).
|
||||
pub daily_sell_items: u32,
|
||||
}
|
||||
|
||||
/// The default prices: (item, buy, sell). Farm, mine and mob produce the
|
||||
/// market buys cheaply; blocks, food and tools it only sells.
|
||||
pub const PRICES: &[(&str, i64, i64)] = &[
|
||||
// Farming.
|
||||
("wheat", 3, 1),
|
||||
("wheat_seeds", 2, 0),
|
||||
("carrot", 3, 1),
|
||||
("potato", 3, 1),
|
||||
("beetroot", 3, 1),
|
||||
("sugar_cane", 3, 1),
|
||||
("pumpkin", 8, 3),
|
||||
("melon_slice", 2, 1),
|
||||
("cactus", 3, 1),
|
||||
("bone_meal", 3, 0),
|
||||
// Mining.
|
||||
("coal", 8, 2),
|
||||
("copper_ingot", 6, 2),
|
||||
("iron_ingot", 30, 8),
|
||||
("gold_ingot", 40, 10),
|
||||
("redstone", 4, 1),
|
||||
("lapis_lazuli", 8, 2),
|
||||
("quartz", 8, 2),
|
||||
("emerald", 80, 20),
|
||||
("diamond", 400, 100),
|
||||
// Wood.
|
||||
("oak_log", 4, 1),
|
||||
("spruce_log", 4, 1),
|
||||
("birch_log", 4, 1),
|
||||
("jungle_log", 4, 1),
|
||||
("acacia_log", 4, 1),
|
||||
("dark_oak_log", 4, 1),
|
||||
("mangrove_log", 4, 1),
|
||||
("cherry_log", 4, 1),
|
||||
// Mob drops.
|
||||
("rotten_flesh", 0, 1),
|
||||
("bone", 3, 1),
|
||||
("string", 3, 1),
|
||||
("gunpowder", 8, 2),
|
||||
("spider_eye", 4, 1),
|
||||
("slime_ball", 10, 2),
|
||||
("blaze_rod", 30, 8),
|
||||
("leather", 6, 2),
|
||||
("feather", 2, 1),
|
||||
// Building.
|
||||
("cobblestone", 1, 0),
|
||||
("stone", 2, 0),
|
||||
("dirt", 1, 0),
|
||||
("sand", 2, 0),
|
||||
("gravel", 2, 0),
|
||||
("glass", 3, 0),
|
||||
("bricks", 4, 0),
|
||||
("stone_bricks", 3, 0),
|
||||
// Food.
|
||||
("bread", 5, 0),
|
||||
("cooked_beef", 6, 0),
|
||||
("cooked_porkchop", 6, 0),
|
||||
("cooked_chicken", 5, 0),
|
||||
("baked_potato", 4, 0),
|
||||
("apple", 4, 0),
|
||||
("golden_carrot", 40, 0),
|
||||
// Tools.
|
||||
("torch", 1, 0),
|
||||
("ladder", 2, 0),
|
||||
("crafting_table", 4, 0),
|
||||
("chest", 6, 0),
|
||||
("furnace", 6, 0),
|
||||
];
|
||||
|
||||
impl Default for Config {
|
||||
/// A small starting list the owner edits: farm and mine produce the
|
||||
/// server buys, a few things it sells, buying always dearer than selling.
|
||||
/// `PRICES`, buying always dearer than selling, and the daily limits.
|
||||
fn default() -> Self {
|
||||
let list: [(&str, i64, i64); 18] = [
|
||||
("minecraft:wheat", 0, 1),
|
||||
("minecraft:carrot", 0, 1),
|
||||
("minecraft:potato", 0, 1),
|
||||
("minecraft:sugar_cane", 0, 1),
|
||||
("minecraft:pumpkin", 0, 3),
|
||||
("minecraft:melon_slice", 0, 1),
|
||||
("minecraft:cactus", 0, 1),
|
||||
("minecraft:oak_log", 4, 1),
|
||||
("minecraft:coal", 8, 2),
|
||||
("minecraft:iron_ingot", 30, 8),
|
||||
("minecraft:gold_ingot", 40, 10),
|
||||
("minecraft:redstone", 4, 1),
|
||||
("minecraft:lapis_lazuli", 8, 2),
|
||||
("minecraft:emerald", 80, 20),
|
||||
("minecraft:diamond", 400, 100),
|
||||
("minecraft:bread", 5, 0),
|
||||
("minecraft:cooked_beef", 6, 0),
|
||||
("minecraft:torch", 1, 0),
|
||||
];
|
||||
Self {
|
||||
enabled: true,
|
||||
items: list
|
||||
items: PRICES
|
||||
.iter()
|
||||
.map(|(id, buy, sell)| {
|
||||
(
|
||||
(*id).to_owned(),
|
||||
format!("minecraft:{id}"),
|
||||
Price {
|
||||
buy: *buy,
|
||||
sell: *sell,
|
||||
@@ -67,10 +129,128 @@ impl Default for Config {
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
daily_sell_coins: 2000,
|
||||
daily_sell_items: 640,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The default prices of SMP 0.1 to 0.11: a config that still holds
|
||||
/// exactly these (never edited) gets today's defaults (`migrate`).
|
||||
const OLD_PRICES: [(&str, i64, i64); 18] = [
|
||||
("wheat", 0, 1),
|
||||
("carrot", 0, 1),
|
||||
("potato", 0, 1),
|
||||
("sugar_cane", 0, 1),
|
||||
("pumpkin", 0, 3),
|
||||
("melon_slice", 0, 1),
|
||||
("cactus", 0, 1),
|
||||
("oak_log", 4, 1),
|
||||
("coal", 8, 2),
|
||||
("iron_ingot", 30, 8),
|
||||
("gold_ingot", 40, 10),
|
||||
("redstone", 4, 1),
|
||||
("lapis_lazuli", 8, 2),
|
||||
("emerald", 80, 20),
|
||||
("diamond", 400, 100),
|
||||
("bread", 5, 0),
|
||||
("cooked_beef", 6, 0),
|
||||
("torch", 1, 0),
|
||||
];
|
||||
|
||||
/// A config whose prices are still the old defaults, untouched: today's
|
||||
/// defaults instead. Whether it changed (the file is written again).
|
||||
pub fn migrate(config: &mut Config) -> bool {
|
||||
let old: BTreeMap<String, Price> = OLD_PRICES
|
||||
.iter()
|
||||
.map(|(id, buy, sell)| {
|
||||
(
|
||||
format!("minecraft:{id}"),
|
||||
Price {
|
||||
buy: *buy,
|
||||
sell: *sell,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if config.items != old {
|
||||
return false;
|
||||
}
|
||||
config.items = Config::default().items;
|
||||
true
|
||||
}
|
||||
|
||||
/// What one player sold today.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Sold {
|
||||
/// The UTC day (days since 1970) the counts are for.
|
||||
pub day: i64,
|
||||
pub coins: i64,
|
||||
pub items: BTreeMap<String, u32>,
|
||||
}
|
||||
|
||||
/// `market.json`: what each player sold today, by UUID.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Ledger {
|
||||
pub sold: BTreeMap<String, Sold>,
|
||||
}
|
||||
|
||||
/// A config's prices made safe: an amount out of range, or a sale price not
|
||||
/// below the buying price, closes that item's sale; so does an item a chain
|
||||
/// of recipes makes for less than it sells for. What it closed, to log.
|
||||
pub fn validate(config: &mut Config) -> Vec<String> {
|
||||
let mut closed = Vec::new();
|
||||
config.daily_sell_coins = config.daily_sell_coins.clamp(0, MAX_AMOUNT);
|
||||
for (id, p) in config.items.iter_mut() {
|
||||
if !valid_id(id) {
|
||||
closed.push(format!("{id}: not an item id; left out"));
|
||||
*p = Price::default();
|
||||
continue;
|
||||
}
|
||||
p.buy = p.buy.clamp(0, MAX_AMOUNT);
|
||||
p.sell = p.sell.clamp(0, MAX_AMOUNT);
|
||||
if p.sell > 0 && p.buy > 0 && p.sell >= p.buy {
|
||||
closed.push(format!(
|
||||
"{id}: sells for {} but buys for {}; the market won't buy it",
|
||||
p.sell, p.buy
|
||||
));
|
||||
p.sell = 0;
|
||||
}
|
||||
}
|
||||
for l in super::smp_recipes::loops(&config.items, &super::smp_recipes::chains()) {
|
||||
closed.push(format!(
|
||||
"{}: crafted from the market's items for {:.2}, sells for {}; the market won't buy it",
|
||||
l.item, l.cost, l.sell
|
||||
));
|
||||
if let Some(p) = config.items.get_mut(&l.item) {
|
||||
p.sell = 0;
|
||||
}
|
||||
}
|
||||
config.items.retain(|_, p| p.buy > 0 || p.sell > 0);
|
||||
closed
|
||||
}
|
||||
|
||||
/// `minecraft:iron_ingot`: lowercase letters, digits, `_`, `.`, `-`, `/`
|
||||
/// and one `:`, as a command line may carry it.
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
let mut parts = id.split(':');
|
||||
let (Some(ns), Some(path), None) = (parts.next(), parts.next(), parts.next()) else {
|
||||
return false;
|
||||
};
|
||||
let ok = |s: &str, extra: &str| {
|
||||
!s.is_empty()
|
||||
&& s.chars().all(|c| {
|
||||
c.is_ascii_lowercase()
|
||||
|| c.is_ascii_digit()
|
||||
|| "_.-".contains(c)
|
||||
|| extra.contains(c)
|
||||
})
|
||||
};
|
||||
ok(ns, "") && ok(path, "/")
|
||||
}
|
||||
|
||||
/// The acting player's items, lent by the game for one command.
|
||||
pub trait Bag {
|
||||
/// The main hand: item id, count, plain.
|
||||
@@ -105,13 +285,126 @@ pub fn item_name(id: &str) -> String {
|
||||
|
||||
pub struct Market {
|
||||
pub config: Config,
|
||||
ledger: Ledger,
|
||||
/// `market.json`; None in tests that keep nothing.
|
||||
path: Option<PathBuf>,
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
impl Market {
|
||||
/// The market of `dir` (`world/horizon-smp/`), its config made safe
|
||||
/// (`validate`), with what to log. A `market.json` that can't be read
|
||||
/// starts the day's counts from nothing (they only limit, never hold
|
||||
/// money).
|
||||
pub fn open(dir: &std::path::Path, mut config: Config) -> (Self, Vec<String>) {
|
||||
let mut problems: Vec<String> = validate(&mut config)
|
||||
.into_iter()
|
||||
.map(|l| format!("market: {l}"))
|
||||
.collect();
|
||||
let path = dir.join("market.json");
|
||||
let ledger = read_json(&path).unwrap_or_else(|e| {
|
||||
problems.push(format!("{e}: today's sale limits start again"));
|
||||
Ledger::default()
|
||||
});
|
||||
let market = Self {
|
||||
config,
|
||||
ledger,
|
||||
path: Some(path),
|
||||
dirty: false,
|
||||
};
|
||||
(market, problems)
|
||||
}
|
||||
|
||||
/// A market that keeps nothing on disk, its config as given (tests).
|
||||
#[cfg(test)]
|
||||
pub fn new(config: Config) -> Self {
|
||||
Self {
|
||||
config,
|
||||
ledger: Ledger::default(),
|
||||
path: None,
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&mut self) -> Result<(), String> {
|
||||
if !self.dirty {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(path) = &self.path {
|
||||
write_json(path, &self.ledger)?;
|
||||
}
|
||||
self.dirty = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What `uuid` sold today (`now`: milliseconds since 1970).
|
||||
fn sold(&self, uuid: u128, now: i64) -> Sold {
|
||||
match self.ledger.sold.get(&uuid_string(uuid)) {
|
||||
Some(s) if s.day == now.div_euclid(DAY_MS) => s.clone(),
|
||||
_ => Sold::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The coins `uuid` may still earn selling today (None: no limit).
|
||||
pub fn coins_left(&self, uuid: u128, now: i64) -> Option<i64> {
|
||||
let cap = self.config.daily_sell_coins;
|
||||
(cap > 0).then(|| (cap - self.sold(uuid, now).coins).max(0))
|
||||
}
|
||||
|
||||
/// How many of `id` `uuid` may still sell today (None: no limit).
|
||||
pub fn items_left(&self, uuid: u128, now: i64, id: &str) -> Option<u32> {
|
||||
let cap = self.config.daily_sell_items;
|
||||
(cap > 0)
|
||||
.then(|| cap.saturating_sub(self.sold(uuid, now).items.get(id).copied().unwrap_or(0)))
|
||||
}
|
||||
|
||||
/// Counts a sale against today's limits.
|
||||
fn count_sale(&mut self, uuid: u128, now: i64, id: &str, n: u32, coins: i64) {
|
||||
let day = now.div_euclid(DAY_MS);
|
||||
let s = self.ledger.sold.entry(uuid_string(uuid)).or_default();
|
||||
if s.day != day {
|
||||
*s = Sold {
|
||||
day,
|
||||
..Sold::default()
|
||||
};
|
||||
}
|
||||
s.coins = s.coins.saturating_add(coins);
|
||||
let c = s.items.entry(id.to_owned()).or_default();
|
||||
*c = c.saturating_add(n);
|
||||
// Yesterday's counts go.
|
||||
self.ledger.sold.retain(|_, s| s.day == day);
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// The price of `id` (nothing on either side when it isn't traded).
|
||||
pub fn price(&self, id: &str) -> Price {
|
||||
self.config.items.get(id).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Today's limits for `uuid`, as a line (None when there are none).
|
||||
pub fn limits_line(&self, economy: &Economy, uuid: u128) -> Option<String> {
|
||||
let now = economy.now;
|
||||
let coins = self.coins_left(uuid, now).map(|left| {
|
||||
format!(
|
||||
"{} left of your {} a day",
|
||||
economy.money(left),
|
||||
economy.money(self.config.daily_sell_coins)
|
||||
)
|
||||
});
|
||||
let items = (self.config.daily_sell_items > 0).then(|| {
|
||||
format!(
|
||||
"at most {} of each item a day",
|
||||
self.config.daily_sell_items
|
||||
)
|
||||
});
|
||||
let parts: Vec<String> = coins.into_iter().chain(items).collect();
|
||||
(!parts.is_empty()).then(|| format!("Selling today: {}.", parts.join(", ")))
|
||||
}
|
||||
|
||||
/// Runs `/worth`, `/sell` or `/buy`; None for another command. `trades`
|
||||
/// is false for a player in creative or spectator mode.
|
||||
pub fn command(
|
||||
&self,
|
||||
&mut self,
|
||||
economy: &mut Economy,
|
||||
me: &Player,
|
||||
trades: bool,
|
||||
@@ -127,7 +420,7 @@ impl Market {
|
||||
return Some(vec![Out::Error("The market is closed.".into())]);
|
||||
}
|
||||
if name == "worth" {
|
||||
return Some(self.worth(economy, bag, args));
|
||||
return Some(self.worth(economy, me, bag, args));
|
||||
}
|
||||
if !trades {
|
||||
return Some(vec![Out::Error(
|
||||
@@ -140,11 +433,7 @@ impl Market {
|
||||
})
|
||||
}
|
||||
|
||||
fn price(&self, id: &str) -> Price {
|
||||
self.config.items.get(id).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn worth(&self, economy: &Economy, bag: &mut dyn Bag, args: &[&str]) -> Vec<Out> {
|
||||
fn worth(&self, economy: &Economy, me: &Player, bag: &mut dyn Bag, args: &[&str]) -> Vec<Out> {
|
||||
let id = match args {
|
||||
[] => match bag.held() {
|
||||
Some((id, ..)) => id,
|
||||
@@ -169,11 +458,22 @@ impl Market {
|
||||
if parts.is_empty() {
|
||||
return vec![Out::Reply(format!("The market doesn't trade {name}."))];
|
||||
}
|
||||
vec![Out::Reply(format!("{name}: {}.", parts.join(", ")))]
|
||||
let mut out = vec![Out::Reply(format!("{name}: {}.", parts.join(", ")))];
|
||||
if p.sell > 0 {
|
||||
if let Some(left) = self.items_left(me.uuid, economy.now, &id) {
|
||||
out.push(Out::Reply(format!(
|
||||
"You may sell {left} more {name} today."
|
||||
)));
|
||||
}
|
||||
out.extend(self.limits_line(economy, me.uuid).map(Out::Reply));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn sell(
|
||||
&self,
|
||||
/// Sells what `args` names (`hand [n]`, `all`, `<item> [n]`), within
|
||||
/// today's limits.
|
||||
pub fn sell(
|
||||
&mut self,
|
||||
economy: &mut Economy,
|
||||
me: &Player,
|
||||
bag: &mut dyn Bag,
|
||||
@@ -224,23 +524,51 @@ impl Market {
|
||||
out.push(Out::Error(format!("The market doesn't buy {name}.")));
|
||||
continue;
|
||||
}
|
||||
// Never more than the account can be paid for at once.
|
||||
let cap = (MAX_AMOUNT / price).min(u32::MAX as i64) as u32;
|
||||
let have = bag.count(&id).min(at_most).min(cap);
|
||||
if have == 0 {
|
||||
// Never more than the account can be paid for at once, nor
|
||||
// than today's limits leave.
|
||||
let now = economy.now;
|
||||
let mut cap = (MAX_AMOUNT / price).min(i64::from(u32::MAX)) as u32;
|
||||
if let Some(left) = self.items_left(me.uuid, now, &id) {
|
||||
cap = cap.min(left);
|
||||
}
|
||||
if let Some(left) = self.coins_left(me.uuid, now) {
|
||||
cap = cap.min((left / price).min(i64::from(u32::MAX)) as u32);
|
||||
}
|
||||
let carried = bag.count(&id).min(at_most);
|
||||
if carried == 0 {
|
||||
out.push(Out::Error(format!("You have no plain {name}.")));
|
||||
continue;
|
||||
}
|
||||
let have = carried.min(cap);
|
||||
if have == 0 {
|
||||
out.push(Out::Error(format!(
|
||||
"You reached today's selling limit for {name}; it starts again at midnight UTC."
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
let taken = bag.take(&id, have);
|
||||
let pay = price * i64::from(taken);
|
||||
if taken == 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(pay) = price.checked_mul(i64::from(taken)) else {
|
||||
bag.give(&id, taken);
|
||||
continue;
|
||||
};
|
||||
let kind = format!("sell {taken} {id}");
|
||||
match economy.adjust(&kind, me.uuid, pay, "") {
|
||||
Ok(_) => {
|
||||
total += pay;
|
||||
self.count_sale(me.uuid, now, &id, taken, pay);
|
||||
total = total.saturating_add(pay);
|
||||
out.push(Out::Reply(format!(
|
||||
"Sold {taken} {name} for {}.",
|
||||
economy.money(pay)
|
||||
)));
|
||||
if taken < carried {
|
||||
out.push(Out::Reply(format!(
|
||||
"{} {name} kept: today's selling limit.",
|
||||
carried - taken
|
||||
)));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let back = bag.give(&id, taken);
|
||||
@@ -259,7 +587,8 @@ impl Market {
|
||||
out
|
||||
}
|
||||
|
||||
fn buy(
|
||||
/// Buys `args` (`<item> [count]`).
|
||||
pub fn buy(
|
||||
&self,
|
||||
economy: &mut Economy,
|
||||
me: &Player,
|
||||
@@ -283,7 +612,15 @@ impl Market {
|
||||
if count > 64 * 36 {
|
||||
return vec![Out::Error("At most a full inventory at once.".into())];
|
||||
}
|
||||
let cost = price * i64::from(count);
|
||||
if count == 0 {
|
||||
return vec![Out::Error("Usage: /buy <item> [count]".into())];
|
||||
}
|
||||
let Some(cost) = price
|
||||
.checked_mul(i64::from(count))
|
||||
.filter(|c| *c <= MAX_AMOUNT)
|
||||
else {
|
||||
return vec![Out::Error("That costs too much at once.".into())];
|
||||
};
|
||||
if economy.balance(me.uuid) < cost {
|
||||
return vec![Out::Error(format!(
|
||||
"{count} {name} cost {}; you have {}.",
|
||||
@@ -307,7 +644,7 @@ impl Market {
|
||||
))];
|
||||
if given < count {
|
||||
// Checked above; kept so money never goes for nothing.
|
||||
let refund = price * i64::from(count - given);
|
||||
let refund = price.saturating_mul(i64::from(count - given));
|
||||
if economy
|
||||
.adjust(
|
||||
&format!("refund {} {id}", count - given),
|
||||
@@ -378,6 +715,10 @@ mod tests {
|
||||
const ALEX: u128 = 1;
|
||||
|
||||
fn setup(name: &str) -> (Economy, Market, Player, std::path::PathBuf) {
|
||||
setup_with(name, Config::default())
|
||||
}
|
||||
|
||||
fn setup_with(name: &str, config: Config) -> (Economy, Market, Player, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!("smp-market-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
let mut e = Economy::open(&dir, EconomyConfig::default()).unwrap();
|
||||
@@ -387,14 +728,7 @@ mod tests {
|
||||
name: "Alex".into(),
|
||||
level: 0,
|
||||
};
|
||||
(
|
||||
e,
|
||||
Market {
|
||||
config: Config::default(),
|
||||
},
|
||||
me,
|
||||
dir,
|
||||
)
|
||||
(e, Market::new(config), me, dir)
|
||||
}
|
||||
|
||||
fn bag(items: &[(&str, u32)], room: u32) -> TestBag {
|
||||
@@ -407,7 +741,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn selling_takes_the_items_then_pays_and_logs_both() {
|
||||
let (mut e, m, me, dir) = setup("sell");
|
||||
let (mut e, mut m, me, dir) = setup("sell");
|
||||
let mut b = bag(
|
||||
&[
|
||||
("minecraft:diamond", 3),
|
||||
@@ -439,7 +773,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn selling_the_hand_takes_only_plain_items() {
|
||||
let (mut e, m, me, dir) = setup("hand");
|
||||
let (mut e, mut m, me, dir) = setup("hand");
|
||||
let mut b = bag(&[("minecraft:iron_ingot", 10)], 0);
|
||||
b.held = Some(("minecraft:iron_ingot".into(), 10, true));
|
||||
let out = m.command(&mut e, &me, true, &mut b, "sell hand 4").unwrap();
|
||||
@@ -460,7 +794,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn buying_checks_money_and_room_before_anything_moves() {
|
||||
let (mut e, m, me, dir) = setup("buy");
|
||||
let (mut e, mut m, me, dir) = setup("buy");
|
||||
e.adjust("test", ALEX, 1000, "").unwrap();
|
||||
let mut b = bag(&[], 3);
|
||||
let out = m
|
||||
@@ -483,8 +817,13 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(out[0], Out::Reply("Bought 2 diamond for 800 coins.".into()));
|
||||
assert_eq!((e.balance(ALEX), b.items["minecraft:diamond"]), (200, 2));
|
||||
let out = m.command(&mut e, &me, true, &mut b, "buy wheat").unwrap();
|
||||
assert_eq!(out, [Out::Error("The market doesn't sell wheat.".into())]);
|
||||
let out = m
|
||||
.command(&mut e, &me, true, &mut b, "buy rotten_flesh")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
[Out::Error("The market doesn't sell rotten flesh.".into())]
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -503,10 +842,10 @@ mod tests {
|
||||
.command(&mut e, &me, false, &mut b, "worth diamond")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
worth,
|
||||
[Out::Reply(
|
||||
worth[0],
|
||||
Out::Reply(
|
||||
"diamond: the market pays 100 coins each, sells it for 400 coins each.".into()
|
||||
)]
|
||||
)
|
||||
);
|
||||
m.config.enabled = false;
|
||||
let out = m.command(&mut e, &me, true, &mut b, "sell all").unwrap();
|
||||
@@ -515,4 +854,181 @@ mod tests {
|
||||
assert!(m.command(&mut e, &me, true, &mut b, "balance").is_none());
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sales_stop_at_the_daily_limits_and_start_again_the_next_day() {
|
||||
let config = Config {
|
||||
daily_sell_coins: 250,
|
||||
daily_sell_items: 30,
|
||||
..Config::default()
|
||||
};
|
||||
let (mut e, mut m, me, dir) = setup_with("limits", config);
|
||||
e.now = 5 * DAY_MS + 1000;
|
||||
let mut b = bag(&[("minecraft:diamond", 5), ("minecraft:wheat", 64)], 0);
|
||||
// 250 coins a day: two diamonds (200), then 50 wheat would pass the
|
||||
// coins but not the 30 items of one kind.
|
||||
let out = m
|
||||
.command(&mut e, &me, true, &mut b, "sell diamond")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out[0],
|
||||
Out::Reply("Sold 2 diamond for 200 coins.".into()),
|
||||
"{out:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
out[1],
|
||||
Out::Reply("3 diamond kept: today's selling limit.".into())
|
||||
);
|
||||
let out = m.command(&mut e, &me, true, &mut b, "sell wheat").unwrap();
|
||||
assert_eq!(
|
||||
out[0],
|
||||
Out::Reply("Sold 30 wheat for 30 coins.".into()),
|
||||
"{out:?}"
|
||||
);
|
||||
let out = m.command(&mut e, &me, true, &mut b, "sell wheat").unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
[Out::Error(
|
||||
"You reached today's selling limit for wheat; it starts again at midnight UTC."
|
||||
.into()
|
||||
)]
|
||||
);
|
||||
let worth = m
|
||||
.command(&mut e, &me, true, &mut b, "worth diamond")
|
||||
.unwrap();
|
||||
assert!(
|
||||
worth.contains(&Out::Reply("You may sell 28 more diamond today.".into())),
|
||||
"{worth:?}"
|
||||
);
|
||||
assert!(
|
||||
worth.contains(&Out::Reply(
|
||||
"Selling today: 20 coins left of your 250 coins a day, at most 30 of each item a day.".into()
|
||||
)),
|
||||
"{worth:?}"
|
||||
);
|
||||
assert_eq!((e.balance(ALEX), b.items["minecraft:diamond"]), (230, 3));
|
||||
// Kept across a restart, in market.json.
|
||||
m.path = Some(dir.join("market.json"));
|
||||
m.dirty = true;
|
||||
m.save().unwrap();
|
||||
let (mut m, _) = Market::open(&dir, m.config.clone());
|
||||
let out = m
|
||||
.command(&mut e, &me, true, &mut b, "sell diamond")
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Error(s) if s.contains("limit")),
|
||||
"{out:?}"
|
||||
);
|
||||
// A new UTC day.
|
||||
e.now = 6 * DAY_MS;
|
||||
let out = m
|
||||
.command(&mut e, &me, true, &mut b, "sell diamond")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out[0],
|
||||
Out::Reply("Sold 2 diamond for 200 coins.".into()),
|
||||
"{out:?}"
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prices_that_would_make_money_are_closed() {
|
||||
let mut config = Config::default();
|
||||
let set = |c: &mut Config, id: &str, buy, sell| {
|
||||
c.items
|
||||
.insert(format!("minecraft:{id}"), Price { buy, sell });
|
||||
};
|
||||
set(&mut config, "emerald", 20, 20);
|
||||
set(&mut config, "iron_block", 0, 300);
|
||||
set(&mut config, "rotten_flesh", -5, i64::MAX);
|
||||
config.items.insert(
|
||||
"minecraft:diamond 1\nop me".into(),
|
||||
Price { buy: 1, sell: 0 },
|
||||
);
|
||||
let closed = validate(&mut config);
|
||||
assert_eq!(closed.len(), 3, "{closed:?}");
|
||||
assert_eq!(
|
||||
config.items["minecraft:emerald"],
|
||||
Price { buy: 20, sell: 0 }
|
||||
);
|
||||
assert!(!config.items.contains_key("minecraft:iron_block"));
|
||||
assert_eq!(
|
||||
config.items["minecraft:rotten_flesh"],
|
||||
Price {
|
||||
buy: 0,
|
||||
sell: MAX_AMOUNT
|
||||
}
|
||||
);
|
||||
assert!(config.items.keys().all(|k| valid_id(k)));
|
||||
// At the largest price: one at a time, without overflow.
|
||||
let (mut e, mut m, me, dir) = setup_with(
|
||||
"validate",
|
||||
Config {
|
||||
daily_sell_coins: 0,
|
||||
..config
|
||||
},
|
||||
);
|
||||
let mut b = bag(&[("minecraft:rotten_flesh", 64)], 0);
|
||||
let out = m
|
||||
.command(&mut e, &me, true, &mut b, "sell rotten_flesh")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out[0],
|
||||
Out::Reply(format!("Sold 1 rotten flesh for {}.", e.money(MAX_AMOUNT))),
|
||||
"{out:?}"
|
||||
);
|
||||
assert_eq!(b.items["minecraft:rotten_flesh"], 63);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_purchase_too_dear_to_count_is_refused() {
|
||||
let mut config = Config::default();
|
||||
config.items.insert(
|
||||
"minecraft:diamond".into(),
|
||||
Price {
|
||||
buy: MAX_AMOUNT,
|
||||
sell: 0,
|
||||
},
|
||||
);
|
||||
let (mut e, mut m, me, dir) = setup_with("dear", config);
|
||||
let mut b = bag(&[], 64 * 36);
|
||||
let out = m
|
||||
.command(&mut e, &me, true, &mut b, "buy diamond 2")
|
||||
.unwrap();
|
||||
assert_eq!(out, [Out::Error("That costs too much at once.".into())]);
|
||||
let out = m
|
||||
.command(&mut e, &me, true, &mut b, "buy diamond 0")
|
||||
.unwrap();
|
||||
assert!(matches!(&out[0], Out::Error(_)), "{out:?}");
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_old_default_prices_become_the_new_ones_and_edited_ones_stay() {
|
||||
let mut config = Config {
|
||||
items: OLD_PRICES
|
||||
.iter()
|
||||
.map(|(id, buy, sell)| {
|
||||
(
|
||||
format!("minecraft:{id}"),
|
||||
Price {
|
||||
buy: *buy,
|
||||
sell: *sell,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
..Config::default()
|
||||
};
|
||||
let mut edited = config.clone();
|
||||
assert!(migrate(&mut config));
|
||||
assert_eq!(config.items, Config::default().items);
|
||||
assert!(!migrate(&mut config), "once");
|
||||
edited.items.get_mut("minecraft:diamond").unwrap().sell = 90;
|
||||
let before = edited.clone();
|
||||
assert!(!migrate(&mut edited));
|
||||
assert_eq!(edited, before);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
// Places: the server's spawn (`/spawn`, `/setspawn`), each player's homes
|
||||
// (`/home`, `/sethome`, `/delhome`, `/homes`), warps (`/warp`, `/warps`,
|
||||
// `/setwarp`, `/delwarp`), teleport requests between players (`/tpa`,
|
||||
// `/tpaccept`, `/tpdeny`), `/back` (where the player died, or was before
|
||||
// their last teleport), `/playtime` and `/rules`. A teleport waits `warmup_seconds`,
|
||||
// and a player who moves or is hurt meanwhile stays where they are; the glue
|
||||
// moves them with a vanilla `tp` run as the server. The number of homes grows
|
||||
// with the time a player has played here, counted by the mod. Everything is
|
||||
// in `places.json`, written atomically. No game types. Added to the game as
|
||||
// `game::smp_places`; also a module of this crate, for its tests.
|
||||
|
||||
use super::smp_economy::{Out, Player};
|
||||
use super::smp_store;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// `config.json`'s `places` section.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
/// How long a player stands still before a teleport (0: at once).
|
||||
pub warmup_seconds: i64,
|
||||
/// How long a `/tpa` request waits for its answer.
|
||||
pub request_seconds: i64,
|
||||
/// Between two teleports of one player.
|
||||
pub cooldown_seconds: i64,
|
||||
/// Homes a player may set, by hours played here: `[hours, homes]`, the
|
||||
/// last line reached counts.
|
||||
pub homes: Vec<[i64; 2]>,
|
||||
/// `/rules`, a line each.
|
||||
pub rules: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
warmup_seconds: 3,
|
||||
request_seconds: 60,
|
||||
cooldown_seconds: 10,
|
||||
homes: vec![[0, 1], [5, 2], [20, 3], [100, 5]],
|
||||
rules: vec![
|
||||
"Be respectful to everyone.".into(),
|
||||
"No griefing, no stealing, no cheats.".into(),
|
||||
"The currency is earned in game only: never bought or sold for real money.".into(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A place in a level.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Place {
|
||||
/// `minecraft:overworld`, `minecraft:the_nether`, `minecraft:the_end`.
|
||||
pub dimension: String,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub z: f64,
|
||||
}
|
||||
|
||||
/// A player on the server, as the places see them.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Body {
|
||||
pub uuid: u128,
|
||||
pub name: String,
|
||||
pub at: Place,
|
||||
pub health: f32,
|
||||
}
|
||||
|
||||
/// `places.json`.
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct Data {
|
||||
spawn: Option<Place>,
|
||||
warps: BTreeMap<String, Place>,
|
||||
/// Homes by player UUID, then by name.
|
||||
homes: BTreeMap<String, BTreeMap<String, Place>>,
|
||||
/// Seconds played here, by player UUID.
|
||||
played: BTreeMap<String, i64>,
|
||||
}
|
||||
|
||||
/// Where a teleport goes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum To {
|
||||
/// A place, and what it is (`home "base"`).
|
||||
Place(Place, String),
|
||||
/// A player, wherever they are when the warmup ends.
|
||||
Player(u128, String),
|
||||
}
|
||||
|
||||
/// A teleport waiting for its warmup.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Pending {
|
||||
pub uuid: u128,
|
||||
to: To,
|
||||
from: Place,
|
||||
health: f32,
|
||||
due: i64,
|
||||
}
|
||||
|
||||
/// A `/tpa` request: `from` asks to go to `to`.
|
||||
struct Request {
|
||||
from: u128,
|
||||
from_name: String,
|
||||
to: u128,
|
||||
at: i64,
|
||||
}
|
||||
|
||||
/// A teleport to make now: the player's name and where.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Teleport {
|
||||
pub uuid: u128,
|
||||
pub name: String,
|
||||
pub to: Place,
|
||||
/// What the player is told once there.
|
||||
pub done: String,
|
||||
}
|
||||
|
||||
/// The places' commands.
|
||||
pub const COMMANDS: [&str; 16] = [
|
||||
"spawn", "setspawn", "home", "sethome", "delhome", "homes", "warp", "warps", "setwarp",
|
||||
"delwarp", "tpa", "tpaccept", "tpdeny", "rules", "back", "playtime",
|
||||
];
|
||||
|
||||
pub struct Places {
|
||||
pub config: Config,
|
||||
path: PathBuf,
|
||||
data: Data,
|
||||
/// Homes, warps or the spawn changed: written at the next save.
|
||||
changed: bool,
|
||||
/// Time played changed: written once a minute.
|
||||
played_at: Option<i64>,
|
||||
pub pending: Vec<Pending>,
|
||||
requests: Vec<Request>,
|
||||
last: HashMap<u128, i64>,
|
||||
/// Where `/back` goes: the place and whether they died there. Not kept
|
||||
/// over a restart.
|
||||
back: HashMap<u128, (Place, bool)>,
|
||||
/// The players seen dead (health 0) and not respawned yet.
|
||||
dead: std::collections::HashSet<u128>,
|
||||
}
|
||||
|
||||
/// `2 h 05 min`, `12 min`.
|
||||
fn duration(seconds: i64) -> String {
|
||||
let (h, m) = (seconds / 3600, seconds % 3600 / 60);
|
||||
if h > 0 {
|
||||
format!("{h} h {m:02} min")
|
||||
} else {
|
||||
format!("{m} min")
|
||||
}
|
||||
}
|
||||
|
||||
/// A home's or a warp's name: 1 to 16 of `a-z 0-9 _ -`.
|
||||
fn name_ok(name: &str) -> bool {
|
||||
(1..=16).contains(&name.len())
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "_-".contains(c))
|
||||
}
|
||||
|
||||
fn moved(a: &Place, b: &Place) -> bool {
|
||||
let d = (a.x - b.x).powi(2) + (a.y - b.y).powi(2) + (a.z - b.z).powi(2);
|
||||
a.dimension != b.dimension || d > 0.25
|
||||
}
|
||||
|
||||
impl Places {
|
||||
/// `places.json` in `dir`; an error when it exists but can't be read
|
||||
/// (never replaced by an empty one).
|
||||
pub fn open(dir: &Path, config: Config) -> Result<Self, String> {
|
||||
let path = dir.join("places.json");
|
||||
let data = smp_store::read_json(&path)?;
|
||||
Ok(Self {
|
||||
config,
|
||||
path,
|
||||
data,
|
||||
changed: false,
|
||||
played_at: None,
|
||||
pending: Vec::new(),
|
||||
requests: Vec::new(),
|
||||
last: HashMap::new(),
|
||||
back: HashMap::new(),
|
||||
dead: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes `places.json` when homes, warps or the spawn changed, or once
|
||||
/// a minute when only the time played did.
|
||||
pub fn save(&mut self, now: i64) -> Result<(), String> {
|
||||
let played = self.played_at.is_some_and(|t| now - t >= 60_000);
|
||||
if !self.changed && !played {
|
||||
return Ok(());
|
||||
}
|
||||
smp_store::write_json(&self.path, &self.data)?;
|
||||
self.changed = false;
|
||||
self.played_at = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Counts a second played for each of these players.
|
||||
pub fn played(&mut self, uuids: &[u128], now: i64) {
|
||||
for &u in uuids {
|
||||
*self
|
||||
.data
|
||||
.played
|
||||
.entry(smp_store::uuid_string(u))
|
||||
.or_default() += 1;
|
||||
}
|
||||
if !uuids.is_empty() {
|
||||
self.played_at.get_or_insert(now);
|
||||
}
|
||||
}
|
||||
|
||||
/// The seconds a player has played here.
|
||||
pub fn played_seconds(&self, uuid: u128) -> i64 {
|
||||
self.data
|
||||
.played
|
||||
.get(&smp_store::uuid_string(uuid))
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// How many homes a player may have now.
|
||||
pub fn home_limit(&self, uuid: u128) -> usize {
|
||||
let hours = self
|
||||
.data
|
||||
.played
|
||||
.get(&smp_store::uuid_string(uuid))
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
/ 3600;
|
||||
self.config
|
||||
.homes
|
||||
.iter()
|
||||
.filter(|[h, _]| hours >= *h)
|
||||
.map(|[_, n]| (*n).max(0) as usize)
|
||||
.next_back()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Starts a teleport's warmup, or says why not.
|
||||
fn start(&mut self, me: &Body, to: To, now: i64) -> Vec<Out> {
|
||||
if let Some(&t) = self.last.get(&me.uuid) {
|
||||
let wait = t + self.config.cooldown_seconds * 1000 - now;
|
||||
if wait > 0 {
|
||||
return vec![Out::Error(format!(
|
||||
"Wait {} s before your next teleport.",
|
||||
(wait + 999) / 1000
|
||||
))];
|
||||
}
|
||||
}
|
||||
self.pending.retain(|p| p.uuid != me.uuid);
|
||||
self.pending.push(Pending {
|
||||
uuid: me.uuid,
|
||||
to,
|
||||
from: me.at.clone(),
|
||||
health: me.health,
|
||||
due: now + self.config.warmup_seconds.max(0) * 1000,
|
||||
});
|
||||
if self.config.warmup_seconds > 0 {
|
||||
vec![Out::Reply(format!(
|
||||
"Teleporting in {} s: don't move.",
|
||||
self.config.warmup_seconds
|
||||
))]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// The places' commands; None for another command. `bodies` are the
|
||||
/// players on, `me` among them.
|
||||
pub fn command(
|
||||
&mut self,
|
||||
me: &Player,
|
||||
bodies: &[Body],
|
||||
now: i64,
|
||||
line: &str,
|
||||
) -> Option<Vec<Out>> {
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
let command = *words.first()?;
|
||||
if !COMMANDS.contains(&command) {
|
||||
return None;
|
||||
}
|
||||
let Some(body) = bodies.iter().find(|b| b.uuid == me.uuid) else {
|
||||
return Some(vec![Out::Error("You are not in the world.".into())]);
|
||||
};
|
||||
let key = smp_store::uuid_string(me.uuid);
|
||||
let named = |n: &str| bodies.iter().find(|b| b.name.eq_ignore_ascii_case(n));
|
||||
Some(match (command, &words[1..]) {
|
||||
("back", []) => match self.back.get(&me.uuid).cloned() {
|
||||
Some((p, died)) => {
|
||||
let what = if died {
|
||||
"where you died"
|
||||
} else {
|
||||
"where you were"
|
||||
};
|
||||
self.start(body, To::Place(p, what.into()), now)
|
||||
}
|
||||
None => vec![Out::Error(
|
||||
"Nowhere to go back to yet: /back follows a death or a teleport.".into(),
|
||||
)],
|
||||
},
|
||||
("playtime", []) => vec![Out::Reply(format!(
|
||||
"You have played here for {}.",
|
||||
duration(self.played_seconds(me.uuid))
|
||||
))],
|
||||
("playtime", [name]) => match named(name) {
|
||||
Some(b) => vec![Out::Reply(format!(
|
||||
"{} has played here for {}.",
|
||||
b.name,
|
||||
duration(self.played_seconds(b.uuid))
|
||||
))],
|
||||
None => vec![Out::Error(format!("{name} isn't on."))],
|
||||
},
|
||||
("rules", []) => {
|
||||
let mut out = vec![Out::Reply("Rules of this server:".into())];
|
||||
for (i, r) in self.config.rules.iter().enumerate() {
|
||||
out.push(Out::Reply(format!("{}. {r}", i + 1)));
|
||||
}
|
||||
out
|
||||
}
|
||||
("spawn", []) => match self.data.spawn.clone() {
|
||||
Some(p) => self.start(body, To::Place(p, "the spawn".into()), now),
|
||||
None => vec![Out::Error(
|
||||
"No spawn yet: an operator sets it with /setspawn.".into(),
|
||||
)],
|
||||
},
|
||||
("setspawn", []) if me.level >= 3 => {
|
||||
self.data.spawn = Some(body.at.clone());
|
||||
self.changed = true;
|
||||
vec![Out::Reply("The spawn is here now.".into())]
|
||||
}
|
||||
("homes", []) => {
|
||||
let homes = self.data.homes.get(&key);
|
||||
let names: Vec<&str> = homes
|
||||
.into_iter()
|
||||
.flat_map(|h| h.keys())
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
vec![Out::Reply(format!(
|
||||
"Your homes ({} of {}): {}",
|
||||
names.len(),
|
||||
self.home_limit(me.uuid),
|
||||
if names.is_empty() {
|
||||
"none yet, /sethome [name]".into()
|
||||
} else {
|
||||
names.join(", ")
|
||||
}
|
||||
))]
|
||||
}
|
||||
("home", rest) | ("sethome", rest) | ("delhome", rest) if rest.len() <= 1 => {
|
||||
let name = rest
|
||||
.first()
|
||||
.map_or("home".to_owned(), |n| n.to_ascii_lowercase());
|
||||
if !name_ok(&name) {
|
||||
return Some(vec![Out::Error(
|
||||
"A home's name: up to 16 of a-z, 0-9, _ and -.".into(),
|
||||
)]);
|
||||
}
|
||||
let homes = self.data.homes.get(&key);
|
||||
let has = homes.is_some_and(|h| h.contains_key(&name));
|
||||
match command {
|
||||
"home" => match homes.and_then(|h| h.get(&name)).cloned() {
|
||||
Some(p) => self.start(body, To::Place(p, format!("home \"{name}\"")), now),
|
||||
None => vec![Out::Error(format!(
|
||||
"No home \"{name}\": /homes lists yours."
|
||||
))],
|
||||
},
|
||||
"sethome" => {
|
||||
let count = homes.map_or(0, |h| h.len());
|
||||
let limit = self.home_limit(me.uuid);
|
||||
if !has && count >= limit {
|
||||
vec![Out::Error(format!(
|
||||
"You have {count} of {limit} homes: play longer for more, or move one (/sethome <name> over it)."
|
||||
))]
|
||||
} else {
|
||||
self.data
|
||||
.homes
|
||||
.entry(key)
|
||||
.or_default()
|
||||
.insert(name.clone(), body.at.clone());
|
||||
self.changed = true;
|
||||
vec![Out::Reply(format!("Home \"{name}\" set here."))]
|
||||
}
|
||||
}
|
||||
_ if has => {
|
||||
if let Some(h) = self.data.homes.get_mut(&key) {
|
||||
h.remove(&name);
|
||||
if h.is_empty() {
|
||||
self.data.homes.remove(&key);
|
||||
}
|
||||
}
|
||||
self.changed = true;
|
||||
vec![Out::Reply(format!("Home \"{name}\" deleted."))]
|
||||
}
|
||||
_ => vec![Out::Error(format!("No home \"{name}\"."))],
|
||||
}
|
||||
}
|
||||
("warps", []) => {
|
||||
let names: Vec<&str> = self.data.warps.keys().map(String::as_str).collect();
|
||||
vec![Out::Reply(if names.is_empty() {
|
||||
"No warps yet.".into()
|
||||
} else {
|
||||
format!("Warps: {}", names.join(", "))
|
||||
})]
|
||||
}
|
||||
("warp", [name]) => match self.data.warps.get(&name.to_ascii_lowercase()).cloned() {
|
||||
Some(p) => self.start(
|
||||
body,
|
||||
To::Place(p, format!("warp \"{}\"", name.to_ascii_lowercase())),
|
||||
now,
|
||||
),
|
||||
None => vec![Out::Error(format!(
|
||||
"No warp \"{name}\": /warps lists them."
|
||||
))],
|
||||
},
|
||||
("setwarp", [name]) if me.level >= 3 => {
|
||||
let name = name.to_ascii_lowercase();
|
||||
if !name_ok(&name) {
|
||||
return Some(vec![Out::Error(
|
||||
"A warp's name: up to 16 of a-z, 0-9, _ and -.".into(),
|
||||
)]);
|
||||
}
|
||||
self.data.warps.insert(name.clone(), body.at.clone());
|
||||
self.changed = true;
|
||||
vec![Out::Reply(format!("Warp \"{name}\" set here."))]
|
||||
}
|
||||
("delwarp", [name]) if me.level >= 3 => {
|
||||
if self.data.warps.remove(&name.to_ascii_lowercase()).is_some() {
|
||||
self.changed = true;
|
||||
vec![Out::Reply(format!("Warp \"{name}\" deleted."))]
|
||||
} else {
|
||||
vec![Out::Error(format!("No warp \"{name}\"."))]
|
||||
}
|
||||
}
|
||||
("tpa", [name]) => match named(name) {
|
||||
None => vec![Out::Error(format!("{name} is not on."))],
|
||||
Some(t) if t.uuid == me.uuid => vec![Out::Error("That's you.".into())],
|
||||
Some(t) => {
|
||||
self.requests.retain(|r| r.from != me.uuid);
|
||||
self.requests.push(Request {
|
||||
from: me.uuid,
|
||||
from_name: me.name.clone(),
|
||||
to: t.uuid,
|
||||
at: now,
|
||||
});
|
||||
vec![
|
||||
Out::Reply(format!(
|
||||
"Asked {} to teleport to them ({} s to answer).",
|
||||
t.name, self.config.request_seconds
|
||||
)),
|
||||
Out::Tell(
|
||||
t.uuid,
|
||||
format!(
|
||||
"{} asks to teleport to you: /tpaccept {0} or /tpdeny {0}.",
|
||||
me.name
|
||||
),
|
||||
),
|
||||
]
|
||||
}
|
||||
},
|
||||
("tpaccept", rest) | ("tpdeny", rest) if rest.len() <= 1 => {
|
||||
let found = self.requests.iter().rposition(|r| {
|
||||
r.to == me.uuid
|
||||
&& rest
|
||||
.first()
|
||||
.is_none_or(|n| r.from_name.eq_ignore_ascii_case(n))
|
||||
});
|
||||
let Some(i) = found else {
|
||||
return Some(vec![Out::Error("No teleport request to answer.".into())]);
|
||||
};
|
||||
let r = self.requests.remove(i);
|
||||
if command == "tpdeny" {
|
||||
return Some(vec![
|
||||
Out::Reply(format!("You turned down {}'s request.", r.from_name)),
|
||||
Out::Tell(r.from, format!("{} turned down your request.", me.name)),
|
||||
]);
|
||||
}
|
||||
let Some(from) = bodies.iter().find(|b| b.uuid == r.from) else {
|
||||
return Some(vec![Out::Error(format!("{} is gone.", r.from_name))]);
|
||||
};
|
||||
let mut out = vec![Out::Reply(format!("{} is on the way.", r.from_name))];
|
||||
let start = self.start(from, To::Player(me.uuid, me.name.clone()), now);
|
||||
// The warmup's lines are for the one who moves.
|
||||
out.extend(start.into_iter().map(|o| match o {
|
||||
Out::Reply(s) | Out::Error(s) => Out::Tell(r.from, s),
|
||||
o => o,
|
||||
}));
|
||||
out
|
||||
}
|
||||
_ => vec![Out::Error(
|
||||
match command {
|
||||
"home" | "sethome" | "delhome" => {
|
||||
"Usage: /home, /sethome, /delhome [name]; /homes"
|
||||
}
|
||||
"warp" | "setwarp" | "delwarp" => "Usage: /warp <name>; /warps",
|
||||
"tpa" => "Usage: /tpa <player>",
|
||||
"tpaccept" | "tpdeny" => "Usage: /tpaccept [player], /tpdeny [player]",
|
||||
_ => "You may not use this command.",
|
||||
}
|
||||
.into(),
|
||||
)],
|
||||
})
|
||||
}
|
||||
|
||||
/// Each tick: warmups that ended become teleports, those of players who
|
||||
/// moved, were hurt or left are called off; requests nobody answered
|
||||
/// expire.
|
||||
pub fn tick(&mut self, bodies: &[Body], now: i64) -> (Vec<Out>, Vec<Teleport>) {
|
||||
let mut out = Vec::new();
|
||||
let mut go = Vec::new();
|
||||
let wait = self.config.request_seconds * 1000;
|
||||
self.requests.retain(|r| {
|
||||
let keep = now - r.at < wait;
|
||||
if !keep {
|
||||
out.push(Out::Tell(r.from, "Your teleport request expired.".into()));
|
||||
}
|
||||
keep
|
||||
});
|
||||
// A death, seen once a second: /back goes there.
|
||||
for b in bodies {
|
||||
if b.health <= 0.0 {
|
||||
if self.dead.insert(b.uuid) {
|
||||
self.back.insert(b.uuid, (b.at.clone(), true));
|
||||
}
|
||||
} else {
|
||||
self.dead.remove(&b.uuid);
|
||||
}
|
||||
}
|
||||
for p in std::mem::take(&mut self.pending) {
|
||||
let Some(me) = bodies.iter().find(|b| b.uuid == p.uuid) else {
|
||||
continue;
|
||||
};
|
||||
if moved(&p.from, &me.at) {
|
||||
out.push(Out::Tell(p.uuid, "Teleport called off: you moved.".into()));
|
||||
} else if me.health < p.health || me.health <= 0.0 {
|
||||
out.push(Out::Tell(
|
||||
p.uuid,
|
||||
"Teleport called off: you were hurt.".into(),
|
||||
));
|
||||
} else if now < p.due {
|
||||
self.pending.push(p);
|
||||
} else {
|
||||
let (to, what) = match &p.to {
|
||||
To::Place(place, what) => (Some(place.clone()), what.clone()),
|
||||
To::Player(u, name) => (
|
||||
bodies.iter().find(|b| b.uuid == *u).map(|b| b.at.clone()),
|
||||
name.clone(),
|
||||
),
|
||||
};
|
||||
match to {
|
||||
Some(to) => {
|
||||
self.last.insert(p.uuid, now);
|
||||
self.back.insert(p.uuid, (p.from.clone(), false));
|
||||
go.push(Teleport {
|
||||
uuid: p.uuid,
|
||||
name: me.name.clone(),
|
||||
to,
|
||||
done: format!("Teleported to {what}."),
|
||||
});
|
||||
}
|
||||
None => out.push(Out::Tell(
|
||||
p.uuid,
|
||||
format!("Teleport called off: {what} is gone."),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
(out, go)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
const A: u128 = 1;
|
||||
const B: u128 = 2;
|
||||
|
||||
fn dir() -> PathBuf {
|
||||
static N: AtomicU32 = AtomicU32::new(0);
|
||||
let n = N.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!("smp-places-{}-{n}", std::process::id()))
|
||||
}
|
||||
fn place(x: f64) -> Place {
|
||||
Place {
|
||||
dimension: "minecraft:overworld".into(),
|
||||
x,
|
||||
y: 64.0,
|
||||
z: 0.0,
|
||||
}
|
||||
}
|
||||
fn body(uuid: u128, x: f64) -> Body {
|
||||
Body {
|
||||
uuid,
|
||||
name: if uuid == A { "Alex" } else { "Steve" }.into(),
|
||||
at: place(x),
|
||||
health: 20.0,
|
||||
}
|
||||
}
|
||||
fn player(uuid: u128, level: u8) -> Player {
|
||||
Player {
|
||||
uuid,
|
||||
name: if uuid == A { "Alex" } else { "Steve" }.into(),
|
||||
level,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_home_is_reached_after_the_warmup_unless_the_player_moves() {
|
||||
let d = dir();
|
||||
let mut p = Places::open(&d, Config::default()).unwrap();
|
||||
let here = [body(A, 10.0)];
|
||||
let out = p.command(&player(A, 0), &here, 0, "sethome base").unwrap();
|
||||
assert_eq!(out, vec![Out::Reply("Home \"base\" set here.".into())]);
|
||||
let there = [body(A, 500.0)];
|
||||
p.command(&player(A, 0), &there, 0, "home base").unwrap();
|
||||
assert!(p.tick(&there, 2_999).1.is_empty(), "the warmup");
|
||||
let (_, go) = p.tick(&there, 3_000);
|
||||
assert_eq!(go[0].to, place(10.0));
|
||||
assert_eq!(go[0].name, "Alex");
|
||||
// The cooldown, then a move calls the next one off.
|
||||
let out = p
|
||||
.command(&player(A, 0), &there, 5_000, "home base")
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Error(s) if s.contains("Wait")),
|
||||
"{out:?}"
|
||||
);
|
||||
p.command(&player(A, 0), &there, 13_000, "home base")
|
||||
.unwrap();
|
||||
let (out, go) = p.tick(&[body(A, 501.0)], 14_000);
|
||||
assert!(go.is_empty());
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![Out::Tell(A, "Teleport called off: you moved.".into())]
|
||||
);
|
||||
// Saved and read back.
|
||||
p.save(0).unwrap();
|
||||
let again = Places::open(&d, Config::default()).unwrap();
|
||||
assert_eq!(
|
||||
again.data.homes[&smp_store::uuid_string(A)]["base"],
|
||||
place(10.0)
|
||||
);
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn back_goes_where_the_player_died_or_was_before_a_teleport() {
|
||||
let d = dir();
|
||||
let mut p = Places::open(&d, Config::default()).unwrap();
|
||||
let out = p
|
||||
.command(&player(A, 0), &[body(A, 0.0)], 0, "back")
|
||||
.unwrap();
|
||||
assert!(matches!(&out[0], Out::Error(s) if s.contains("Nowhere")));
|
||||
// Dead at x 40: seen by the next tick, once.
|
||||
let mut dead = body(A, 40.0);
|
||||
dead.health = 0.0;
|
||||
p.tick(&[dead.clone()], 1_000);
|
||||
dead.at = place(41.0);
|
||||
p.tick(&[dead], 2_000);
|
||||
let alive = [body(A, 0.0)];
|
||||
p.tick(&alive, 3_000);
|
||||
p.command(&player(A, 0), &alive, 3_000, "back").unwrap();
|
||||
let (_, go) = p.tick(&alive, 6_000);
|
||||
assert_eq!(go[0].to, place(40.0));
|
||||
assert_eq!(go[0].done, "Teleported to where you died.");
|
||||
// Then /back goes to where that teleport started.
|
||||
let there = [body(A, 40.0)];
|
||||
p.command(&player(A, 0), &there, 20_000, "back").unwrap();
|
||||
let (_, go) = p.tick(&there, 23_000);
|
||||
assert_eq!(go[0].to, place(0.0));
|
||||
assert_eq!(go[0].done, "Teleported to where you were.");
|
||||
p.played(&[A], 0);
|
||||
let out = p.command(&player(A, 0), &there, 0, "playtime").unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![Out::Reply("You have played here for 0 min.".into())]
|
||||
);
|
||||
assert_eq!(duration(7_500), "2 h 05 min");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn homes_grow_with_time_played() {
|
||||
let d = dir();
|
||||
let mut p = Places::open(&d, Config::default()).unwrap();
|
||||
let here = [body(A, 0.0)];
|
||||
p.command(&player(A, 0), &here, 0, "sethome").unwrap();
|
||||
let out = p.command(&player(A, 0), &here, 0, "sethome two").unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Error(s) if s.contains("1 of 1")),
|
||||
"{out:?}"
|
||||
);
|
||||
// Moving a home is always allowed.
|
||||
let out = p.command(&player(A, 0), &here, 0, "sethome home").unwrap();
|
||||
assert!(matches!(&out[0], Out::Reply(_)));
|
||||
p.save(0).unwrap();
|
||||
for _ in 0..5 * 3600 {
|
||||
p.played(&[A], 0);
|
||||
}
|
||||
assert_eq!(p.home_limit(A), 2);
|
||||
// Time played is written once a minute.
|
||||
p.save(59_999).unwrap();
|
||||
assert_eq!(
|
||||
Places::open(&d, Config::default()).unwrap().home_limit(A),
|
||||
1
|
||||
);
|
||||
p.save(60_000).unwrap();
|
||||
let out = p.command(&player(A, 0), &here, 0, "sethome two").unwrap();
|
||||
assert!(matches!(&out[0], Out::Reply(_)), "{out:?}");
|
||||
let again = Places::open(&d, Config::default()).unwrap();
|
||||
assert_eq!(again.home_limit(A), 2);
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_moves_the_asker_to_the_one_who_accepts() {
|
||||
let d = dir();
|
||||
let mut p = Places::open(&d, Config::default()).unwrap();
|
||||
let both = [body(A, 0.0), body(B, 100.0)];
|
||||
let out = p.command(&player(A, 0), &both, 0, "tpa steve").unwrap();
|
||||
assert!(
|
||||
matches!(&out[1], Out::Tell(B, s) if s.contains("/tpaccept Alex")),
|
||||
"{out:?}"
|
||||
);
|
||||
let out = p.command(&player(B, 0), &both, 1_000, "tpaccept").unwrap();
|
||||
assert_eq!(
|
||||
out[1],
|
||||
Out::Tell(A, "Teleporting in 3 s: don't move.".into())
|
||||
);
|
||||
// Steve walked on meanwhile: Alex lands where Steve is then.
|
||||
let later = [body(A, 0.0), body(B, 120.0)];
|
||||
let (_, go) = p.tick(&later, 4_000);
|
||||
assert_eq!((go[0].uuid, go[0].to.x), (A, 120.0));
|
||||
// A hurt player stays; an unanswered request expires.
|
||||
p.command(&player(A, 0), &later, 20_000, "tpa steve")
|
||||
.unwrap();
|
||||
p.command(&player(B, 0), &later, 20_000, "tpaccept alex")
|
||||
.unwrap();
|
||||
let mut hurt = later.clone();
|
||||
hurt[0].health = 15.0;
|
||||
let (out, go) = p.tick(&hurt, 21_000);
|
||||
assert!(go.is_empty());
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![Out::Tell(A, "Teleport called off: you were hurt.".into())]
|
||||
);
|
||||
p.command(&player(A, 0), &later, 30_000, "tpa steve")
|
||||
.unwrap();
|
||||
let (out, _) = p.tick(&later, 90_000);
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![Out::Tell(A, "Your teleport request expired.".into())]
|
||||
);
|
||||
let out = p.command(&player(B, 0), &later, 90_000, "tpdeny").unwrap();
|
||||
assert!(matches!(&out[0], Out::Error(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_and_warps_are_set_by_operators() {
|
||||
let d = dir();
|
||||
let mut p = Places::open(&d, Config::default()).unwrap();
|
||||
let here = [body(A, 7.0)];
|
||||
let out = p.command(&player(A, 0), &here, 0, "spawn").unwrap();
|
||||
assert!(matches!(&out[0], Out::Error(s) if s.contains("/setspawn")));
|
||||
let out = p.command(&player(A, 0), &here, 0, "setspawn").unwrap();
|
||||
assert!(matches!(&out[0], Out::Error(_)), "level 0");
|
||||
p.command(&player(A, 3), &here, 0, "setspawn").unwrap();
|
||||
p.command(&player(A, 3), &here, 0, "setwarp Shop").unwrap();
|
||||
let out = p.command(&player(A, 0), &here, 0, "warps").unwrap();
|
||||
assert_eq!(out, vec![Out::Reply("Warps: shop".into())]);
|
||||
p.command(&player(A, 0), &[body(A, 50.0)], 0, "spawn")
|
||||
.unwrap();
|
||||
let (_, go) = p.tick(&[body(A, 50.0)], 3_000);
|
||||
assert_eq!(
|
||||
(go[0].to.x, go[0].done.as_str()),
|
||||
(7.0, "Teleported to the spawn.")
|
||||
);
|
||||
let out = p.command(&player(A, 0), &here, 0, "rules").unwrap();
|
||||
assert_eq!(out.len(), 4);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
// Who is on, and how: a welcome for a player's first join (a line to
|
||||
// everyone, the welcome and the rules to the newcomer), a reward once a day
|
||||
// for being on, AFK players (no move, no turn and no command for
|
||||
// `afk_minutes`, or `/afk`) left out of the sleep count, and a limit on how
|
||||
// fast one player runs the SMP's commands. Looked at once a second, never
|
||||
// per tick. No game types. Added to the game as `game::smp_presence`; also
|
||||
// a module of this crate, for its tests.
|
||||
|
||||
use super::smp_economy::{Out, Player};
|
||||
use super::smp_store;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const COMMANDS: [&str; 1] = ["afk"];
|
||||
|
||||
/// `config.json`'s `presence` section.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
/// The line everyone sees when a player joins for the first time
|
||||
/// (`{player}` filled in); empty: none.
|
||||
pub first_join: String,
|
||||
/// What the newcomer reads, before the rules.
|
||||
pub welcome: Vec<String>,
|
||||
/// Paid once a day (UTC) to each player who is on; 0: none.
|
||||
pub daily_reward: i64,
|
||||
/// Minutes without a move, a turn or a command before a player is AFK;
|
||||
/// 0: only `/afk`.
|
||||
pub afk_minutes: i64,
|
||||
/// Whether everyone hears that a player is AFK (else only them).
|
||||
pub afk_announce: bool,
|
||||
/// At most this many SMP commands in `spam_seconds` a player (operators
|
||||
/// of level 3 aside); 0: no limit.
|
||||
pub spam_commands: usize,
|
||||
pub spam_seconds: i64,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
first_join: "{player} joined for the first time: welcome!".into(),
|
||||
welcome: vec![
|
||||
"Welcome to the server, {player}!".into(),
|
||||
"Set a home with /sethome, claim your land with /claim, see the richest with /baltop.".into(),
|
||||
],
|
||||
daily_reward: 50,
|
||||
afk_minutes: 5,
|
||||
afk_announce: false,
|
||||
spam_commands: 8,
|
||||
spam_seconds: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `presence.json`.
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct Data {
|
||||
/// The players who have joined, by UUID.
|
||||
known: BTreeSet<String>,
|
||||
/// The last day (UTC, `yyyy-mm-dd`) each player got the daily reward.
|
||||
rewarded: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// A player on, as the presence sees them: where they are and look.
|
||||
pub struct Seen {
|
||||
pub uuid: u128,
|
||||
pub name: String,
|
||||
/// x, y, z, yaw, pitch.
|
||||
pub pose: [f64; 5],
|
||||
}
|
||||
|
||||
/// What the game does for the presence.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Act {
|
||||
/// Pays the daily reward (logged as `daily_reward`).
|
||||
Reward(u128),
|
||||
/// Leaves the player out of the sleep count, or counts them again.
|
||||
Sleep(u128, bool),
|
||||
}
|
||||
|
||||
pub struct Presence {
|
||||
pub config: Config,
|
||||
path: PathBuf,
|
||||
data: Data,
|
||||
dirty: bool,
|
||||
/// Each player on: their pose a second ago, and when they last moved,
|
||||
/// turned or ran a command (ms).
|
||||
on: HashMap<u128, ([f64; 5], i64)>,
|
||||
afk: HashSet<u128>,
|
||||
/// Each player's last commands (ms), for the limit.
|
||||
recent: HashMap<u128, VecDeque<i64>>,
|
||||
}
|
||||
|
||||
impl Presence {
|
||||
/// Opens `presence.json`. Without it, the players `known` already (the
|
||||
/// economy's accounts) aren't welcomed as newcomers.
|
||||
pub fn open(
|
||||
dir: &Path,
|
||||
config: Config,
|
||||
known: impl IntoIterator<Item = u128>,
|
||||
) -> Result<Self, String> {
|
||||
let path = dir.join("presence.json");
|
||||
let fresh = !path.exists();
|
||||
let mut data: Data = smp_store::read_json(&path)?;
|
||||
if fresh {
|
||||
data.known = known.into_iter().map(smp_store::uuid_string).collect();
|
||||
}
|
||||
Ok(Self {
|
||||
config,
|
||||
path,
|
||||
data,
|
||||
dirty: fresh,
|
||||
on: HashMap::new(),
|
||||
afk: HashSet::new(),
|
||||
recent: HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save(&mut self) -> Result<(), String> {
|
||||
if self.dirty {
|
||||
smp_store::write_json(&self.path, &self.data)?;
|
||||
self.dirty = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Once a second: who joined, who left, who moved; `today` is the UTC
|
||||
/// date, `rules` `/rules`' lines.
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
players: &[Seen],
|
||||
today: &str,
|
||||
now: i64,
|
||||
rules: &[String],
|
||||
) -> (Vec<Out>, Vec<Act>) {
|
||||
let (mut out, mut acts) = (Vec::new(), Vec::new());
|
||||
let gone: Vec<u128> = self
|
||||
.on
|
||||
.keys()
|
||||
.filter(|u| !players.iter().any(|p| p.uuid == **u))
|
||||
.copied()
|
||||
.collect();
|
||||
for uuid in gone {
|
||||
self.on.remove(&uuid);
|
||||
self.recent.remove(&uuid);
|
||||
if self.afk.remove(&uuid) {
|
||||
acts.push(Act::Sleep(uuid, false));
|
||||
}
|
||||
}
|
||||
for p in players {
|
||||
let key = smp_store::uuid_string(p.uuid);
|
||||
let Some((pose, active)) = self.on.get_mut(&p.uuid) else {
|
||||
// Joined.
|
||||
self.on.insert(p.uuid, (p.pose, now));
|
||||
if self.data.known.insert(key.clone()) {
|
||||
self.dirty = true;
|
||||
let fill = |l: &str| l.replace("{player}", &p.name);
|
||||
if !self.config.first_join.is_empty() {
|
||||
out.push(Out::All(fill(&self.config.first_join)));
|
||||
}
|
||||
out.extend(
|
||||
self.config
|
||||
.welcome
|
||||
.iter()
|
||||
.map(|l| Out::Tell(p.uuid, fill(l))),
|
||||
);
|
||||
if !rules.is_empty() {
|
||||
out.push(Out::Tell(p.uuid, "The rules:".into()));
|
||||
out.extend(
|
||||
rules
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| Out::Tell(p.uuid, format!("{}. {r}", i + 1))),
|
||||
);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let moved = pose.iter().zip(&p.pose).enumerate().any(|(i, (a, b))| {
|
||||
// A hundredth of a block, half a degree.
|
||||
(a - b).abs() > if i < 3 { 0.01 } else { 0.5 }
|
||||
});
|
||||
*pose = p.pose;
|
||||
if moved {
|
||||
*active = now;
|
||||
if self.afk.remove(&p.uuid) {
|
||||
out.extend(self.back(p.uuid, &p.name, &mut acts));
|
||||
}
|
||||
} else if self.config.afk_minutes > 0
|
||||
&& now - *active >= self.config.afk_minutes * 60_000
|
||||
&& self.afk.insert(p.uuid)
|
||||
{
|
||||
out.push(self.away(p.uuid, &p.name, &mut acts));
|
||||
}
|
||||
}
|
||||
// Each player's first second on today, or the day turning while on.
|
||||
if self.config.daily_reward > 0 {
|
||||
for p in players {
|
||||
let key = smp_store::uuid_string(p.uuid);
|
||||
if self.data.rewarded.get(&key).map(String::as_str) != Some(today) {
|
||||
self.data.rewarded.insert(key, today.to_owned());
|
||||
self.dirty = true;
|
||||
acts.push(Act::Reward(p.uuid));
|
||||
}
|
||||
}
|
||||
}
|
||||
(out, acts)
|
||||
}
|
||||
|
||||
fn away(&mut self, uuid: u128, name: &str, acts: &mut Vec<Act>) -> Out {
|
||||
acts.push(Act::Sleep(uuid, true));
|
||||
if self.config.afk_announce {
|
||||
Out::All(format!("{name} is AFK."))
|
||||
} else {
|
||||
Out::Tell(
|
||||
uuid,
|
||||
"You are AFK: you don't count for sleeping until you move.".into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn back(&mut self, uuid: u128, name: &str, acts: &mut Vec<Act>) -> Vec<Out> {
|
||||
acts.push(Act::Sleep(uuid, false));
|
||||
vec![if self.config.afk_announce {
|
||||
Out::All(format!("{name} is back."))
|
||||
} else {
|
||||
Out::Tell(uuid, "You are back.".into())
|
||||
}]
|
||||
}
|
||||
|
||||
/// Before each of a player's SMP commands: counts it as activity, and
|
||||
/// refuses it past the limit.
|
||||
pub fn allow(&mut self, me: &Player, now: i64) -> Result<(Vec<Out>, Vec<Act>), Out> {
|
||||
let (limit, window) = (self.config.spam_commands, self.config.spam_seconds * 1000);
|
||||
if limit > 0 && me.level < 3 {
|
||||
let recent = self.recent.entry(me.uuid).or_default();
|
||||
while recent.front().is_some_and(|t| now - t >= window) {
|
||||
recent.pop_front();
|
||||
}
|
||||
if recent.len() >= limit {
|
||||
return Err(Out::Error(format!(
|
||||
"Slow down: at most {limit} commands every {} s.",
|
||||
self.config.spam_seconds
|
||||
)));
|
||||
}
|
||||
recent.push_back(now);
|
||||
}
|
||||
if let Some((_, active)) = self.on.get_mut(&me.uuid) {
|
||||
*active = now;
|
||||
}
|
||||
let mut acts = Vec::new();
|
||||
let out = if self.afk.remove(&me.uuid) {
|
||||
self.back(me.uuid, &me.name, &mut acts)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok((out, acts))
|
||||
}
|
||||
|
||||
/// `/afk`: AFK until the player moves, turns or runs a command.
|
||||
pub fn command(&mut self, me: &Player, line: &str) -> Option<(Vec<Out>, Vec<Act>)> {
|
||||
if line.trim() != "afk" {
|
||||
return None;
|
||||
}
|
||||
let mut acts = Vec::new();
|
||||
if !self.afk.insert(me.uuid) {
|
||||
return Some((vec![Out::Reply("You are already AFK.".into())], acts));
|
||||
}
|
||||
let out = self.away(me.uuid, &me.name, &mut acts);
|
||||
Some((vec![out], acts))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const A: u128 = 1;
|
||||
const B: u128 = 2;
|
||||
|
||||
fn seen(uuid: u128, x: f64) -> Seen {
|
||||
Seen {
|
||||
uuid,
|
||||
name: if uuid == A { "Alex" } else { "Steve" }.into(),
|
||||
pose: [x, 64.0, 0.0, 0.0, 0.0],
|
||||
}
|
||||
}
|
||||
|
||||
fn me(uuid: u128) -> Player {
|
||||
Player {
|
||||
uuid,
|
||||
name: "Alex".into(),
|
||||
level: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn open(known: Vec<u128>) -> (Presence, PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"smp-presence-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
(Presence::open(&dir, Config::default(), known).unwrap(), dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_first_join_is_welcomed_once_and_known_players_are_not() {
|
||||
let (mut p, dir) = open(vec![B]);
|
||||
let rules = vec!["Be kind.".to_owned()];
|
||||
let (out, _) = p.tick(&[seen(A, 0.0), seen(B, 0.0)], "2026-10-31", 0, &rules);
|
||||
let all: Vec<_> = out.iter().filter(|o| matches!(o, Out::All(_))).collect();
|
||||
assert_eq!(all.len(), 1, "{out:?}");
|
||||
assert!(matches!(all[0], Out::All(l) if l.starts_with("Alex joined for the first time")));
|
||||
assert!(
|
||||
out.iter()
|
||||
.any(|o| matches!(o, Out::Tell(A, l) if l == "1. Be kind."))
|
||||
);
|
||||
assert!(!out.iter().any(|o| matches!(o, Out::Tell(B, _))));
|
||||
p.save().unwrap();
|
||||
// Back after leaving, or after a restart: no welcome.
|
||||
p.tick(&[], "2026-10-31", 1_000, &rules);
|
||||
let (out, _) = p.tick(&[seen(A, 0.0)], "2026-10-31", 2_000, &rules);
|
||||
assert!(out.is_empty(), "{out:?}");
|
||||
let mut again = Presence::open(&dir, Config::default(), []).unwrap();
|
||||
let (out, _) = again.tick(&[seen(A, 0.0)], "2026-10-31", 0, &rules);
|
||||
assert!(out.is_empty(), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_daily_reward_comes_once_a_day() {
|
||||
let (mut p, _) = open(vec![]);
|
||||
let (_, acts) = p.tick(&[seen(A, 0.0)], "2026-10-31", 0, &[]);
|
||||
assert_eq!(acts, vec![Act::Reward(A)]);
|
||||
p.tick(&[], "2026-10-31", 1_000, &[]);
|
||||
let (_, acts) = p.tick(&[seen(A, 0.0)], "2026-10-31", 2_000, &[]);
|
||||
assert!(acts.is_empty(), "{acts:?}");
|
||||
// Midnight with the player on.
|
||||
let (_, acts) = p.tick(&[seen(A, 0.0)], "2026-11-01", 3_000, &[]);
|
||||
assert_eq!(acts, vec![Act::Reward(A)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_still_player_goes_afk_and_comes_back_by_moving_or_a_command() {
|
||||
let (mut p, _) = open(vec![A]);
|
||||
p.config.daily_reward = 0;
|
||||
p.tick(&[seen(A, 0.0)], "d", 0, &[]);
|
||||
let (_, acts) = p.tick(&[seen(A, 0.0)], "d", 299_000, &[]);
|
||||
assert!(acts.is_empty());
|
||||
let (out, acts) = p.tick(&[seen(A, 0.0)], "d", 300_000, &[]);
|
||||
assert_eq!(acts, vec![Act::Sleep(A, true)]);
|
||||
assert!(matches!(&out[..], [Out::Tell(A, _)]));
|
||||
let (_, acts) = p.tick(&[seen(A, 0.0)], "d", 301_000, &[]);
|
||||
assert!(acts.is_empty(), "once");
|
||||
let (_, acts) = p.tick(&[seen(A, 1.0)], "d", 302_000, &[]);
|
||||
assert_eq!(acts, vec![Act::Sleep(A, false)]);
|
||||
// /afk, then a command.
|
||||
let (_, acts) = p.command(&me(A), "afk").unwrap();
|
||||
assert_eq!(acts, vec![Act::Sleep(A, true)]);
|
||||
let (_, acts) = p.allow(&me(A), 303_000).unwrap();
|
||||
assert_eq!(acts, vec![Act::Sleep(A, false)]);
|
||||
// Leaving while AFK counts them again.
|
||||
p.command(&me(A), "afk");
|
||||
let (_, acts) = p.tick(&[], "d", 304_000, &[]);
|
||||
assert_eq!(acts, vec![Act::Sleep(A, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_past_the_limit_wait() {
|
||||
let (mut p, _) = open(vec![A]);
|
||||
for i in 0..8 {
|
||||
assert!(p.allow(&me(A), i * 100).is_ok());
|
||||
}
|
||||
assert!(p.allow(&me(A), 900).is_err());
|
||||
assert!(p.allow(&me(A), 5_000).is_ok(), "the first left the window");
|
||||
let op = Player { level: 3, ..me(A) };
|
||||
assert!(p.allow(&op, 5_001).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
// Whether the market's prices let anyone get rich by crafting: buying items,
|
||||
// crafting or smelting them into others, and selling those for more than
|
||||
// they cost. The cheapest way to get each item from the market is found over
|
||||
// the recipes (Bellman-Ford: a recipe lowers its result's cost to what its
|
||||
// inputs cost, until nothing changes); an item the market buys for more than
|
||||
// that cost is a loop. Fuel is left out, so a loop is found even where the
|
||||
// fuel would eat the profit. The vanilla recipes that matter for the default
|
||||
// prices are written here as facts (`CHAINS`); the tests also walk every
|
||||
// recipe of the game's own data when it is there. No game types. Added to
|
||||
// the game as `game::smp_recipes`; also a module of this crate, for its
|
||||
// tests.
|
||||
|
||||
use super::smp_market::Price;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A recipe: `count` of `result` from the ingredients, each a choice of
|
||||
/// items (any one of them) and how many of it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Recipe {
|
||||
pub result: String,
|
||||
pub count: u32,
|
||||
pub inputs: Vec<(Vec<String>, u32)>,
|
||||
}
|
||||
|
||||
/// An item the market buys for more than it costs to make from what the
|
||||
/// market sells.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Loop {
|
||||
pub item: String,
|
||||
/// What the market pays for one.
|
||||
pub sell: i64,
|
||||
/// The least one costs, bought or crafted.
|
||||
pub cost: f64,
|
||||
}
|
||||
|
||||
/// A written recipe: result, count, [(ingredient, how many)].
|
||||
type Chain = (&'static str, u32, &'static [(&'static str, u32)]);
|
||||
|
||||
/// The compression and food chains around the default prices.
|
||||
const CHAINS: &[Chain] = &[
|
||||
("iron_block", 1, &[("iron_ingot", 9)]),
|
||||
("iron_ingot", 9, &[("iron_block", 1)]),
|
||||
("iron_nugget", 9, &[("iron_ingot", 1)]),
|
||||
("iron_ingot", 1, &[("iron_nugget", 9)]),
|
||||
("iron_ingot", 1, &[("raw_iron", 1)]),
|
||||
("raw_iron_block", 1, &[("raw_iron", 9)]),
|
||||
("raw_iron", 9, &[("raw_iron_block", 1)]),
|
||||
("gold_block", 1, &[("gold_ingot", 9)]),
|
||||
("gold_ingot", 9, &[("gold_block", 1)]),
|
||||
("gold_nugget", 9, &[("gold_ingot", 1)]),
|
||||
("gold_ingot", 1, &[("gold_nugget", 9)]),
|
||||
("gold_ingot", 1, &[("raw_gold", 1)]),
|
||||
("copper_block", 1, &[("copper_ingot", 9)]),
|
||||
("copper_ingot", 9, &[("copper_block", 1)]),
|
||||
("copper_ingot", 1, &[("raw_copper", 1)]),
|
||||
("diamond_block", 1, &[("diamond", 9)]),
|
||||
("diamond", 9, &[("diamond_block", 1)]),
|
||||
("emerald_block", 1, &[("emerald", 9)]),
|
||||
("emerald", 9, &[("emerald_block", 1)]),
|
||||
("coal_block", 1, &[("coal", 9)]),
|
||||
("coal", 9, &[("coal_block", 1)]),
|
||||
("redstone_block", 1, &[("redstone", 9)]),
|
||||
("redstone", 9, &[("redstone_block", 1)]),
|
||||
("lapis_block", 1, &[("lapis_lazuli", 9)]),
|
||||
("lapis_lazuli", 9, &[("lapis_block", 1)]),
|
||||
("quartz_block", 1, &[("quartz", 4)]),
|
||||
("hay_block", 1, &[("wheat", 9)]),
|
||||
("wheat", 9, &[("hay_block", 1)]),
|
||||
("bread", 1, &[("wheat", 3)]),
|
||||
("melon", 1, &[("melon_slice", 9)]),
|
||||
("melon_seeds", 1, &[("melon_slice", 1)]),
|
||||
("pumpkin_seeds", 4, &[("pumpkin", 1)]),
|
||||
("sugar", 1, &[("sugar_cane", 1)]),
|
||||
("paper", 3, &[("sugar_cane", 3)]),
|
||||
("bone_meal", 3, &[("bone", 1)]),
|
||||
("bone_block", 1, &[("bone_meal", 9)]),
|
||||
("bone_meal", 9, &[("bone_block", 1)]),
|
||||
("slime_block", 1, &[("slime_ball", 9)]),
|
||||
("slime_ball", 9, &[("slime_block", 1)]),
|
||||
("white_wool", 1, &[("string", 4)]),
|
||||
("dried_kelp_block", 1, &[("dried_kelp", 9)]),
|
||||
("oak_planks", 4, &[("oak_log", 1)]),
|
||||
("spruce_planks", 4, &[("spruce_log", 1)]),
|
||||
("birch_planks", 4, &[("birch_log", 1)]),
|
||||
("stick", 4, &[("oak_planks", 2)]),
|
||||
("torch", 4, &[("coal", 1), ("stick", 1)]),
|
||||
("charcoal", 1, &[("oak_log", 1)]),
|
||||
("stone", 1, &[("cobblestone", 1)]),
|
||||
("stone_bricks", 4, &[("stone", 4)]),
|
||||
("glass", 1, &[("sand", 1)]),
|
||||
("cooked_beef", 1, &[("beef", 1)]),
|
||||
("cooked_porkchop", 1, &[("porkchop", 1)]),
|
||||
("cooked_chicken", 1, &[("chicken", 1)]),
|
||||
("baked_potato", 1, &[("potato", 1)]),
|
||||
("golden_carrot", 1, &[("carrot", 1), ("gold_nugget", 8)]),
|
||||
("blaze_powder", 2, &[("blaze_rod", 1)]),
|
||||
("leather", 1, &[("rabbit_hide", 4)]),
|
||||
("tnt", 1, &[("gunpowder", 5), ("sand", 4)]),
|
||||
];
|
||||
|
||||
/// `CHAINS` as recipes.
|
||||
pub fn chains() -> Vec<Recipe> {
|
||||
CHAINS
|
||||
.iter()
|
||||
.map(|(result, count, inputs)| Recipe {
|
||||
result: format!("minecraft:{result}"),
|
||||
count: *count,
|
||||
inputs: inputs
|
||||
.iter()
|
||||
.map(|(i, n)| (vec![format!("minecraft:{i}")], *n))
|
||||
.collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The least each item costs from the market: bought, or crafted from what
|
||||
/// the market sells (absent: no way at all).
|
||||
pub fn costs(prices: &BTreeMap<String, Price>, recipes: &[Recipe]) -> BTreeMap<String, f64> {
|
||||
let mut cost: BTreeMap<String, f64> = prices
|
||||
.iter()
|
||||
.filter(|(_, p)| p.buy > 0)
|
||||
.map(|(id, p)| (id.clone(), p.buy as f64))
|
||||
.collect();
|
||||
// Each round can only lower a cost; as many rounds as recipes is
|
||||
// Bellman-Ford's bound, and the loop stops as soon as nothing changes.
|
||||
for _ in 0..=recipes.len() {
|
||||
let mut changed = false;
|
||||
for r in recipes {
|
||||
if r.count == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut total = 0.0;
|
||||
let mut makeable = true;
|
||||
for (choices, n) in &r.inputs {
|
||||
let best = choices
|
||||
.iter()
|
||||
.filter_map(|c| cost.get(c))
|
||||
.fold(f64::INFINITY, |a, &b| a.min(b));
|
||||
if !best.is_finite() {
|
||||
makeable = false;
|
||||
break;
|
||||
}
|
||||
total += best * f64::from(*n);
|
||||
}
|
||||
if !makeable {
|
||||
continue;
|
||||
}
|
||||
let each = total / f64::from(r.count);
|
||||
let old = cost.get(&r.result).copied().unwrap_or(f64::INFINITY);
|
||||
if each < old - 1e-9 {
|
||||
cost.insert(r.result.clone(), each);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
cost
|
||||
}
|
||||
|
||||
/// The items the market buys for more than they cost to get from it.
|
||||
pub fn loops(prices: &BTreeMap<String, Price>, recipes: &[Recipe]) -> Vec<Loop> {
|
||||
let cost = costs(prices, recipes);
|
||||
prices
|
||||
.iter()
|
||||
.filter(|(_, p)| p.sell > 0)
|
||||
.filter_map(|(id, p)| {
|
||||
let c = *cost.get(id)?;
|
||||
(p.sell as f64 > c + 1e-9).then(|| Loop {
|
||||
item: id.clone(),
|
||||
sell: p.sell,
|
||||
cost: c,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// An ingredient of a recipe file: an item, a `#tag`, or a list of either,
|
||||
/// as the items it allows (`tags` maps a tag to its items, tags in tags
|
||||
/// resolved).
|
||||
fn choices(v: &serde_json::Value, tags: &BTreeMap<String, Vec<String>>) -> Vec<String> {
|
||||
match v {
|
||||
serde_json::Value::String(s) => match s.strip_prefix('#') {
|
||||
Some(tag) => tags.get(tag).cloned().unwrap_or_default(),
|
||||
None => vec![s.clone()],
|
||||
},
|
||||
serde_json::Value::Array(a) => a.iter().flat_map(|x| choices(x, tags)).collect(),
|
||||
serde_json::Value::Object(o) => o
|
||||
.get("item")
|
||||
.or_else(|| o.get("tag"))
|
||||
.map(|x| choices(x, tags))
|
||||
.unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// A vanilla recipe file (`data/minecraft/recipe/*.json`) as a recipe, for
|
||||
/// the kinds that turn items into items: shaped and shapeless crafting,
|
||||
/// transmuting, the furnaces and the campfire, the stonecutter and the
|
||||
/// smithing table. None for the others (dyeing, fireworks...).
|
||||
pub fn parse(json: &serde_json::Value, tags: &BTreeMap<String, Vec<String>>) -> Option<Recipe> {
|
||||
let kind = json.get("type")?.as_str()?;
|
||||
let result = json.get("result")?;
|
||||
let (result, count) = match result {
|
||||
serde_json::Value::String(s) => (s.clone(), 1),
|
||||
r => (
|
||||
r.get("id")?.as_str()?.to_owned(),
|
||||
r.get("count").and_then(|c| c.as_u64()).unwrap_or(1) as u32,
|
||||
),
|
||||
};
|
||||
let one = |key: &str| json.get(key).map(|v| (choices(v, tags), 1));
|
||||
let inputs: Vec<(Vec<String>, u32)> = match kind.strip_prefix("minecraft:")? {
|
||||
"crafting_shaped" => {
|
||||
let key = json.get("key")?.as_object()?;
|
||||
let mut counts: BTreeMap<char, u32> = BTreeMap::new();
|
||||
for row in json.get("pattern")?.as_array()? {
|
||||
for c in row.as_str()?.chars().filter(|c| *c != ' ') {
|
||||
*counts.entry(c).or_default() += 1;
|
||||
}
|
||||
}
|
||||
counts
|
||||
.into_iter()
|
||||
.map(|(c, n)| Some((choices(key.get(&c.to_string())?, tags), n)))
|
||||
.collect::<Option<_>>()?
|
||||
}
|
||||
"crafting_shapeless" => json
|
||||
.get("ingredients")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(|i| (choices(i, tags), 1))
|
||||
.collect(),
|
||||
"crafting_transmute" => vec![one("input")?, one("material")?],
|
||||
"smelting" | "blasting" | "smoking" | "campfire_cooking" | "stonecutting" => {
|
||||
vec![one("ingredient")?]
|
||||
}
|
||||
"smithing_transform" => vec![one("template")?, one("base")?, one("addition")?],
|
||||
_ => return None,
|
||||
};
|
||||
// An ingredient nothing can fill makes the recipe useless here.
|
||||
if inputs.iter().any(|(c, _)| c.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
Some(Recipe {
|
||||
result,
|
||||
count,
|
||||
inputs,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Item tags (`data/minecraft/tags/item/*.json`, by name: `logs`) resolved
|
||||
/// into items, as `minecraft:logs` → items.
|
||||
pub fn resolve_tags(raw: &BTreeMap<String, Vec<String>>) -> BTreeMap<String, Vec<String>> {
|
||||
fn expand(name: &str, raw: &BTreeMap<String, Vec<String>>, depth: u32, out: &mut Vec<String>) {
|
||||
let Some(values) = raw.get(name) else {
|
||||
return;
|
||||
};
|
||||
for v in values {
|
||||
match v.strip_prefix('#') {
|
||||
Some(t) if depth < 16 => expand(t, raw, depth + 1, out),
|
||||
Some(_) => {}
|
||||
None => out.push(v.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
raw.keys()
|
||||
.map(|name| {
|
||||
let mut out = Vec::new();
|
||||
expand(name, raw, 0, &mut out);
|
||||
(name.clone(), out)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::smp_market::Config;
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn prices(list: &[(&str, i64, i64)]) -> BTreeMap<String, Price> {
|
||||
list.iter()
|
||||
.map(|(id, buy, sell)| {
|
||||
(
|
||||
format!("minecraft:{id}"),
|
||||
Price {
|
||||
buy: *buy,
|
||||
sell: *sell,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_sold_dearer_than_its_ingots_is_a_loop() {
|
||||
// 9 ingots at 30 make a block the market would buy for 300.
|
||||
let p = prices(&[("iron_ingot", 30, 8), ("iron_block", 0, 300)]);
|
||||
let found = loops(&p, &chains());
|
||||
assert_eq!(found.len(), 1, "{found:?}");
|
||||
assert_eq!(found[0].item, "minecraft:iron_block");
|
||||
assert!((found[0].cost - 270.0).abs() < 1e-9);
|
||||
// The other way: a cheap block uncrafted into ingots the market buys.
|
||||
let p = prices(&[("iron_ingot", 30, 8), ("iron_block", 60, 0)]);
|
||||
let found = loops(&p, &chains());
|
||||
assert_eq!(found[0].item, "minecraft:iron_ingot", "{found:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bread_from_cheap_wheat_and_planks_from_cheap_logs_are_loops() {
|
||||
let p = prices(&[("wheat", 1, 0), ("bread", 5, 4)]);
|
||||
assert_eq!(loops(&p, &chains())[0].item, "minecraft:bread");
|
||||
let p = prices(&[("oak_log", 4, 1), ("oak_planks", 0, 2)]);
|
||||
assert_eq!(loops(&p, &chains())[0].item, "minecraft:oak_planks");
|
||||
// Through two recipes: logs → planks → sticks.
|
||||
let p = prices(&[("oak_log", 4, 1), ("stick", 0, 1)]);
|
||||
assert_eq!(loops(&p, &chains())[0].item, "minecraft:stick");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_prices_have_no_loop() {
|
||||
let config = Config::default();
|
||||
assert_eq!(loops(&config.items, &chains()), []);
|
||||
for (id, p) in &config.items {
|
||||
assert!(
|
||||
p.buy == 0 || p.sell < p.buy,
|
||||
"{id} sells for {} and buys for {}",
|
||||
p.sell,
|
||||
p.buy
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipe_files_read_as_recipes() {
|
||||
let tags = resolve_tags(
|
||||
&[
|
||||
(
|
||||
"minecraft:planks",
|
||||
vec!["minecraft:oak_planks", "#minecraft:x"],
|
||||
),
|
||||
("minecraft:x", vec![]),
|
||||
(
|
||||
"minecraft:oak_logs",
|
||||
vec!["minecraft:oak_log", "minecraft:oak_wood"],
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_owned(), v.into_iter().map(str::to_owned).collect()))
|
||||
.collect(),
|
||||
);
|
||||
let shaped: serde_json::Value = serde_json::from_str(
|
||||
r##"{"type":"minecraft:crafting_shaped","key":{"#":"minecraft:iron_ingot","S":"#minecraft:planks"},
|
||||
"pattern":["# #","#S#"," # "],"result":{"id":"minecraft:bucket"}}"##,
|
||||
)
|
||||
.unwrap();
|
||||
let r = parse(&shaped, &tags).unwrap();
|
||||
assert_eq!(r.result, "minecraft:bucket");
|
||||
assert_eq!(
|
||||
r.inputs,
|
||||
[
|
||||
(vec!["minecraft:iron_ingot".to_owned()], 5),
|
||||
(vec!["minecraft:oak_planks".to_owned()], 1)
|
||||
]
|
||||
);
|
||||
let shapeless: serde_json::Value = serde_json::from_str(
|
||||
r##"{"type":"minecraft:crafting_shapeless","ingredients":["#minecraft:oak_logs"],"result":{"count":4,"id":"minecraft:oak_planks"}}"##,
|
||||
)
|
||||
.unwrap();
|
||||
let r = parse(&shapeless, &tags).unwrap();
|
||||
assert_eq!((r.count, r.inputs[0].0.len()), (4, 2));
|
||||
let smelt: serde_json::Value = serde_json::from_str(
|
||||
r#"{"type":"minecraft:smelting","ingredient":["minecraft:a","minecraft:b"],"result":{"id":"minecraft:c"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(parse(&smelt, &tags).unwrap().inputs[0].0.len(), 2);
|
||||
let special: serde_json::Value =
|
||||
serde_json::from_str(r#"{"type":"minecraft:crafting_special_repairitem"}"#).unwrap();
|
||||
assert_eq!(parse(&special, &tags), None);
|
||||
}
|
||||
|
||||
/// The game's own recipe and tag files: `SMP_VANILLA_DATA` (a
|
||||
/// `data/minecraft` folder), or a Horizon checkout's prepared resources
|
||||
/// beside this repository. None when neither is there (CI).
|
||||
fn vanilla_data() -> Option<PathBuf> {
|
||||
if let Some(p) = std::env::var_os("SMP_VANILLA_DATA") {
|
||||
return Some(PathBuf::from(p));
|
||||
}
|
||||
let home = PathBuf::from(std::env::var_os("HOME")?);
|
||||
let p = home.join("code/horizon/.local/horizon-client/26.3/data/minecraft");
|
||||
p.join("recipe").is_dir().then_some(p)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_prices_have_no_loop_through_any_vanilla_recipe() {
|
||||
let Some(data) = vanilla_data() else {
|
||||
eprintln!("no vanilla data (SMP_VANILLA_DATA): only the written chains are checked");
|
||||
return;
|
||||
};
|
||||
let read = |p: &std::path::Path| -> serde_json::Value {
|
||||
serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
|
||||
};
|
||||
// Tags in folders too: `tags/item/enchantable/sword.json` is
|
||||
// `minecraft:enchantable/sword`.
|
||||
fn walk(dir: &std::path::Path, out: &mut Vec<PathBuf>) {
|
||||
for entry in std::fs::read_dir(dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.is_dir() {
|
||||
walk(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "json") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let root = data.join("tags/item");
|
||||
let mut files = Vec::new();
|
||||
walk(&root, &mut files);
|
||||
let mut raw = BTreeMap::new();
|
||||
for path in files {
|
||||
let name = path.strip_prefix(&root).unwrap().with_extension("");
|
||||
let values = read(&path)["values"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|v| {
|
||||
v.as_str()
|
||||
.or_else(|| v.get("id")?.as_str())
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.collect();
|
||||
raw.insert(format!("minecraft:{}", name.to_string_lossy()), values);
|
||||
}
|
||||
let tags = resolve_tags(&raw);
|
||||
let mut recipes = Vec::new();
|
||||
for entry in std::fs::read_dir(data.join("recipe")).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.extension().is_some_and(|e| e == "json")
|
||||
&& let Some(r) = parse(&read(&path), &tags)
|
||||
{
|
||||
recipes.push(r);
|
||||
}
|
||||
}
|
||||
assert!(recipes.len() > 1000, "{} recipes", recipes.len());
|
||||
let config = Config::default();
|
||||
assert_eq!(loops(&config.items, &recipes), []);
|
||||
// The checker does find a loop through the real recipes.
|
||||
let mut cheap = config.items.clone();
|
||||
cheap.insert("minecraft:iron_block".into(), Price { buy: 0, sell: 300 });
|
||||
assert_eq!(loops(&cheap, &recipes)[0].item, "minecraft:iron_block");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
// `/shop`: the market (smp_market) as a chest menu. The first page shows the
|
||||
// categories; a category's page its items, with what the market asks and
|
||||
// pays (in its name). A click buys one, a shift-click a stack of 64; a click on a stack in
|
||||
// your own inventory sells it; "Sell all" sells everything the market buys.
|
||||
// Buying and selling go through the market's own rules (its prices, today's
|
||||
// limits, items first then money, logged). This file only lays the pages out
|
||||
// and says what a click means; the glue opens the menu and moves the items.
|
||||
// No game types. Added to the game as `game::smp_shop`; also a module of this
|
||||
// crate, for its tests.
|
||||
|
||||
use super::smp_market::{Config as MarketConfig, Price, item_name};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Rows of the menu, and its slots.
|
||||
pub const ROWS: u8 = 6;
|
||||
pub const SIZE: usize = 54;
|
||||
/// A page's item slots (the rows above the bottom one).
|
||||
pub const PER_PAGE: usize = 45;
|
||||
/// What a shift-click buys.
|
||||
pub const STACK: u32 = 64;
|
||||
|
||||
/// The bottom row's buttons.
|
||||
const BACK: usize = 45;
|
||||
const HELP: usize = 46;
|
||||
const PREV: usize = 47;
|
||||
const BALANCE: usize = 48;
|
||||
const SELL_ALL: usize = 49;
|
||||
const LIMITS: usize = 50;
|
||||
const NEXT: usize = 51;
|
||||
const CLOSE: usize = 53;
|
||||
/// Where the first page puts the categories: the middle of rows 2 to 4.
|
||||
const CATEGORY_SLOTS: [usize; 21] = [
|
||||
10, 11, 12, 13, 14, 15, 16, 19, 20, 21, 22, 23, 24, 25, 28, 29, 30, 31, 32, 33, 34,
|
||||
];
|
||||
|
||||
/// A category: its name, the item its button shows, its items.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Category {
|
||||
pub name: String,
|
||||
pub icon: String,
|
||||
pub items: Vec<String>,
|
||||
}
|
||||
|
||||
/// `config.json`'s `shop`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub enabled: bool,
|
||||
pub title: String,
|
||||
/// The market's items not in any category go to an "Other" one.
|
||||
pub categories: Vec<Category>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
let cat = |name: &str, icon: &str, items: &[&str]| Category {
|
||||
name: name.into(),
|
||||
icon: format!("minecraft:{icon}"),
|
||||
items: items.iter().map(|i| format!("minecraft:{i}")).collect(),
|
||||
};
|
||||
Self {
|
||||
enabled: true,
|
||||
title: "Shop".into(),
|
||||
categories: vec![
|
||||
cat(
|
||||
"Farming",
|
||||
"wheat",
|
||||
&[
|
||||
"wheat",
|
||||
"wheat_seeds",
|
||||
"carrot",
|
||||
"potato",
|
||||
"beetroot",
|
||||
"sugar_cane",
|
||||
"pumpkin",
|
||||
"melon_slice",
|
||||
"cactus",
|
||||
"bone_meal",
|
||||
],
|
||||
),
|
||||
cat(
|
||||
"Mining",
|
||||
"diamond",
|
||||
&[
|
||||
"coal",
|
||||
"copper_ingot",
|
||||
"iron_ingot",
|
||||
"gold_ingot",
|
||||
"redstone",
|
||||
"lapis_lazuli",
|
||||
"quartz",
|
||||
"emerald",
|
||||
"diamond",
|
||||
],
|
||||
),
|
||||
cat(
|
||||
"Wood",
|
||||
"oak_log",
|
||||
&[
|
||||
"oak_log",
|
||||
"spruce_log",
|
||||
"birch_log",
|
||||
"jungle_log",
|
||||
"acacia_log",
|
||||
"dark_oak_log",
|
||||
"mangrove_log",
|
||||
"cherry_log",
|
||||
],
|
||||
),
|
||||
cat(
|
||||
"Mob drops",
|
||||
"bone",
|
||||
&[
|
||||
"rotten_flesh",
|
||||
"bone",
|
||||
"string",
|
||||
"gunpowder",
|
||||
"spider_eye",
|
||||
"slime_ball",
|
||||
"blaze_rod",
|
||||
"leather",
|
||||
"feather",
|
||||
],
|
||||
),
|
||||
cat(
|
||||
"Building",
|
||||
"bricks",
|
||||
&[
|
||||
"cobblestone",
|
||||
"stone",
|
||||
"dirt",
|
||||
"sand",
|
||||
"gravel",
|
||||
"glass",
|
||||
"bricks",
|
||||
"stone_bricks",
|
||||
],
|
||||
),
|
||||
cat(
|
||||
"Food",
|
||||
"bread",
|
||||
&[
|
||||
"bread",
|
||||
"cooked_beef",
|
||||
"cooked_porkchop",
|
||||
"cooked_chicken",
|
||||
"baked_potato",
|
||||
"apple",
|
||||
"golden_carrot",
|
||||
],
|
||||
),
|
||||
cat(
|
||||
"Tools",
|
||||
"torch",
|
||||
&["torch", "ladder", "crafting_table", "chest", "furnace"],
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which page a player sees.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Page {
|
||||
Main,
|
||||
/// A category (by index in `categories`) and its page from 0.
|
||||
Category(usize, usize),
|
||||
}
|
||||
|
||||
/// One slot of the menu.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Cell {
|
||||
Empty,
|
||||
/// The bottom row's background.
|
||||
Filler,
|
||||
/// A category's button: its icon, name, and how many items it has.
|
||||
Category(String, String, usize),
|
||||
/// An item and its price.
|
||||
Item(String, Price),
|
||||
Back,
|
||||
Prev,
|
||||
Next,
|
||||
Balance,
|
||||
SellAll,
|
||||
Limits,
|
||||
Help,
|
||||
Close,
|
||||
}
|
||||
|
||||
/// What a click asks for.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Click {
|
||||
/// Nothing (an empty slot, a filler).
|
||||
Nothing,
|
||||
/// Show this page.
|
||||
Show(Page),
|
||||
/// Buy this many of the item.
|
||||
Buy(String, u32),
|
||||
/// Sell everything the market buys.
|
||||
SellAll,
|
||||
Close,
|
||||
}
|
||||
|
||||
/// The categories as shown: each with only the items the market trades,
|
||||
/// those it trades in none of them under "Other", the empty ones left out.
|
||||
pub fn categories(config: &Config, market: &MarketConfig) -> Vec<Category> {
|
||||
let traded = |id: &String| {
|
||||
market
|
||||
.items
|
||||
.get(id)
|
||||
.is_some_and(|p| p.buy > 0 || p.sell > 0)
|
||||
};
|
||||
let mut out: Vec<Category> = config
|
||||
.categories
|
||||
.iter()
|
||||
.map(|c| Category {
|
||||
items: c.items.iter().filter(|i| traded(i)).cloned().collect(),
|
||||
..c.clone()
|
||||
})
|
||||
.collect();
|
||||
let other: Vec<String> = market
|
||||
.items
|
||||
.keys()
|
||||
.filter(|id| traded(id) && !out.iter().any(|c| c.items.contains(id)))
|
||||
.cloned()
|
||||
.collect();
|
||||
if !other.is_empty() {
|
||||
out.push(Category {
|
||||
name: "Other".into(),
|
||||
icon: "minecraft:chest".into(),
|
||||
items: other,
|
||||
});
|
||||
}
|
||||
out.retain(|c| !c.items.is_empty());
|
||||
out
|
||||
}
|
||||
|
||||
/// The players' open shop menus: their window and page.
|
||||
#[derive(Default)]
|
||||
pub struct Shop {
|
||||
pub views: HashMap<u128, (Option<i32>, Page)>,
|
||||
}
|
||||
|
||||
impl Shop {
|
||||
/// The page `uuid` sees, as cells.
|
||||
pub fn layout(&self, config: &Config, market: &MarketConfig, uuid: u128) -> Vec<Cell> {
|
||||
let page = self.views.get(&uuid).map_or(Page::Main, |v| v.1);
|
||||
let cats = categories(config, market);
|
||||
let mut cells = vec![Cell::Empty; SIZE];
|
||||
for c in &mut cells[PER_PAGE..] {
|
||||
*c = Cell::Filler;
|
||||
}
|
||||
cells[HELP] = Cell::Help;
|
||||
cells[BALANCE] = Cell::Balance;
|
||||
cells[SELL_ALL] = Cell::SellAll;
|
||||
cells[LIMITS] = Cell::Limits;
|
||||
cells[CLOSE] = Cell::Close;
|
||||
match page {
|
||||
Page::Main => {
|
||||
for (slot, c) in CATEGORY_SLOTS.iter().zip(&cats) {
|
||||
cells[*slot] = Cell::Category(c.icon.clone(), c.name.clone(), c.items.len());
|
||||
}
|
||||
}
|
||||
Page::Category(i, p) => {
|
||||
cells[BACK] = Cell::Back;
|
||||
let items = cats.get(i).map(|c| c.items.as_slice()).unwrap_or_default();
|
||||
for (slot, id) in items.iter().skip(p * PER_PAGE).take(PER_PAGE).enumerate() {
|
||||
cells[slot] = Cell::Item(
|
||||
id.clone(),
|
||||
market.items.get(id).copied().unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
if p > 0 {
|
||||
cells[PREV] = Cell::Prev;
|
||||
}
|
||||
if (p + 1) * PER_PAGE < items.len() {
|
||||
cells[NEXT] = Cell::Next;
|
||||
}
|
||||
}
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
/// What a click on the menu's `slot` means (`shift`: a shift-click); a
|
||||
/// new page is kept as the player's.
|
||||
pub fn click(
|
||||
&mut self,
|
||||
config: &Config,
|
||||
market: &MarketConfig,
|
||||
uuid: u128,
|
||||
slot: usize,
|
||||
shift: bool,
|
||||
) -> Click {
|
||||
let cells = self.layout(config, market, uuid);
|
||||
let page = self.views.get(&uuid).map_or(Page::Main, |v| v.1);
|
||||
let click = match (cells.get(slot), page) {
|
||||
(Some(Cell::Category(_, name, _)), _) => {
|
||||
let i = categories(config, market)
|
||||
.iter()
|
||||
.position(|c| &c.name == name);
|
||||
i.map_or(Click::Nothing, |i| Click::Show(Page::Category(i, 0)))
|
||||
}
|
||||
(Some(Cell::Item(id, price)), _) if price.buy > 0 => {
|
||||
Click::Buy(id.clone(), if shift { STACK } else { 1 })
|
||||
}
|
||||
(Some(Cell::Back), _) => Click::Show(Page::Main),
|
||||
(Some(Cell::Prev), Page::Category(i, p)) => {
|
||||
Click::Show(Page::Category(i, p.saturating_sub(1)))
|
||||
}
|
||||
(Some(Cell::Next), Page::Category(i, p)) => Click::Show(Page::Category(i, p + 1)),
|
||||
(Some(Cell::SellAll), _) => Click::SellAll,
|
||||
(Some(Cell::Close), _) => Click::Close,
|
||||
_ => Click::Nothing,
|
||||
};
|
||||
if let (Click::Show(p), Some(v)) = (&click, self.views.get_mut(&uuid)) {
|
||||
v.1 = *p;
|
||||
}
|
||||
click
|
||||
}
|
||||
}
|
||||
|
||||
/// An item's button: its name and what the market asks and pays.
|
||||
pub fn item_label(id: &str, price: Price, money: &dyn Fn(i64) -> String) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if price.buy > 0 {
|
||||
parts.push(format!("buy {}", money(price.buy)));
|
||||
}
|
||||
if price.sell > 0 {
|
||||
parts.push(format!("sell {}", money(price.sell)));
|
||||
}
|
||||
format!("{}: {}", title(id), parts.join(", "))
|
||||
}
|
||||
|
||||
/// The help button's text.
|
||||
pub const HELP_TEXT: &str = "Click: buy 1 · Shift-click: buy 64 · Click a stack of yours: sell it";
|
||||
|
||||
/// `minecraft:iron_ingot` as `Iron Ingot`, for a button.
|
||||
pub fn title(id: &str) -> String {
|
||||
item_name(id)
|
||||
.split(' ')
|
||||
.map(|w| {
|
||||
let mut c = w.chars();
|
||||
c.next()
|
||||
.map(|f| f.to_uppercase().chain(c).collect())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ALEX: u128 = 1;
|
||||
|
||||
fn open(shop: &mut Shop) {
|
||||
shop.views.insert(ALEX, (Some(3), Page::Main));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_page_shows_the_categories_with_the_market_s_items() {
|
||||
let (config, mut market) = (Config::default(), MarketConfig::default());
|
||||
// An item in no category: under "Other". One the market doesn't
|
||||
// trade: nowhere.
|
||||
market
|
||||
.items
|
||||
.insert("minecraft:sponge".into(), Price { buy: 50, sell: 0 });
|
||||
market.items.remove("minecraft:torch");
|
||||
let cats = categories(&config, &market);
|
||||
assert_eq!(cats.last().unwrap().name, "Other");
|
||||
assert_eq!(cats.last().unwrap().items, ["minecraft:sponge"]);
|
||||
assert!(
|
||||
cats.iter()
|
||||
.all(|c| !c.items.contains(&"minecraft:torch".to_owned()))
|
||||
);
|
||||
// Every default item is in a category of its own.
|
||||
let all = categories(&config, &MarketConfig::default());
|
||||
assert!(all.iter().all(|c| c.name != "Other"), "{all:?}");
|
||||
let mut shop = Shop::default();
|
||||
open(&mut shop);
|
||||
let cells = shop.layout(&config, &market, ALEX);
|
||||
assert_eq!(
|
||||
cells[10],
|
||||
Cell::Category("minecraft:wheat".into(), "Farming".into(), 10)
|
||||
);
|
||||
assert_eq!(
|
||||
(cells[BALANCE].clone(), cells[CLOSE].clone()),
|
||||
(Cell::Balance, Cell::Close)
|
||||
);
|
||||
assert_eq!(cells[BACK], Cell::Filler);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_open_a_category_buy_one_or_a_stack_and_go_back() {
|
||||
let (config, market) = (Config::default(), MarketConfig::default());
|
||||
let mut shop = Shop::default();
|
||||
open(&mut shop);
|
||||
// Mining: the second category.
|
||||
assert_eq!(
|
||||
shop.click(&config, &market, ALEX, 11, false),
|
||||
Click::Show(Page::Category(1, 0))
|
||||
);
|
||||
let cells = shop.layout(&config, &market, ALEX);
|
||||
assert_eq!(
|
||||
cells[0],
|
||||
Cell::Item("minecraft:coal".into(), Price { buy: 8, sell: 2 })
|
||||
);
|
||||
assert_eq!(cells[BACK], Cell::Back);
|
||||
assert_eq!(
|
||||
shop.click(&config, &market, ALEX, 0, false),
|
||||
Click::Buy("minecraft:coal".into(), 1)
|
||||
);
|
||||
assert_eq!(
|
||||
shop.click(&config, &market, ALEX, 0, true),
|
||||
Click::Buy("minecraft:coal".into(), 64)
|
||||
);
|
||||
assert_eq!(
|
||||
shop.click(&config, &market, ALEX, 40, false),
|
||||
Click::Nothing
|
||||
);
|
||||
assert_eq!(
|
||||
shop.click(&config, &market, ALEX, SELL_ALL, false),
|
||||
Click::SellAll
|
||||
);
|
||||
assert_eq!(
|
||||
shop.click(&config, &market, ALEX, BACK, false),
|
||||
Click::Show(Page::Main)
|
||||
);
|
||||
assert_eq!(shop.views[&ALEX].1, Page::Main);
|
||||
assert_eq!(
|
||||
shop.click(&config, &market, ALEX, CLOSE, false),
|
||||
Click::Close
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_the_market_only_buys_is_not_sold_and_long_categories_have_pages() {
|
||||
let mut market = MarketConfig::default();
|
||||
let mut config = Config::default();
|
||||
// Rotten flesh: the market buys it, never sells it.
|
||||
let mut shop = Shop::default();
|
||||
open(&mut shop);
|
||||
shop.click(&config, &market, ALEX, 13, false);
|
||||
assert_eq!(shop.views[&ALEX].1, Page::Category(3, 0));
|
||||
assert_eq!(shop.click(&config, &market, ALEX, 0, false), Click::Nothing);
|
||||
// 50 items in one category: two pages.
|
||||
let many: Vec<String> = (0..50).map(|i| format!("minecraft:item_{i}")).collect();
|
||||
for id in &many {
|
||||
market.items.insert(id.clone(), Price { buy: 2, sell: 1 });
|
||||
}
|
||||
config.categories = vec![Category {
|
||||
name: "All".into(),
|
||||
icon: "minecraft:chest".into(),
|
||||
items: many,
|
||||
}];
|
||||
shop.views.insert(ALEX, (Some(3), Page::Category(0, 0)));
|
||||
let cells = shop.layout(&config, &market, ALEX);
|
||||
assert_eq!(
|
||||
(cells[PREV].clone(), cells[NEXT].clone()),
|
||||
(Cell::Filler, Cell::Next)
|
||||
);
|
||||
assert_eq!(
|
||||
shop.click(&config, &market, ALEX, NEXT, false),
|
||||
Click::Show(Page::Category(0, 1))
|
||||
);
|
||||
let cells = shop.layout(&config, &market, ALEX);
|
||||
assert_eq!(
|
||||
cells[0],
|
||||
Cell::Item("minecraft:item_45".into(), Price { buy: 2, sell: 1 })
|
||||
);
|
||||
assert_eq!(cells[5], Cell::Empty);
|
||||
assert_eq!(cells[PREV], Cell::Prev);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_read_well() {
|
||||
let money = |n: i64| format!("{n} coins");
|
||||
assert_eq!(
|
||||
item_label("minecraft:iron_ingot", Price { buy: 30, sell: 8 }, &money),
|
||||
"Iron Ingot: buy 30 coins, sell 8 coins"
|
||||
);
|
||||
assert_eq!(
|
||||
item_label("minecraft:bone", Price { buy: 0, sell: 1 }, &money),
|
||||
"Bone: sell 1 coins"
|
||||
);
|
||||
}
|
||||
}
|
||||
+388
-13
@@ -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))
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in new issue
Block a user