SMP 0.12.0: /shop menu, daily selling limits, no profitable recipe loop, /money and /eco give *

- /shop: the market as a 6-row chest menu: categories (config.json `shop`), a click buys one, a shift-click 64,
  a click on a stack of yours sells it, "Sell all"; balance, today's limit and help in the bottom row. Through the
  market's own rules; items move by the console's clear/give, measured before and after.
- Market: buy always dearer than sell (else the sale closes), no recipe chain makes a sold item for less than it
  sells (smp_recipes, Bellman-Ford over the recipes; the tests walk every vanilla recipe when the data is there),
  daily limits per player (2,000 coins, 640 of each item, market.json), checked arithmetic, item ids validated.
- 56 default prices in 7 categories; a config still holding the 18 old defaults gets the new ones.
- /money give|take|set (= /eco), /eco give * <amount> for everyone on; recipients are told; all logged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-31 21:13:11 +01:00
1 parent 455a8b800f
commit f56752d74f
5 files changed
+194 -10

No files matched your search

+46 -6
View File
@@ -8,7 +8,7 @@ Players install nothing: it speaks only what a stock Horizon client already show
lines, vanilla chest menus the server fills with ordinary items (the trade menu); later sounds, titles, the action
bar, boss bars and the sidebar).
This version: the economy, the server market, player trades, the coinflip, the lottery, places (spawn, homes, warps,
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).
@@ -17,8 +17,10 @@ header and footer).
| `/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) |
@@ -46,6 +48,43 @@ The market trades only plain items (not worn, named or enchanted), and only with
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
@@ -111,9 +150,10 @@ 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 |
@@ -134,8 +174,8 @@ and the world lifecycle. `compatibility.json` names `"hooks": 49`.
| File | What it is |
|---|---|
| `mixins/smp/horizon-mixin.toml` | The bundle: eleven 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`, `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_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/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,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 |
+3
View File
@@ -114,6 +114,9 @@ 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}");
}
+74 -1
View File
@@ -135,6 +135,51 @@ impl Default for Config {
}
}
/// 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)]
@@ -270,7 +315,8 @@ impl Market {
(market, problems)
}
/// A market that keeps nothing on disk, its config as given.
/// A market that keeps nothing on disk, its config as given (tests).
#[cfg(test)]
pub fn new(config: Config) -> Self {
Self {
config,
@@ -958,4 +1004,31 @@ mod tests {
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);
}
}
+3
View File
@@ -176,6 +176,7 @@ pub fn loops(prices: &BTreeMap<String, Price>, recipes: &[Recipe]) -> Vec<Loop>
.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).
@@ -195,6 +196,7 @@ fn choices(v: &serde_json::Value, tags: &BTreeMap<String, Vec<String>>) -> Vec<S
}
}
#[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
@@ -248,6 +250,7 @@ pub fn parse(json: &serde_json::Value, tags: &BTreeMap<String, Vec<String>>) ->
})
}
#[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>> {
+68 -3
View File
@@ -15,7 +15,10 @@ 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.
"""
@@ -499,6 +502,57 @@ def presence(server, alex, steve):
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 = {}
@@ -605,7 +659,7 @@ def run(ctl, resources, out):
config = json.loads((data / "config.json").read_text())
results["config_keys"] = sorted(config)
assert results["config_keys"] == ["board", "claims", "currency", "gambling", "market", "places", "presence",
"starting_balance"], config
"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"]
@@ -707,13 +761,24 @@ def run(ctl, resources, out):
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 + 7, 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()]
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))