SMP 0.27.0: futures in the game (/futures menu, minute marking)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-01 22:43:47 +01:00
1 parent 7257e32237
commit 5ba617927f
6 files changed
+200 -8

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "horizon-mod-smp"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "horizon-mod-smp"
version = "0.26.0"
version = "0.27.0"
edition = "2024"
license = "MIT"
publish = false
+19 -1
View File
@@ -21,6 +21,7 @@ header and footer).
| `/treasury`, `/treasury log` | the Treasury's balance, its income by tax and its spending (today and since it opened), its last movements |
| `/treasury pay <player> <amount> <reason>` | operators of level 3: pays a player from the Treasury (an event prize, a bug refund, a grant), never more than it holds; everyone sees it |
| `/jobs`, `/jobs deliver <n>` | the day's contracts: deliver items the state asks for, paid from the Treasury |
| `/futures`, `/futures long\|short <item> <margin> [leverage]`, `/futures close <n>`, `/futures info <item>`, `/futures list` | perpetual futures on the market's prices: a chest menu of your positions (profit green, loss red, liquidation price), leverage up to 10x |
| `/eco stats` | the money supply and where it sits, what the server made and destroyed today, taxes and fees, how evenly money is spread |
| `/bank`, `/bank deposit <amount\|all>`, `/bank withdraw <amount\|all>` | your savings, which earn a small interest from the Treasury each night |
| `/ah` | the auction house: a chest menu of listings by category, cheapest a unit first; click one to buy it or bid; your listings (click to take one back) and your mail |
@@ -309,6 +310,23 @@ at most `budget_percent` (10) of the Treasury when posted (an empty Treasury pos
command with the items pays. The board is kept in `economy.json` (`ext.jobs`): a restart neither posts twice nor
loses a delivery.
**Futures.** `/futures long|short <item> <margin> [leverage]` opens a perpetual position on an item's market price:
its size is margin × leverage at the mark, in items. The mark is the market's 10-minute average of the mid
(`Market::mark_price`/`twap`), never the last trade, so one player can't move it in one go; an item needs 5 minutes of
prices first. Leverage: 2x for any traded item, +1x per `volume_per_leverage` (500) items the market traded in 24 h,
up to `max_leverage` (10x). The liquidation price is shown when it opens and in the menu. Each minute (the market's
price timer, never per tick) positions are marked: one whose equity falls to `maintenance_percent` (5%) of its size
is liquidated, online or not, and its whole margin goes to the insurance fund (the player is told when back; a
margin of `announce_margin` (1,000) or more is told to everyone). A player never loses more than the margin: a move
past it is the fund's (counted as `gaps`). The house is every trade's other side through the insurance fund (part of
the Treasury): profits come out of it, losses go in, and a position that would raise the house's net exposure past
what the fund could pay in a `stress_percent` (30%) move is refused. Fees `fee_percent` (0.2%) of the size, to open
and to close, go to the Treasury. Funding, each hour: the side with more open interest pays the fund up to
`funding_percent_hour` (0.05%) of its size, in proportion to the imbalance, from its margins. Caps: `max_player_item`
(20,000 coins of size on one item) and `max_player_total` (50,000), open interest on an item at most
`max_item_volume_days` (3) days of its market volume. Positions and their margins (the `futures` pool) are in
`economy.json` (`ext.futures`), written with the money: a restart keeps them and never liquidates twice.
**The report.** `/eco stats` (anyone) shows the money supply (wallets, savings and money held, the Treasury, the
insurance fund, each pool), what the server made today (market sales, the daily reward) and destroyed (market
purchases, operators' `/eco take`), the day's taxes and fees, and how evenly money is spread (Gini over players
@@ -363,7 +381,7 @@ action bar (API 21), `native_block_spread`, `native_piston_move`, `native_explos
| File | What it is |
|---|---|
| `mixins/smp/horizon-mixin.toml` | The bundle: twenty-eight modules and the HEAD hooks |
| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs`, `smp_recipes.rs`, `smp_shop.rs`, `smp_nick.rs`, `smp_trade.rs`, `smp_games.rs`, `smp_lottery.rs`, `smp_places.rs`, `smp_claims.rs`, `smp_board.rs`, `smp_presence.rs`, `smp_tax.rs`, `smp_auction.rs`, `smp_bank.rs`, `smp_report.rs`, `smp_jobs.rs`, `smp_market_prices.rs`, `smp_market_menu.rs`, `smp_market_chart.rs`, `smp_invsee.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_nick`, `game::smp_trade`, `game::smp_games`, `game::smp_lottery`, `game::smp_places`, `game::smp_claims`, `game::smp_board`, `game::smp_presence`, `game::smp_tax`, `game::smp_auction`, `game::smp_bank`, `game::smp_report`, `game::smp_jobs`, `game::smp_market_prices`, `game::smp_market_menu`, `game::smp_market_chart`, `game::smp_invsee`; also this crate's modules (`src/lib.rs`), for their tests |
| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs`, `smp_recipes.rs`, `smp_shop.rs`, `smp_nick.rs`, `smp_trade.rs`, `smp_games.rs`, `smp_lottery.rs`, `smp_places.rs`, `smp_claims.rs`, `smp_board.rs`, `smp_presence.rs`, `smp_tax.rs`, `smp_auction.rs`, `smp_bank.rs`, `smp_report.rs`, `smp_jobs.rs`, `smp_market_prices.rs`, `smp_market_menu.rs`, `smp_market_chart.rs`, `smp_invsee.rs`, `smp_futures.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_nick`, `game::smp_trade`, `game::smp_games`, `game::smp_lottery`, `game::smp_places`, `game::smp_claims`, `game::smp_board`, `game::smp_presence`, `game::smp_tax`, `game::smp_auction`, `game::smp_bank`, `game::smp_report`, `game::smp_jobs`, `game::smp_market_prices`, `game::smp_market_menu`, `game::smp_market_chart`, `game::smp_invsee`, `game::smp_futures`; 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 |
| `mixins/smp/smp_casino.rs`, `smp_casino_roulette.rs`, `smp_casino_slots.rs`, `smp_casino_crash.rs`, `smp_casino_blackjack.rs` | The casino's rules, menus and effects (`game::smp_casino*`) |
+171 -3
View File
@@ -11,6 +11,7 @@ use crate::game::{
Game,
smp_auction::{self, AuctionHouse},
smp_bank::{self, Bank},
smp_futures::{self, Futures},
smp_jobs::{self, Jobs},
smp_report::{self, Report},
native_server::{NativeCommand, NativeServerPlayer, NativeSidebar},
@@ -42,7 +43,7 @@ use std::{path::PathBuf, sync::Mutex};
/// The mod's id, name and version.
pub const ID: &str = "smp";
pub const NAME: &str = "SMP";
pub const VERSION: &str = "0.26.0";
pub const VERSION: &str = "0.27.0";
/// `config.json` in `world/horizon-smp/`, written with its defaults the
/// first time, and again with the keys a newer version adds (values kept).
@@ -65,6 +66,7 @@ pub struct Config {
pub bank: smp_bank::Config,
pub report: smp_report::Config,
pub jobs: smp_jobs::Config,
pub futures: smp_futures::Config,
pub web: WebPush,
}
@@ -103,6 +105,8 @@ pub struct Smp {
report: Option<Report>,
/// The day's contracts (None with the economy off).
jobs: Option<Jobs>,
/// Positions on the market's prices (None with the economy off).
futures: Option<Futures>,
market: Market,
shop_config: smp_shop::Config,
/// The `/shop` menus open.
@@ -195,6 +199,7 @@ fn open(dir: PathBuf) -> Smp {
let bank = economy.as_ref().ok().map(|e| Bank::open(e, config.bank));
let report = economy.as_ref().ok().map(|e| Report::open(e, config.report));
let jobs = economy.as_ref().ok().map(|e| Jobs::open(e, config.jobs));
let futures = economy.as_ref().ok().map(|e| Futures::open(e, config.futures));
let places = Places::open(&dir, config.places);
if let Err(e) = &places {
log::error!("[smp] homes and warps are off: {e}");
@@ -240,6 +245,7 @@ fn open(dir: PathBuf) -> Smp {
bank,
report,
jobs,
futures,
market,
shop_config: config.shop,
shop: Shop::default(),
@@ -292,6 +298,13 @@ fn save(smp: &mut Smp) {
if let (Some(j), Ok(e)) = (&mut smp.jobs, &mut smp.economy) {
j.store(e);
}
// And the futures' positions (their margins are in the `futures` pool).
if let (Some(f), Ok(e)) = (&mut smp.futures, &mut smp.economy) {
f.store(e);
for p in f.problems.drain(..) {
log::error!("[smp] {p}");
}
}
// So are the auction house's listings, bids and mail.
if let (Some(a), Ok(e)) = (&mut smp.auction, &mut smp.economy) {
a.store(e);
@@ -420,6 +433,12 @@ pub fn commands(commands: &mut Vec<NativeCommand>) {
"money stats",
"jobs",
"jobs deliver <contract:integer>",
"futures",
"futures list",
"futures info <item:item>",
"futures long <item:item> <margin:integer> [leverage:word]",
"futures short <item:item> <margin:integer> [leverage:word]",
"futures close <position:integer>",
] {
commands.push(NativeCommand::new(usage));
}
@@ -463,9 +482,9 @@ impl Bag for GameBag<'_, '_> {
}
}
const COMMANDS: [&str; 20] = [
const COMMANDS: [&str; 21] = [
"balance", "bal", "pay", "baltop", "eco", "money", "worth", "sell", "buy", "shop", "market", "trade", "coinflip",
"lottery", "treasury", "taxes", "ah", "bank", "jobs", "casino",
"lottery", "treasury", "taxes", "ah", "bank", "jobs", "casino", "futures",
];
/// `/pay <player> <amount>`'s receiver (account, name) and amount, as
@@ -482,6 +501,21 @@ fn pay_target(e: &Economy, line: &str) -> Option<(u128, String, i64)> {
Some((to, account.name.clone(), amount))
}
/// The market's prices as the futures read them: the mark is the 10-minute
/// average of the mid (never the last trade), once the market has at least
/// 5 minutes of it; the volume is the last 24 hours'.
struct MarketPrices<'a>(&'a Market);
impl smp_futures::Prices for MarketPrices<'_> {
fn mark(&self, id: &str, now: i64) -> Option<f64> {
self.0.mark_price(id, now)?;
self.0.twap(id, 10, now)
}
fn volume(&self, id: &str, now: i64) -> u64 {
self.0.volume(id, now, 24)
}
}
/// The market's (sell, buy) for one item, as the contracts read it.
fn market_price(market: &Market, id: &str) -> Option<(i64, i64)> {
let p = market.price(id);
@@ -693,6 +727,8 @@ pub(in crate::game) fn perform(
}
Err(_) => vec![Out::Error("The economy is off: its file can't be read (see the server log).".into())],
}
} else if word == "futures" {
futures_command(game, smp, &me, now, line)
} else {
match &mut smp.economy {
Ok(e) => {
@@ -1037,6 +1073,10 @@ pub(in crate::game) fn menu_clicked(game: &mut Game<'_>, uuid: u128, window: i32
shop_clicked(game, smp, uuid, slot, mode == 1);
return;
}
if smp.futures.as_ref().is_some_and(|f| f.views.get(&uuid).is_some_and(|v| v.0 == Some(window))) {
futures_clicked(game, smp, uuid, slot);
return;
}
if smp.market_menus.views.get(&uuid).is_some_and(|v| v.0 == Some(window)) {
market_clicked(game, smp, uuid, slot);
return;
@@ -1095,6 +1135,12 @@ pub(in crate::game) fn menu_closed(game: &mut Game<'_>, uuid: u128, window: i32)
smp.shop.views.remove(&uuid);
return;
}
if let Some(f) = &mut smp.futures
&& f.views.get(&uuid).is_some_and(|v| v.0 == Some(window))
{
f.views.remove(&uuid);
return;
}
if smp.market_menus.views.get(&uuid).is_some_and(|v| v.0 == Some(window)) {
smp.market_menus.views.remove(&uuid);
return;
@@ -1899,6 +1945,112 @@ fn ah_clicked(game: &mut Game<'_>, smp: &mut Smp, uuid: u128, slot: i32) {
tell(game, uuid, out);
}
/// `/futures ...`: a command, or (with no argument) the menu.
fn futures_command(game: &mut Game<'_>, smp: &mut Smp, me: &Player, now: i64, line: &str) -> Vec<Out> {
let (Ok(e), Some(f)) = (&mut smp.economy, &mut smp.futures) else {
return vec![Out::Error("The economy is off: its file can't be read (see the server log).".into())];
};
e.seen(me.uuid, &me.name);
let out = f.command(e, &MarketPrices(&smp.market), me, now, line).unwrap_or_default();
if !out.is_empty() || !line.split_whitespace().eq(["futures"]) {
return out;
}
if smp.trades.trade_of(me.uuid).is_some() {
return vec![Out::Error("Finish or cancel your trade first.".into())];
}
f.views.insert(me.uuid, (None, None));
let items = futures_items(smp, me.uuid, now);
let window = game.native_open_menu(me.uuid, smp_futures::ROWS, Text::literal("Futures"), items);
let f = smp.futures.as_mut().expect("checked");
match window {
Some(w) => {
f.views.insert(me.uuid, (Some(w), None));
Vec::new()
}
None => {
f.views.remove(&me.uuid);
vec![Out::Error("The futures menu couldn't open.".into())]
}
}
}
/// The futures menu: each position with its profit (green) or loss (red)
/// and liquidation price; the insurance fund; help.
fn futures_items(smp: &Smp, uuid: u128, now: i64) -> Vec<Option<ItemStack>> {
let (Some(f), Ok(e)) = (&smp.futures, &smp.economy) else {
return Vec::new();
};
let prices = MarketPrices(&smp.market);
f.layout(uuid)
.into_iter()
.map(|cell| {
use smp_futures::{Cell as C, Prices as _};
Some(match cell {
C::Empty => return None,
C::Position(id, armed) => {
let p = f.position(id)?;
let mark = prices.mark(&p.item, now);
let up = mark.is_none_or(|m| p.pnl(m) >= 0.0);
let line = f.describe(p, mark);
if armed {
button(&p.item, &format!("Click again to close: {line}"), "yellow")
} else {
button(&p.item, &line, if up { "green" } else { "red" })
}
}
C::Fund => button(
"minecraft:gold_block",
&format!(
"Insurance fund: {} · fees so far {} · {} liquidations",
e.money(e.pool(smp_economy::INSURANCE)),
e.money(f.data.fees),
f.data.liquidations
),
"gold",
),
C::Help => button("minecraft:book", smp_futures::HELP_TEXT, "white"),
C::Close => button("minecraft:barrier", "Close", "red"),
})
})
.collect()
}
/// A click in the futures menu: arm a position, close it, or close.
fn futures_clicked(game: &mut Game<'_>, smp: &mut Smp, uuid: u128, slot: i32) {
let Some(p) = game.native_server_players().into_iter().find(|p| p.uuid == uuid) else {
return;
};
let Ok(slot) = usize::try_from(slot) else {
return;
};
if slot >= smp_futures::SIZE {
return;
}
let me = Player {
uuid,
name: p.name.clone(),
level: p.permission,
};
let now = chrono::Utc::now().timestamp_millis();
let (Ok(e), Some(f)) = (&mut smp.economy, &mut smp.futures) else {
return;
};
e.now = now;
let out = match f.click(uuid, slot) {
smp_futures::Click::Nothing => return,
smp_futures::Click::Close => {
f.views.remove(&uuid);
game.native_close_menu(uuid);
return;
}
smp_futures::Click::Redraw => Vec::new(),
smp_futures::Click::Shut(id) => f.close(e, &MarketPrices(&smp.market), &me, id, now),
};
let items = futures_items(smp, uuid, now);
game.native_set_menu_items(uuid, items);
tell(game, uuid, out);
}
/// What a menu click answers, to the player who clicked (no command's
/// feedback there).
fn tell(game: &mut Game<'_>, uuid: u128, out: Vec<Out>) {
@@ -1994,6 +2146,8 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
out.push(Out::Tell(t.uuid, done));
}
}
// Futures menus to draw again after the minute's marking.
let mut redraw: Vec<u128> = Vec::new();
if let Ok(e) = &mut smp.economy
&& n % 20 == 0
{
@@ -2019,6 +2173,16 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
let vat = smp.taxes.as_ref().map_or(0, |t| t.config.vat_percent);
out.extend(a.tick(e, &on, vat, now));
}
// Futures: marked once a minute (liquidations, funding each hour);
// the open menus are drawn again then, with the new profits.
if let Some(f) = &mut smp.futures {
let on: Vec<u128> = players.iter().map(|p| p.uuid).collect();
let minute = f.data.minute;
out.extend(f.tick(e, &MarketPrices(&smp.market), &on, now));
if f.data.minute != minute {
redraw = f.views.iter().filter(|v| v.1.0.is_some()).map(|v| *v.0).collect();
}
}
// The day's contracts: an O(1) check each second, posted once a day.
if let Some(j) = &mut smp.jobs {
let price = |id: &str| market_price(&smp.market, id);
@@ -2082,6 +2246,10 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
}
}
}
for uuid in redraw {
let items = futures_items(smp, uuid, now);
game.native_set_menu_items(uuid, items);
}
// Each trade's offers against the inventories now: a player gone or
// dead ends it; a stack that changed leaves its offer.
let sides: Vec<(u128, String)> = smp
+7 -1
View File
@@ -1,6 +1,6 @@
[mod]
id = "smp"
version = "0.26.0"
version = "0.27.0"
priority = 100
# The commit of horizon it was tested on: it needs horizon#6106 (merged at 975c07d): native_inventory,
@@ -68,6 +68,12 @@ file = "crates/horizon-client/src/game.rs"
name = "smp_jobs"
source = "smp_jobs.rs"
[[module]]
id = "futures"
file = "crates/horizon-client/src/game.rs"
name = "smp_futures"
source = "smp_futures.rs"
[[module]]
id = "bank"
file = "crates/horizon-client/src/game.rs"
+1 -1
View File
@@ -877,7 +877,7 @@ def run(ctl, resources, out):
time.sleep(2)
config = json.loads((data / "config.json").read_text())
results["config_keys"] = sorted(config)
assert results["config_keys"] == ["auction", "bank", "board", "casino", "claims", "currency", "gambling", "jobs",
assert results["config_keys"] == ["auction", "bank", "board", "casino", "claims", "currency", "futures", "gambling", "jobs",
"market", "nicks", "places", "presence", "report", "shop",
"starting_balance", "taxes", "web"], config
results["welcome"] = steve.chat()