mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 11:05:58 -10:00
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:
1 parent
7257e32237
commit
5ba617927f
6 files changed
+200
-8
No files matched your search
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-smp"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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
@@ -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*`) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user