Merge pull request #56 from horizon-rust/feat/smp-market

SMP: the server market (/worth, /sell, /buy)
This commit is contained in:
levzzz authored and GitHub committed 2026-10-30 23:04:50 +05:00
commit 37f573d08f
9 files changed
+679 -55

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "horizon-mod-smp"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "horizon-mod-smp"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
license = "MIT"
publish = false
+15 -5
View File
@@ -4,7 +4,10 @@ The official Horizon server's survival layer ([design](https://github.com/horizo
It is **not** part of Horizon's own server: only a server that builds this mod into its engine has it. It isn't listed
in the registry.
This version: the economy.
Players install nothing: it speaks only what a stock Horizon client already shows (commands with completions, chat
lines; later vanilla chest menus filled by the server, sounds, titles, the action bar, boss bars, the sidebar).
This version: the economy and the server market.
| Command | Does |
|---|---|
@@ -12,6 +15,13 @@ This version: the economy.
| `/pay <player> <amount>` | gives money to a player who has an account (anyone who has played since the mod was on) |
| `/baltop [page]` | the richest players, 10 a page |
| `/eco give\|take\|set <player> <amount>` | operators of level 3: corrections, logged |
| `/worth [item]` | what the market pays and asks for an item (the one in your hand without a name) |
| `/sell hand [count]`, `/sell <item> [count]`, `/sell all` | sells to the market: the stack in your hand, an item, or everything it buys |
| `/buy <item> [count]` | buys from the market, only what fits in your inventory |
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.
The currency is earned in game only: it can't be bought with real money or cashed out (Mojang's usage guidelines).
@@ -22,7 +32,7 @@ folder is untouched until the mod first runs on it; it changes no block and no p
| File | Holds |
|---|---|
| `config.json` | `currency` (default `coins`) and `starting_balance` (default 0), written with the defaults the first time |
| `config.json` | `currency` (default `coins`), `starting_balance` (default 0), and `market`: `enabled` and `items`, each item's `buy` (what the market asks) and `sell` (what it pays), 0 closing that side; written with the defaults the first time, and again when a newer version adds keys (your values kept) |
| `economy.json` | balances by player UUID, with the name last seen |
| `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 |
@@ -33,12 +43,12 @@ 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
server tick and the world lifecycle. `compatibility.json` names `"hooks": 8`.
acting player's items (API 9), the server tick and the world lifecycle. `compatibility.json` names `"hooks": 9`.
| File | What it is |
|---|---|
| `mixins/smp/horizon-mixin.toml` | The bundle: three modules and the HEAD hooks |
| `mixins/smp/smp_store.rs`, `smp_economy.rs` | The rules, no game types: added to the game as `game::smp_store`, `game::smp_economy`; also this crate's modules (`src/lib.rs`), for their tests |
| `mixins/smp/horizon-mixin.toml` | The bundle: four modules and the HEAD hooks |
| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs` | The rules, no game types: added to the game as `game::smp_store`, `game::smp_economy`, `game::smp_market`; 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}.rs` | One small helper per hook |
| `proof.py` | A dedicated server built with the mod and two real clients, on fresh temporary data |
+2 -2
View File
@@ -1,8 +1,8 @@
{
"game": "26.3",
"hooks": 8,
"hooks": 9,
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "768d20d9980c2e04cc76951d18ec6c72ccef0176",
"horizon_revision": "b070f41ddae470df36694dff7f176bdda2a3ad90",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
}
+101 -39
View File
@@ -1,12 +1,13 @@
// 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). Added to the game as
// `game::smp_glue`; the helpers (register.rs, command.rs, tick.rs,
// lifecycle.rs) call it from the hook points.
// between the game's types and the rules (smp_economy, smp_market). Added to
// the game as `game::smp_glue`; the helpers (register.rs, command.rs,
// tick.rs, lifecycle.rs) call it from the hook points.
use crate::game::{
Game,
native_server::{NativeCommand, NativeServerPlayer},
smp_economy::{self, Economy, Out, Player},
smp_market::{self, Bag, Market},
smp_store,
};
use horizon_client::text::{self, Text};
@@ -15,15 +16,16 @@ 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.1.0";
pub const VERSION: &str = "0.2.0";
/// `config.json` in `world/horizon-smp/`, written with its defaults the
/// first time.
/// first time, and again with the keys a newer version adds (values kept).
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Config {
#[serde(flatten)]
pub economy: smp_economy::Config,
pub market: smp_market::Config,
}
/// The SMP of the open world.
@@ -31,6 +33,7 @@ pub struct Smp {
dir: PathBuf,
/// An error when `economy.json` can't be read: the commands say so.
economy: Result<Economy, String>,
market: Market,
}
static STATE: Mutex<Option<Smp>> = Mutex::new(None);
@@ -38,16 +41,32 @@ static TICKS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0
fn open(dir: PathBuf) -> Smp {
let config_path = dir.join("config.json");
let config: Config = match smp_store::read_json(&config_path) {
Ok(c) => c,
let (config, write) = match smp_store::read_json::<Option<serde_json::Value>>(&config_path) {
// No file yet: the defaults, written for the owner to edit.
Ok(None) => (Config::default(), true),
Ok(Some(v)) => match serde_json::from_value::<Config>(v.clone()) {
// Written again only when a newer version adds keys; values kept.
Ok(c) => {
let keys = |v: &serde_json::Value| v.as_object().map(|o| o.len());
let full = serde_json::to_value(&c).unwrap_or_default();
let added = keys(&full) != keys(&v);
(c, added)
}
Err(e) => {
log::error!(
"[smp] {}: {e}: the defaults are used",
config_path.display()
);
(Config::default(), false)
}
},
// A damaged file stays as it is for the owner to mend.
Err(e) => {
log::error!("[smp] {e}: the defaults are used");
Config::default()
(Config::default(), false)
}
};
if !config_path.exists()
&& let Err(e) = smp_store::write_json(&config_path, &config)
{
if write && let Err(e) = smp_store::write_json(&config_path, &config) {
log::error!("[smp] {e}");
}
log::info!("[smp] {NAME} {VERSION} on {}", dir.display());
@@ -55,12 +74,18 @@ fn open(dir: PathBuf) -> Smp {
if let Err(e) = &economy {
log::error!("[smp] the economy is off: {e}");
}
Smp { dir, economy }
Smp {
dir,
economy,
market: Market {
config: config.market,
},
}
}
/// Runs `f` on the SMP of the game's open world (opened on first use).
fn with<R>(game: &Game<'_>, f: impl FnOnce(&mut Smp) -> R) -> Option<R> {
let dir = game.native_world_dir()?.join(smp_store::DIR);
/// The SMP's state, opened for the world in `dir` on first use (the files
/// of another world it held are written first).
fn state(dir: PathBuf) -> std::sync::MutexGuard<'static, Option<Smp>> {
let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner());
if state.as_ref().is_none_or(|s| s.dir != dir) {
if let Some(old) = state.as_mut() {
@@ -68,11 +93,10 @@ fn with<R>(game: &Game<'_>, f: impl FnOnce(&mut Smp) -> R) -> Option<R> {
}
*state = Some(open(dir));
}
let smp = state.as_mut().expect("opened above");
if let Ok(e) = &mut smp.economy {
if let Some(Ok(e)) = state.as_mut().map(|s| &mut s.economy) {
e.now = chrono::Utc::now().timestamp_millis();
}
Some(f(smp))
state
}
fn save(smp: &mut Smp) {
@@ -90,6 +114,11 @@ pub fn commands(commands: &mut Vec<NativeCommand>) {
"bal [player:word]",
"pay <player:word> <amount:integer>",
"baltop [page:integer]",
"worth [item:item]",
"sell hand [count:integer]",
"sell all",
"sell <item:item> [count:integer]",
"buy <item:item> [count:integer]",
] {
commands.push(NativeCommand::new(usage));
}
@@ -101,6 +130,34 @@ pub fn commands(commands: &mut Vec<NativeCommand>) {
}
}
/// The acting player's items (inside `native_perform_command`).
struct GameBag<'a, 'g>(&'a mut Game<'g>);
impl Bag for GameBag<'_, '_> {
fn held(&self) -> Option<(String, u32, bool)> {
self.0.native_held()
}
fn carried(&self) -> Vec<(String, u32)> {
self.0.native_carried_items()
}
fn count(&mut self, item: &str) -> u32 {
self.0.native_count_items(item)
}
fn take(&mut self, item: &str, count: u32) -> u32 {
self.0.native_take_items(item, count)
}
fn room(&mut self, item: &str) -> u32 {
self.0.native_room_for(item)
}
fn give(&mut self, item: &str, count: u32) -> u32 {
self.0.native_give_items(item, count)
}
}
const COMMANDS: [&str; 8] = [
"balance", "bal", "pay", "baltop", "eco", "worth", "sell", "buy",
];
/// A player's command.
pub(in crate::game) fn perform(
game: &mut Game<'_>,
@@ -108,32 +165,34 @@ pub(in crate::game) fn perform(
line: &str,
ran: &mut bool,
) {
if !game.native_on(ID) {
if !game.native_on(ID) || !COMMANDS.contains(&line.split(' ').next().unwrap_or_default()) {
return;
}
let Some(dir) = game.native_world_dir() else {
return;
};
let me = Player {
uuid: player.uuid,
name: player.name.clone(),
level: player.permission,
};
let Some(out) = with(game, |smp| match &mut smp.economy {
// Survival and adventure: a creative player could sell what they made.
let trades = matches!(player.game_mode, 0 | 2);
let mut guard = state(dir.join(smp_store::DIR));
let smp = guard.as_mut().expect("opened");
let out = match &mut smp.economy {
Ok(e) => {
e.seen(me.uuid, &me.name);
let mut bag = GameBag(game);
e.command(&me, line)
.or_else(|| smp.market.command(e, &me, trades, &mut bag, line))
.unwrap_or_default()
}
Err(_) => matches!(
line.split(' ').next(),
Some("balance" | "bal" | "pay" | "baltop" | "eco")
)
.then(|| {
vec![Out::Error(
"The economy is off: its file can't be read (see the server log).".into(),
)]
}),
})
.flatten() else {
return;
Err(_) => vec![Out::Error(
"The economy is off: its file can't be read (see the server log).".into(),
)],
};
drop(guard);
*ran = true;
show(game, out);
}
@@ -153,20 +212,23 @@ 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<'_>) {
let Some(dir) = game.native_world_dir() else {
return;
};
let n = TICKS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let players = if n % 20 == 0 {
game.native_server_players()
} else {
Vec::new()
};
with(game, |smp| {
if let Ok(e) = &mut smp.economy {
for p in &players {
e.seen(p.uuid, &p.name);
}
let mut guard = state(dir.join(smp_store::DIR));
let smp = guard.as_mut().expect("opened");
if let Ok(e) = &mut smp.economy {
for p in &players {
e.seen(p.uuid, &p.name);
}
save(smp);
});
}
save(smp);
}
/// The world closed: its files are written and its state let go.
+9 -3
View File
@@ -1,12 +1,12 @@
[mod]
id = "smp"
version = "0.1.0"
version = "0.2.0"
priority = 100
# The commit it was tested on. It uses only the native hook points (hooks 8 in
# The commit it was tested on. It uses only the native hook points (hooks 9 in
# compatibility.json): it builds on any Horizon that has them.
[compatibility]
horizon_revision = "768d20d9980c2e04cc76951d18ec6c72ccef0176"
horizon_revision = "b070f41ddae470df36694dff7f176bdda2a3ad90"
[[module]]
id = "store"
@@ -20,6 +20,12 @@ file = "crates/horizon-client/src/game.rs"
name = "smp_economy"
source = "smp_economy.rs"
[[module]]
id = "market"
file = "crates/horizon-client/src/game.rs"
name = "smp_market"
source = "smp_market.rs"
[[module]]
id = "glue"
file = "crates/horizon-client/src/game.rs"
+518
View File
@@ -0,0 +1,518 @@
// The server market: the server buys and sells vanilla items at the prices
// of `config.json`'s `market`, the economy's way to earn without changing
// survival. `/worth`, `/sell hand|all|<item> [count]`, `/buy <item> [count]`.
// Only plain items (no damage, no name or enchantment) are traded, and only
// by players in survival or adventure mode. Items move first, then money,
// both logged; a sale the log refuses gives the items back. No game types:
// the glue lends the acting player's items as a `Bag`. Added to the game as
// `game::smp_market`; also a module of this crate, for its tests.
use super::smp_economy::{Economy, MAX_AMOUNT, Out, Player, parse_amount};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// What the server pays for one item (`sell`) and asks for one (`buy`); 0
/// closes that side.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Price {
pub buy: i64,
pub sell: i64,
}
/// `config.json`'s `market`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub enabled: bool,
/// By item id, `minecraft:diamond`.
pub items: BTreeMap<String, Price>,
}
impl Default for Config {
/// A small starting list the owner edits: farm and mine produce the
/// server buys, a few things it sells, buying always dearer than selling.
fn default() -> Self {
let list: [(&str, i64, i64); 18] = [
("minecraft:wheat", 0, 1),
("minecraft:carrot", 0, 1),
("minecraft:potato", 0, 1),
("minecraft:sugar_cane", 0, 1),
("minecraft:pumpkin", 0, 3),
("minecraft:melon_slice", 0, 1),
("minecraft:cactus", 0, 1),
("minecraft:oak_log", 4, 1),
("minecraft:coal", 8, 2),
("minecraft:iron_ingot", 30, 8),
("minecraft:gold_ingot", 40, 10),
("minecraft:redstone", 4, 1),
("minecraft:lapis_lazuli", 8, 2),
("minecraft:emerald", 80, 20),
("minecraft:diamond", 400, 100),
("minecraft:bread", 5, 0),
("minecraft:cooked_beef", 6, 0),
("minecraft:torch", 1, 0),
];
Self {
enabled: true,
items: list
.iter()
.map(|(id, buy, sell)| {
(
(*id).to_owned(),
Price {
buy: *buy,
sell: *sell,
},
)
})
.collect(),
}
}
}
/// The acting player's items, lent by the game for one command.
pub trait Bag {
/// The main hand: item id, count, plain.
fn held(&self) -> Option<(String, u32, bool)>;
/// Every plain item carried, with how many.
fn carried(&self) -> Vec<(String, u32)>;
fn count(&mut self, item: &str) -> u32;
/// Takes up to `count`; what it took.
fn take(&mut self, item: &str, count: u32) -> u32;
/// How many more fit.
fn room(&mut self, item: &str) -> u32;
/// Gives up to `count` where they fit; what it gave.
fn give(&mut self, item: &str, count: u32) -> u32;
}
/// `diamond` or `minecraft:diamond` as `minecraft:diamond`.
pub fn item_id(name: &str) -> String {
let name = name.to_ascii_lowercase();
if name.contains(':') {
name
} else {
format!("minecraft:{name}")
}
}
/// `minecraft:iron_ingot` as `iron ingot`.
pub fn item_name(id: &str) -> String {
id.strip_prefix("minecraft:")
.unwrap_or(id)
.replace('_', " ")
}
pub struct Market {
pub config: Config,
}
impl Market {
/// Runs `/worth`, `/sell` or `/buy`; None for another command. `trades`
/// is false for a player in creative or spectator mode.
pub fn command(
&self,
economy: &mut Economy,
me: &Player,
trades: bool,
bag: &mut dyn Bag,
line: &str,
) -> Option<Vec<Out>> {
let words: Vec<&str> = line.split_whitespace().collect();
let (&name, args) = words.split_first()?;
if !matches!(name, "worth" | "sell" | "buy") {
return None;
}
if !self.config.enabled {
return Some(vec![Out::Error("The market is closed.".into())]);
}
if name == "worth" {
return Some(self.worth(economy, bag, args));
}
if !trades {
return Some(vec![Out::Error(
"The market trades only with players in survival or adventure mode.".into(),
)]);
}
Some(match name {
"sell" => self.sell(economy, me, bag, args),
_ => self.buy(economy, me, bag, args),
})
}
fn price(&self, id: &str) -> Price {
self.config.items.get(id).copied().unwrap_or_default()
}
fn worth(&self, economy: &Economy, bag: &mut dyn Bag, args: &[&str]) -> Vec<Out> {
let id = match args {
[] => match bag.held() {
Some((id, ..)) => id,
None => {
return vec![Out::Error(
"Hold an item, or name one: /worth <item>".into(),
)];
}
},
[item] => item_id(item),
_ => return vec![Out::Error("Usage: /worth [item]".into())],
};
let p = self.price(&id);
let name = item_name(&id);
let mut parts = Vec::new();
if p.sell > 0 {
parts.push(format!("the market pays {} each", economy.money(p.sell)));
}
if p.buy > 0 {
parts.push(format!("sells it for {} each", economy.money(p.buy)));
}
if parts.is_empty() {
return vec![Out::Reply(format!("The market doesn't trade {name}."))];
}
vec![Out::Reply(format!("{name}: {}.", parts.join(", ")))]
}
fn sell(
&self,
economy: &mut Economy,
me: &Player,
bag: &mut dyn Bag,
args: &[&str],
) -> Vec<Out> {
let usage = || vec![Out::Error("Usage: /sell hand|all|<item> [count]".into())];
// What to sell: (item, at most how many).
let wanted: Vec<(String, u32)> = match args {
["all"] => bag
.carried()
.into_iter()
.filter(|(id, _)| self.price(id).sell > 0)
.collect(),
["hand", rest @ ..] => {
let Some((id, count, plain)) = bag.held() else {
return vec![Out::Error("Your hand is empty.".into())];
};
if !plain {
return vec![Out::Error(
"The market only takes plain items (not worn, named or enchanted).".into(),
)];
}
match rest {
[] => vec![(id, count)],
[n] => match parse_count(n) {
Some(n) => vec![(id, n.min(count))],
None => return usage(),
},
_ => return usage(),
}
}
[item] => vec![(item_id(item), u32::MAX)],
[item, n] => match parse_count(n) {
Some(n) => vec![(item_id(item), n)],
None => return usage(),
},
_ => return usage(),
};
if wanted.is_empty() {
return vec![Out::Error("You carry nothing the market buys.".into())];
}
let mut out = Vec::new();
let mut total = 0i64;
for (id, at_most) in wanted {
let price = self.price(&id).sell;
let name = item_name(&id);
if price <= 0 {
out.push(Out::Error(format!("The market doesn't buy {name}.")));
continue;
}
// Never more than the account can be paid for at once.
let cap = (MAX_AMOUNT / price).min(u32::MAX as i64) as u32;
let have = bag.count(&id).min(at_most).min(cap);
if have == 0 {
out.push(Out::Error(format!("You have no plain {name}.")));
continue;
}
let taken = bag.take(&id, have);
let pay = price * i64::from(taken);
let kind = format!("sell {taken} {id}");
match economy.adjust(&kind, me.uuid, pay, "") {
Ok(_) => {
total += pay;
out.push(Out::Reply(format!(
"Sold {taken} {name} for {}.",
economy.money(pay)
)));
}
Err(e) => {
let back = bag.give(&id, taken);
out.push(Out::Error(format!(
"The sale failed ({e}); {back} {name} given back."
)));
}
}
}
if total > 0 {
out.push(Out::Reply(format!(
"Balance: {}",
economy.money(economy.balance(me.uuid))
)));
}
out
}
fn buy(
&self,
economy: &mut Economy,
me: &Player,
bag: &mut dyn Bag,
args: &[&str],
) -> Vec<Out> {
let (item, count) = match args {
[item] => (*item, 1),
[item, n] => match parse_count(n) {
Some(n) => (*item, n),
None => return vec![Out::Error("Usage: /buy <item> [count]".into())],
},
_ => return vec![Out::Error("Usage: /buy <item> [count]".into())],
};
let id = item_id(item);
let name = item_name(&id);
let price = self.price(&id).buy;
if price <= 0 {
return vec![Out::Error(format!("The market doesn't sell {name}."))];
}
if count > 64 * 36 {
return vec![Out::Error("At most a full inventory at once.".into())];
}
let cost = price * i64::from(count);
if economy.balance(me.uuid) < cost {
return vec![Out::Error(format!(
"{count} {name} cost {}; you have {}.",
economy.money(cost),
economy.money(economy.balance(me.uuid))
))];
}
let room = bag.room(&id);
if room < count {
return vec![Out::Error(format!(
"Only {room} {name} fit in your inventory."
))];
}
if let Err(e) = economy.adjust(&format!("buy {count} {id}"), me.uuid, -cost, "") {
return vec![Out::Error(e)];
}
let given = bag.give(&id, count);
let mut out = vec![Out::Reply(format!(
"Bought {given} {name} for {}.",
economy.money(cost)
))];
if given < count {
// Checked above; kept so money never goes for nothing.
let refund = price * i64::from(count - given);
if economy
.adjust(
&format!("refund {} {id}", count - given),
me.uuid,
refund,
"",
)
.is_ok()
{
out.push(Out::Reply(format!(
"{} refunded for what didn't fit.",
economy.money(refund)
)));
}
}
out.push(Out::Reply(format!(
"Balance: {}",
economy.money(economy.balance(me.uuid))
)));
out
}
}
fn parse_count(s: &str) -> Option<u32> {
parse_amount(s).and_then(|n| u32::try_from(n).ok())
}
#[cfg(test)]
mod tests {
use super::super::smp_economy::Config as EconomyConfig;
use super::*;
use std::fs;
/// A pretend inventory: plain stacks by item, one held item, a room cap.
struct TestBag {
items: BTreeMap<String, u32>,
held: Option<(String, u32, bool)>,
room: u32,
}
impl Bag for TestBag {
fn held(&self) -> Option<(String, u32, bool)> {
self.held.clone()
}
fn carried(&self) -> Vec<(String, u32)> {
self.items.iter().map(|(k, v)| (k.clone(), *v)).collect()
}
fn count(&mut self, item: &str) -> u32 {
self.items.get(item).copied().unwrap_or(0)
}
fn take(&mut self, item: &str, count: u32) -> u32 {
let have = self.items.entry(item.into()).or_default();
let n = (*have).min(count);
*have -= n;
n
}
fn room(&mut self, _item: &str) -> u32 {
self.room
}
fn give(&mut self, item: &str, count: u32) -> u32 {
let n = count.min(self.room);
self.room -= n;
*self.items.entry(item.into()).or_default() += n;
n
}
}
const ALEX: u128 = 1;
fn setup(name: &str) -> (Economy, Market, Player, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("smp-market-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let mut e = Economy::open(&dir, EconomyConfig::default()).unwrap();
e.seen(ALEX, "Alex");
let me = Player {
uuid: ALEX,
name: "Alex".into(),
level: 0,
};
(
e,
Market {
config: Config::default(),
},
me,
dir,
)
}
fn bag(items: &[(&str, u32)], room: u32) -> TestBag {
TestBag {
items: items.iter().map(|(k, v)| ((*k).into(), *v)).collect(),
held: None,
room,
}
}
#[test]
fn selling_takes_the_items_then_pays_and_logs_both() {
let (mut e, m, me, dir) = setup("sell");
let mut b = bag(
&[
("minecraft:diamond", 3),
("minecraft:wheat", 20),
("minecraft:dirt", 64),
],
0,
);
let out = m
.command(&mut e, &me, true, &mut b, "sell diamond 2")
.unwrap();
assert_eq!(out[0], Out::Reply("Sold 2 diamond for 200 coins.".into()));
assert_eq!(b.items["minecraft:diamond"], 1);
let out = m.command(&mut e, &me, true, &mut b, "sell all").unwrap();
assert!(
out.contains(&Out::Reply("Sold 1 diamond for 100 coins.".into())),
"{out:?}"
);
assert!(
out.contains(&Out::Reply("Sold 20 wheat for 20 coins.".into())),
"{out:?}"
);
assert_eq!(b.items["minecraft:dirt"], 64, "dirt has no price");
assert_eq!(e.balance(ALEX), 320);
let log = fs::read_to_string(dir.join("audit.log")).unwrap();
assert_eq!(log.matches("\"sell ").count(), 3, "{log}");
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn selling_the_hand_takes_only_plain_items() {
let (mut e, m, me, dir) = setup("hand");
let mut b = bag(&[("minecraft:iron_ingot", 10)], 0);
b.held = Some(("minecraft:iron_ingot".into(), 10, true));
let out = m.command(&mut e, &me, true, &mut b, "sell hand 4").unwrap();
assert_eq!(out[0], Out::Reply("Sold 4 iron ingot for 32 coins.".into()));
b.held = Some(("minecraft:diamond_sword".into(), 1, false));
let out = m.command(&mut e, &me, true, &mut b, "sell hand").unwrap();
assert_eq!(
out,
[Out::Error(
"The market only takes plain items (not worn, named or enchanted).".into()
)]
);
b.held = None;
let out = m.command(&mut e, &me, true, &mut b, "sell hand").unwrap();
assert_eq!(out, [Out::Error("Your hand is empty.".into())]);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn buying_checks_money_and_room_before_anything_moves() {
let (mut e, m, me, dir) = setup("buy");
e.adjust("test", ALEX, 1000, "").unwrap();
let mut b = bag(&[], 3);
let out = m
.command(&mut e, &me, true, &mut b, "buy diamond 3")
.unwrap();
assert_eq!(
out,
[Out::Error(
"3 diamond cost 1,200 coins; you have 1,000 coins.".into()
)]
);
let out = m.command(&mut e, &me, true, &mut b, "buy torch 4").unwrap();
assert_eq!(
out,
[Out::Error("Only 3 torch fit in your inventory.".into())]
);
assert_eq!(e.balance(ALEX), 1000);
let out = m
.command(&mut e, &me, true, &mut b, "buy minecraft:diamond 2")
.unwrap();
assert_eq!(out[0], Out::Reply("Bought 2 diamond for 800 coins.".into()));
assert_eq!((e.balance(ALEX), b.items["minecraft:diamond"]), (200, 2));
let out = m.command(&mut e, &me, true, &mut b, "buy wheat").unwrap();
assert_eq!(out, [Out::Error("The market doesn't sell wheat.".into())]);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn creative_players_and_a_closed_market_trade_nothing() {
let (mut e, mut m, me, dir) = setup("closed");
let mut b = bag(&[("minecraft:diamond", 64)], 64);
let out = m.command(&mut e, &me, false, &mut b, "sell all").unwrap();
assert_eq!(
out,
[Out::Error(
"The market trades only with players in survival or adventure mode.".into()
)]
);
let worth = m
.command(&mut e, &me, false, &mut b, "worth diamond")
.unwrap();
assert_eq!(
worth,
[Out::Reply(
"diamond: the market pays 100 coins each, sells it for 400 coins each.".into()
)]
);
m.config.enabled = false;
let out = m.command(&mut e, &me, true, &mut b, "sell all").unwrap();
assert_eq!(out, [Out::Error("The market is closed.".into())]);
assert_eq!((e.balance(ALEX), b.items["minecraft:diamond"]), (0, 64));
assert!(m.command(&mut e, &me, true, &mut b, "balance").is_none());
fs::remove_dir_all(dir).unwrap();
}
}
+30 -4
View File
@@ -5,6 +5,8 @@ Steve, `horizon-ctl --offscreen`) join it. Checked from the clients' own chat an
- /balance, /pay (the payer's answer and the payee's line), refused payments (too much, to oneself, unknown name);
- /baltop; /eco is refused below level 3 and works for an operator (made one from the server's console);
- the commands are in each player's command tree (the chat box's suggestions);
- the market (Steve in survival, items given from the console): /worth, /sell <item>, /sell hand, /buy, with the
items gone from or in his inventory as his client sees it; a creative player can't sell;
- after the server stops cleanly and starts again, the balances are the same, and audit.log holds every change.
Everything runs in fresh temporary folders (the server's, each client's home, launcher and XDG folders), no audio.
@@ -144,8 +146,10 @@ def run(ctl, resources, out):
steve.join(port)
time.sleep(2)
data = server.folder / "world" / "horizon-smp"
results["config"] = json.loads((data / "config.json").read_text())
assert results["config"] == {"currency": "coins", "starting_balance": 0}, results["config"]
config = json.loads((data / "config.json").read_text())
results["config_keys"] = sorted(config)
assert results["config_keys"] == ["currency", "market", "starting_balance"], config
assert config["market"]["items"]["minecraft:diamond"] == {"buy": 400, "sell": 100}, config
server.console("op Alex")
time.sleep(1)
alex.say("/eco give Alex 500")
@@ -164,6 +168,27 @@ def run(ctl, resources, out):
results["baltop"] = steve.say("/baltop")
assert any("1. Alex: 380 coins" in l for l in results["baltop"]), results
assert any("2. Steve: 120 coins" in l for l in results["baltop"]), results
server.console("give Steve minecraft:diamond 3")
server.console("give Steve minecraft:wheat 20")
time.sleep(2)
results["worth"] = steve.say("/worth diamond")
assert any("diamond: the market pays 100 coins each, sells it for 400 coins each." in l
for l in results["worth"]), results
results["sell_wheat"] = steve.say("/sell wheat")
assert any("Sold 20 wheat for 20 coins." in l for l in results["sell_wheat"]), results
results["sell_hand"] = steve.say("/sell hand")
assert any("Sold 3 diamond for 300 coins." in l for l in results["sell_hand"]), results
results["buy"] = steve.say("/buy torch 5")
assert any("Bought 5 torch for 5 coins." in l for l in results["buy"]), results
time.sleep(1)
counts = steve.run("inventory").get("counts", {})
results["steve_items"] = counts
have = lambda item: counts.get(f"minecraft:{item}", counts.get(item, 0))
assert (have("diamond"), have("wheat"), have("torch")) == (0, 0, 5), counts
server.console("gamemode creative Alex")
time.sleep(1)
results["creative"] = alex.say("/sell all")
assert any("only with players in survival or adventure mode" in l for l in results["creative"]), results
steve.quit()
alex.quit()
lines = server.stop()
@@ -171,10 +196,11 @@ def run(ctl, resources, out):
economy = json.loads((data / "economy.json").read_text())
balances = {a["name"]: a["balance"] for a in economy["accounts"].values()}
results["economy.json"] = balances
assert balances == {"Alex": 380, "Steve": 120}, balances
assert balances == {"Alex": 380, "Steve": 435}, balances
audit = [json.loads(l) for l in (data / "audit.log").read_text().splitlines()]
results["audit"] = [a["kind"] for a in audit]
assert results["audit"] == ["open", "open", "eco_give", "pay"], results["audit"]
assert results["audit"] == ["open", "open", "eco_give", "pay", "sell 20 minecraft:wheat",
"sell 3 minecraft:diamond", "buy 5 minecraft:torch"], results["audit"]
server.start()
alex = Client(ctl, resources, "Alex2", root)
alex.name = "Alex"
+2
View File
@@ -3,5 +3,7 @@
//! `super::smp_store` reaches the same code in both.
#[path = "../mixins/smp/smp_economy.rs"]
pub mod smp_economy;
#[path = "../mixins/smp/smp_market.rs"]
pub mod smp_market;
#[path = "../mixins/smp/smp_store.rs"]
pub mod smp_store;