mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 11:05:58 -10:00
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:
1 parent
455a8b800f
commit
f56752d74f
5 files changed
+194
-10
No files matched your search
+46
-6
@@ -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 |
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user