mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 11:05:58 -10:00
Merge pull request #56 from horizon-rust/feat/smp-market
SMP: the server market (/worth, /sell, /buy)
This commit is contained in:
9 files changed
+679
-55
No files matched your search
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-smp"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user