mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 11:05:58 -10:00
Merge main and preserve control-tool support for both layouts
Co-Authored-By: Codex <[email protected]>
This commit is contained in:
commit
f3d5967ae6
109 files changed
+2468
-612
No files matched your search
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-smp"
|
||||
version = "0.31.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "horizon-mod-smp"
|
||||
version = "0.31.0"
|
||||
version = "0.33.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
+35
-19
@@ -27,7 +27,7 @@ header and footer).
|
||||
| `/ah cancel <listing>`, `/ah claim`, `/ah search <words>`, `/ah mine`, `/ah mail` | takes back a listing nobody bid on; gives you your mail; the menu on a search, your listings, your mail |
|
||||
| `/shop` | the market as a chest menu: categories, a click buys one, a shift-click 64, a click on a stack of yours sells it, "Sell all" |
|
||||
| `/market [item]` | the moving market as a chest menu: categories, each item's price, its move over 1 h and 24 h and its volume; an item's page buys or sells 1, 16 or 64, sells all you carry, and gives its price chart as a map |
|
||||
| `/worth [item]` | an item's price, the same to buy and to sell (the one in your hand without a name), and what you may still sell today |
|
||||
| `/worth [item]` | an item's price, the same to buy and to sell (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 |
|
||||
| `/trade <player>` | asks a player to trade (60 s to answer) |
|
||||
@@ -43,6 +43,7 @@ header and footer).
|
||||
| `/home [name]`, `/sethome [name]`, `/delhome [name]`, `/homes` | your homes (`home` when unnamed): go, set or move one where you stand, delete, list |
|
||||
| `/warp <name>`, `/warps` | to a warp, the list; `/setwarp <name>`, `/delwarp <name>` for operators of level 3 |
|
||||
| `/tpa <player>`, `/tpaccept [player]`, `/tpdeny [player]` | asks to go to a player (60 s to answer); answers a request |
|
||||
| `/rtp`, `/wild` | to a random safe spot 500 to 5,000 blocks from the spawn, in the Overworld only; once every 30 minutes |
|
||||
| `/rules` | the server's rules |
|
||||
| `/back` | back to where you died, or where your last teleport took you from (warmup and cooldown as any teleport) |
|
||||
| `/afk` | marks you away from the keyboard until you move, turn or run a command |
|
||||
@@ -63,7 +64,7 @@ The market trades only plain items (not worn, named or enchanted), and only with
|
||||
mode. Items move first, then money, both logged; a sale the log refuses gives the items back, and a purchase checks the
|
||||
money and the room before anything moves.
|
||||
|
||||
**No way to get rich from the market.** The market makes money only from what players gather, and only so much a day:
|
||||
**No way to get rich from the market.** The market makes money only from what players gather:
|
||||
- One price per item, to buy and to sell (since 0.29): a config that still has two (`buy` 400, `sell` 100) gets
|
||||
their geometric mean (200) at load, logged ("one price now"); `buy` or `sell` 0 still closes that side. A trade
|
||||
of N items is priced along the curve it moves the price on (below), so buying N and selling them back gives back
|
||||
@@ -76,12 +77,10 @@ money and the room before anything moves.
|
||||
copper, diamond, emerald, coal, redstone, lapis, wheat, wood and food chains at each start.
|
||||
- Items a recipe links (an ingot and its block, wheat and hay...) share one moving price, the block moving it as
|
||||
its 9 ingots do, so crafting never beats the market however prices move; the vanilla-recipe test checks every item
|
||||
at the worst drift (what makes it at 0.6 times its price, it at 1.6) too.
|
||||
- Each player sells at most `daily_sell_coins` (2,000 coins) and `daily_sell_items` (640 of each item) a UTC day,
|
||||
`/worth` and the shop show what is left, and `market.json` keeps the day's count across restarts.
|
||||
- Money received with `/pay` counts toward the receiver's same `daily_sell_coins`: selling up to the limit on alts
|
||||
and paying it all to a main is refused once the main's limit is reached ("can only receive N more today").
|
||||
Trades (`/trade`) and auction house sales are not counted.
|
||||
at the worst drift (what makes it at 0.2 times its price, it at 1.6) too.
|
||||
- There are no daily coin or per-item limits on market sales, or daily limits on `/pay` receipts.
|
||||
The former `daily_sell_coins` and `daily_sell_items` settings and `market.json` counts are ignored,
|
||||
including in existing server files.
|
||||
- Amounts are checked: no price above 1,000,000,000,000, no sum that overflows.
|
||||
- The coinflip and the lottery give the winner all the stakes: they move money between players and never make any.
|
||||
|
||||
@@ -140,15 +139,19 @@ names), `reserved` (the words above).
|
||||
**Moving prices** (`market.dynamic`, on by default since 0.26). Each item has one price: its base price (`items`)
|
||||
times a factor that supply and demand move as each trade happens. Every item bought raises the log of the factor by
|
||||
`1% / depth`, every item sold lowers it as much, where the depth follows the item's rarity (400 / its price, between
|
||||
2 and 400: a diamond moves 50 times faster per item than cobblestone; `depth` overrides it per item). A trade of N is
|
||||
2 and 50: a diamond moves 25 times faster per item than cobblestone, and any item reaches the floor within 10,000
|
||||
sold; `depth` overrides it per item). A trade of N is
|
||||
worth the area under that curve (`price / step × (e^(N × step) - 1)`), rounded up to whole coins when buying and down
|
||||
when selling: one diamond at 200 costs 201 and sells for 199, 64 cobblestone at 1 cost 65, and a sale walks back the
|
||||
exact path of the purchase. All trades together move an item at most `max_minute_move_percent` (5%) a minute from
|
||||
where it started that minute: past that, a purchase is cut short and a sale keeps the rest ("sell them in a
|
||||
minute"). The factor goes back to 1 with a half-life of `half_life_hours` (6) and stays within `min_factor` (0.6)
|
||||
and `max_factor` (1.6), where the rest of a trade goes at that price. There is no tax on trades (an older config's
|
||||
`tax_percent` is ignored): the receipt is the price ("Sold 3 diamond at 198 coins each: 595 coins."). Items a player bought from the market in the last `round_trip_hours` (24) can't be
|
||||
sold back to it. (`max_trade_impact_percent` and `max_player_impact_percent` are no longer used: the curve and the
|
||||
minute"). The factor goes back to 1 with a half-life of `half_life_hours` (6) and stays within `min_factor` (0.2)
|
||||
and `max_factor` (1.6), where the rest of a trade goes at that price. With no daily sale caps (since 0.33), the floor
|
||||
is what bounds a farm: selling one item without end takes its price down to a fifth of its base, where it stays while
|
||||
the selling goes on; a config with the old floor (0.6) gets 0.2 when it loads. There is no tax on trades (an older config's
|
||||
`tax_percent` is ignored): the receipt is the price ("Sold 3 diamond at 198 coins each: 595 coins."). Market purchases can be sold back immediately;
|
||||
the former `round_trip_hours` setting and saved purchase receipts are ignored. (`max_trade_impact_percent` and
|
||||
`max_player_impact_percent` are no longer used: the curve and the
|
||||
minute's band do their work.) History: one point a minute for 24 hours and one an hour for 30 days (the mid price, the
|
||||
items traded and those sold), in `market_prices.json`, written by a thread every 5 minutes and when the world closes.
|
||||
At the first minute of each UTC day, the day's three biggest risers and fallers (at least 1%) and news from real
|
||||
@@ -158,7 +161,7 @@ fixed prices of 0.23. For other mods: `Market::spot`, `volume(id, now, hours)`,
|
||||
|
||||
**`/market`.** A 6-row chest menu: the shop's categories and the day's movers; a category's items, each with its
|
||||
price, its change over 1 h and 24 h (green ▲, red ▼) and what was traded in 24 h; an item's page with its
|
||||
base price and what you may still sell today, buttons to buy or sell 1, 16 and 64 (what they come to
|
||||
base price , buttons to buy or sell 1, 16 and 64 (what they come to
|
||||
along the curve), sell all of it you carry, and the price chart over 24 hours or 7 days, given as a filled map (128 × 128,
|
||||
drawn by the server: the curve green when it rose, red when it fell, the price, the change, the low and high). A
|
||||
chart map is drawn again each minute for whoever carries it, and once a minute the open menus follow the prices.
|
||||
@@ -167,8 +170,8 @@ chart map is drawn again each minute for whoever carries it, and once a minute t
|
||||
**The shop.** `/shop` opens a 6-row chest menu on the market's prices: the categories of `config.json`'s `shop` (an
|
||||
item in none goes under "Other"), each item named with its prices. A click buys one, a shift-click 64; a click on a
|
||||
stack in your own inventory, below the menu, sells it; "Sell all" sells everything the market buys. The bottom row
|
||||
shows your balance and today's selling limit. It goes through the market's own rules (survival or adventure only,
|
||||
items then money, logged, the daily limits); the items move by the console's `clear` and `give`, measured on the
|
||||
shows your balance. It goes through the market's own rules (survival or adventure only,
|
||||
items then money, logged); the items move by the console's `clear` and `give`, measured on the
|
||||
inventory before and after, so the money follows what really moved.
|
||||
|
||||
**Trades.** Once accepted, both players see a 6-row chest menu: their own offer on the left (16 stacks), the other's
|
||||
@@ -226,6 +229,20 @@ was on (`homes`: `[hours, homes]`, by default 1 home, 2 from 5 hours, 3 from 20,
|
||||
lines. `/spawn` lands on a random safe column within `spawn_radius` (250) blocks of the spawn point, like vanilla's `respawn_radius` for a first join or a respawn: after the `tp`, vanilla's `spreadplayers` moves the player off fluid, fire and magma onto solid ground (0: exactly the spawn point; if no column is found the player stays at the spawn point). Teleports are vanilla's `tp`, run as the server (`execute in <level> run tp`), so they cross levels and land
|
||||
as any teleport does.
|
||||
|
||||
`/rtp` (or `/wild`) draws `rtp_attempts` (6) chunks at random between `rtp_min_distance` (500) and `rtp_max_distance`
|
||||
(5000) blocks from the spawn (`/setspawn`'s point, 0 0 without one), uniform over the ring's area and at least 16 blocks
|
||||
inside the world border (as last saved in `world_border.dat`), then holds them loaded one after another with vanilla's
|
||||
`forceload` while the warmup runs: the game generates them on its worker threads, never on the server tick. Once a
|
||||
chunk is loaded, the Overworld's tick reads its 256 columns (`native_read_columns`, the top block below the
|
||||
`MOTION_BLOCKING_NO_LEAVES` height and the two above it) and the player lands, still standing still, on a random one
|
||||
whose top block is solid ground (no fluid, fire, cactus, magma, powder snow, berry bush or dripstone) with air or grass
|
||||
above it, outside another player's claim and still in the ring and the border; with none the next chunk is tried. With
|
||||
no spot after the six, or no land loaded `rtp_search_seconds` (30) after the warmup, it says so and nothing is spent.
|
||||
A teleport made starts `rtp_cooldown_seconds` (1800) for that player, kept in `places.json` over restarts ("Wait 12 min
|
||||
before your next /rtp."); a teleport called off (a move, a hit, the game failing it) doesn't. Elsewhere than the
|
||||
Overworld it refuses. The chunks it forced are let go 10 s after the landing, or at the next start if the server
|
||||
stopped first; a chunk an operator forced already is used and left forced. `/back` returns from it as from any teleport.
|
||||
|
||||
**Claims.** In a claimed chunk, only its owner, the players they trust and operators of level 3 break blocks or right
|
||||
click blocks: doors, chests, levers, buttons, placing a block, a bucket or flint on a block. Anyone else's click does
|
||||
nothing, and the action bar says whose land it is. The chunks a player may hold grow with the time played here
|
||||
@@ -327,14 +344,13 @@ folder is untouched until the mod first runs on it; it changes no block and no p
|
||||
|
||||
| File | Holds |
|
||||
|---|---|
|
||||
| `config.json` | `currency` (default `coins`), `starting_balance` (default 0), `market`: `enabled`, `items` (each item's `buy`, what the market asks, and `sell`, what it pays, 0 closing that side), `daily_sell_coins` (2000) and `daily_sell_items` (640), 0 for no limit; `shop`: `enabled`, `title`, `categories` (`name`, `icon`, `items`); `web`: `url` (Kira's `/smp/ingest`) and `token` (empty: nothing is sent), where the pages of `web/` go; written with the defaults the first time, and again when a newer version adds keys (your values kept) |
|
||||
| `config.json` | `currency` (default `coins`), `starting_balance` (default 0), `market`: `enabled`, `items` (each item's `buy`, what the market asks, and `sell`, what it pays, 0 closing that side), `shop`: `enabled`, `title`, `categories` (`name`, `icon`, `items`); `web`: `url` (Kira's `/smp/ingest`) and `token` (empty: nothing is sent), where the pages of `web/` go; 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; `held`: coinflip stakes and lottery tickets being decided; `pools`: the casino's pot and the auction house's bids; `ext`: other parts' state written in the same step as the money (`auction`: the listings, bids, mail and notes) |
|
||||
| `places.json` | the spawn, the warps, each player's homes, and the seconds each player has played (written once a minute) |
|
||||
| `nicks.json` | each player's nickname and when they last changed it |
|
||||
| `vanish.json` | the vanished operators by UUID, with their name |
|
||||
| `staff.log` | one line per `/invsee` or `/endersee` menu in which something moved: who, whose, what was taken and put |
|
||||
| `market.json` | what each player sold today (coins and items), for the daily limits |
|
||||
| `market_prices.json` | each item's price factor and history (minutes for 24 h, hours for 30 days: `[time, mid, traded, sold]`), what each player bought in the last day (no selling it back), the day's summary |
|
||||
| `market_prices.json` | each item's price factor and history (minutes for 24 h, hours for 30 days: `[time, mid, traded, sold]`), the day's summary |
|
||||
| `web/market.json` | for the website (`/hub/market`, through Kira), written by a thread each minute prices move: `schema` 1, `generated`, `currency`, `tax_percent` (always 0, for older readers), `daily_summary` (`day`, `lines`), `items` (`id`, `name`, `category`, `price` (the one price; `buy` and `sell` repeat it on the sides the market trades, for older readers), `mid`, `base`, `change_1h`, `change_24h`, `volume_24h`, `day`: 5-minute points, `month`: hours, each `{step, t, price, volume}`). When `web.token` is set, the same thread POSTs each page of `web/` that changed (gzipped, `Authorization: Bearer <token>`) to `<web.url>/<name>`: Kira keeps the last one and serves it to the site; the server needs no inbound access and Kira no key to it |
|
||||
| `presence.json` | the players who have joined (no welcome again), and the day each last got the daily reward |
|
||||
| `board.json` | the players who hid the sidebar (`/sidebar off`) |
|
||||
|
||||
+109
-42
@@ -2,7 +2,7 @@
|
||||
// between the game's types and the rules (smp_economy, smp_market, smp_shop,
|
||||
// smp_trade, smp_games, smp_lottery, smp_places, smp_claims). Added to
|
||||
// the game as `game::smp_glue`; the helpers (register.rs, command.rs,
|
||||
// tick.rs, lifecycle.rs, menu_clicked.rs, menu_closed.rs, destroy_block.rs,
|
||||
// tick.rs, level_tick.rs, lifecycle.rs, menu_clicked.rs, menu_closed.rs, destroy_block.rs,
|
||||
// right_click_block.rs, block_spread.rs, piston_move.rs,
|
||||
// explosion_detonate.rs, fill_bucket.rs, attack_entity.rs) call it from the
|
||||
// hook points.
|
||||
@@ -41,7 +41,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.31.0";
|
||||
pub const VERSION: &str = "0.33.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).
|
||||
@@ -143,6 +143,11 @@ static BUSY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(
|
||||
/// Sleep-count changes decided inside a player's command, for the next
|
||||
/// second's tick.
|
||||
static PENDING_SLEEP: Mutex<Vec<smp_presence::Act>> = Mutex::new(Vec::new());
|
||||
/// Whether an `/rtp` waits for its land: the Overworld's tick reads it then.
|
||||
static RTP: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// What each `/rtp`'s reads cost the Overworld's tick, by player: reads,
|
||||
/// total and longest (µs), for the line logged when they land.
|
||||
static RTP_COST: Mutex<Vec<(u128, u32, u64, u64)>> = Mutex::new(Vec::new());
|
||||
|
||||
fn open(dir: PathBuf) -> Smp {
|
||||
let config_path = dir.join("config.json");
|
||||
@@ -305,9 +310,6 @@ fn save(smp: &mut Smp) {
|
||||
{
|
||||
log::error!("[smp] {err}");
|
||||
}
|
||||
if let Err(err) = smp.market.save() {
|
||||
log::error!("[smp] {err}");
|
||||
}
|
||||
if let Ok(p) = &mut smp.places
|
||||
&& let Err(err) = p.save(chrono::Utc::now().timestamp_millis())
|
||||
{
|
||||
@@ -392,6 +394,8 @@ pub fn commands(commands: &mut Vec<NativeCommand>) {
|
||||
"tpdeny [player:word]",
|
||||
"rules",
|
||||
"back",
|
||||
"rtp",
|
||||
"wild",
|
||||
"playtime [player:word]",
|
||||
"claim",
|
||||
"claim info",
|
||||
@@ -592,18 +596,91 @@ const COMMANDS: [&str; 16] = [
|
||||
"lottery", "ah", "casino",
|
||||
];
|
||||
|
||||
/// `/pay <player> <amount>`'s receiver (account, name) and amount, as
|
||||
/// `Economy::pay` reads them; None for another command.
|
||||
fn pay_target(e: &Economy, line: &str) -> Option<(u128, String, i64)> {
|
||||
let [cmd, who, amount] = line.split_whitespace().collect::<Vec<_>>()[..] else {
|
||||
return None;
|
||||
/// The world border, from the Overworld's `world_border.dat` (the root's in
|
||||
/// older worlds), as the game last saved it; None without one (the default
|
||||
/// border, 60 million blocks wide).
|
||||
fn world_border(world: &std::path::Path) -> Option<smp_places::Bounds> {
|
||||
let files = [
|
||||
world.join("dimensions/minecraft/overworld/data/minecraft/world_border.dat"),
|
||||
world.join("data/minecraft/world_border.dat"),
|
||||
];
|
||||
let root = files
|
||||
.iter()
|
||||
.find_map(|f| horizon_world::datafix::files::read_saved_dat(f).ok().flatten())?;
|
||||
let border = horizon_client::worldborder::WorldBorder::unpack(root.get("data")?);
|
||||
Some(border.bounds())
|
||||
}
|
||||
|
||||
/// After a level's tick: in the Overworld, each `/rtp` that waits reads the
|
||||
/// chunk it loads (the game reads only the level it ticks), five times a
|
||||
/// second. Columns in another player's claim are never a spot.
|
||||
pub(in crate::game) fn level_ticked(game: &mut Game<'_>, dimension: &str) {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
if dimension != smp_places::OVERWORLD
|
||||
|| !RTP.load(Relaxed)
|
||||
|| TICKS.load(Relaxed) % 4 != 0
|
||||
|| !game.native_on(ID)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(dir) = game.native_world_dir() else {
|
||||
return;
|
||||
};
|
||||
if cmd != "pay" {
|
||||
let mut guard = state(dir.join(smp_store::DIR));
|
||||
let Some(smp) = guard.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let (Ok(places), claims) = (&mut smp.places, &smp.claims) else {
|
||||
return;
|
||||
};
|
||||
let game = &*game;
|
||||
let mut cost = RTP_COST.lock().unwrap_or_else(|e| e.into_inner());
|
||||
places.rtp_look(&mut |uuid, (cx, cz)| {
|
||||
let started = std::time::Instant::now();
|
||||
let ground = read_chunk(game, claims, uuid, (cx, cz));
|
||||
let us = started.elapsed().as_micros() as u64;
|
||||
match cost.iter_mut().find(|c| c.0 == uuid) {
|
||||
Some(c) => *c = (uuid, c.1 + 1, c.2 + us, c.3.max(us)),
|
||||
None => cost.push((uuid, 1, us, us)),
|
||||
}
|
||||
ground
|
||||
});
|
||||
RTP.store(places.rtp_searching(), Relaxed);
|
||||
}
|
||||
|
||||
/// Chunk `(cx, cz)` of the Overworld as `/rtp` reads it, None while it isn't
|
||||
/// loaded; no columns when another player claimed it.
|
||||
fn read_chunk(
|
||||
game: &Game<'_>,
|
||||
claims: &Result<Claims, String>,
|
||||
uuid: u128,
|
||||
(cx, cz): (i32, i32),
|
||||
) -> Option<smp_places::Ground> {
|
||||
let columns: Vec<(i32, i32)> = (0..256).map(|i| (cx * 16 + i / 16, cz * 16 + i % 16)).collect();
|
||||
let read = game
|
||||
.native_read_columns(
|
||||
smp_places::OVERWORLD,
|
||||
&columns,
|
||||
crate::game::native_world_edit::NativeHeightmap::MotionBlockingNoLeaves,
|
||||
(1, 2),
|
||||
)
|
||||
.ok()?;
|
||||
if read.bases.iter().any(Option::is_none) {
|
||||
return None;
|
||||
}
|
||||
let amount = smp_economy::parse_amount(amount)?;
|
||||
let (to, account) = e.find(who)?;
|
||||
Some((to, account.name.clone(), amount))
|
||||
let mut ground = smp_places::Ground { palette: read.palette, columns: Vec::new() };
|
||||
let claimed = claims
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|c| c.owner(smp_places::OVERWORLD, cx * 16, cz * 16))
|
||||
.is_some_and(|owner| owner != uuid);
|
||||
if !claimed {
|
||||
for (i, (&(x, z), base)) in columns.iter().zip(&read.bases).enumerate() {
|
||||
let b = &read.blocks[i * 3..i * 3 + 3];
|
||||
ground.columns.push((x, z, base.unwrap_or_default(), [b[0], b[1], b[2]]));
|
||||
}
|
||||
}
|
||||
Some(ground)
|
||||
}
|
||||
|
||||
/// The players on, as the places see them.
|
||||
@@ -820,6 +897,11 @@ pub(in crate::game) fn perform(
|
||||
} else if smp_places::COMMANDS.contains(&word) {
|
||||
match &mut smp.places {
|
||||
Ok(p) => {
|
||||
if matches!(word, "rtp" | "wild") {
|
||||
p.border = world_border(&dir);
|
||||
RTP_COST.lock().unwrap_or_else(|e| e.into_inner()).retain(|c| c.0 != me.uuid);
|
||||
BUSY.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
let seen: Vec<NativeServerPlayer> = players.iter().filter(|p| !hidden.contains(&p.uuid)).cloned().collect();
|
||||
p.command(&me, &bodies(&seen), now, line).unwrap_or_default()
|
||||
}
|
||||
@@ -850,27 +932,7 @@ pub(in crate::game) fn perform(
|
||||
let report = smp.report.as_ref();
|
||||
report
|
||||
.and_then(|r| r.command(e, line))
|
||||
.or_else(|| {
|
||||
// A payment counts toward the receiver's daily selling
|
||||
// limit (alts can't sell for a main past it).
|
||||
let paid = pay_target(e, line);
|
||||
if let Some((to, name, amount)) = &paid
|
||||
&& let Err(left) = smp.market.may_receive(*to, *amount, now)
|
||||
{
|
||||
return Some(vec![Out::Error(format!(
|
||||
"{name} can only receive {} more today: payments count toward the same daily limit as selling to the market.",
|
||||
e.money(left)
|
||||
))]);
|
||||
}
|
||||
let before = paid.as_ref().map(|(to, ..)| e.balance(*to));
|
||||
let out = e.command(&me, &on, line);
|
||||
if let (Some((to, _, amount)), Some(b)) = (paid, before)
|
||||
&& e.balance(to) == b + amount
|
||||
{
|
||||
smp.market.count_received(to, now, amount);
|
||||
}
|
||||
out
|
||||
})
|
||||
.or_else(|| e.command(&me, &on, line))
|
||||
.or_else(|| smp.market.command(e, &me, trades, &mut bag, line))
|
||||
.or_else(|| {
|
||||
let (out, a) = smp.trades.command(e, &me, trades, &online, now, line)?;
|
||||
@@ -1509,10 +1571,6 @@ fn shop_items(smp: &Smp, uuid: u128) -> Vec<Option<ItemStack>> {
|
||||
button("minecraft:gold_ingot", &format!("Balance: {}", money(b)), "gold")
|
||||
}
|
||||
C::SellAll => button("minecraft:hopper", "Sell everything the market buys", "green"),
|
||||
C::Limits => {
|
||||
let line = economy.and_then(|e| smp.market.limits_line(e, uuid));
|
||||
button("minecraft:clock", &line.unwrap_or_else(|| "No selling limit".into()), "aqua")
|
||||
}
|
||||
C::Help => button("minecraft:book", smp_shop::HELP_TEXT, "white"),
|
||||
C::Close => button("minecraft:barrier", "Close", "red"),
|
||||
})
|
||||
@@ -1693,9 +1751,6 @@ fn market_items(smp: &Smp, uuid: u128, now: i64) -> Vec<Option<ItemStack>> {
|
||||
let mut lines = price_lines(smp, &id, now);
|
||||
let base = smp.market.base(&id);
|
||||
lines.push((format!("Base price: {}", money(base.buy.max(base.sell))), "gray"));
|
||||
if let Some(left) = smp.market.items_left(uuid, now, &id) {
|
||||
lines.push((format!("You may still sell {left} today"), "gray"));
|
||||
}
|
||||
with_lore(button(&id, &name(&id), "white"), &lines)
|
||||
}
|
||||
C::Buy(id, n) => {
|
||||
@@ -2293,8 +2348,20 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
|
||||
{
|
||||
log::warn!("[smp] no safe spot near the spawn: {spread}");
|
||||
}
|
||||
if t.what == "a random spot" {
|
||||
let mut cost = RTP_COST.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (_, n, total, max) = cost.iter().find(|c| c.0 == t.uuid).copied().unwrap_or_default();
|
||||
cost.retain(|c| c.0 != t.uuid);
|
||||
log::info!(
|
||||
"[smp] /rtp: {} to {:.1} {:.1} {:.1} ({n} chunk reads on the tick: {total} µs, longest {max} µs)",
|
||||
t.name, t.to.x, t.to.y, t.to.z
|
||||
);
|
||||
}
|
||||
out.push(Out::Tell(t.uuid, t.done.clone()));
|
||||
}
|
||||
// `/rtp`'s chunks: held loaded while it looks, let go after.
|
||||
p.force(now, &mut |line| game.native_run_command(line));
|
||||
RTP.store(p.rtp_searching(), Relaxed);
|
||||
}
|
||||
if let Ok(e) = &mut smp.economy
|
||||
&& n % 20 == 0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[mod]
|
||||
id = "smp"
|
||||
version = "0.31.0"
|
||||
version = "0.33.0"
|
||||
priority = 100
|
||||
|
||||
# The commit of horizon it was tested on: it needs native_hide_player (horizon#7003, API 314, stamped at 48c63bc),
|
||||
@@ -213,6 +213,16 @@ bindings = [
|
||||
{ name = "phase", ty = "crate::game::native_lifecycle::ServerTickPhase", value = "phase" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "level_tick"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_level_tick_post"
|
||||
helper = "level_tick.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
{ name = "dimension", ty = "&str", value = "dimension" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "lifecycle"
|
||||
kind = "HEAD"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// After a level's tick: in the Overworld, the `/rtp`s waiting for their land
|
||||
// read it (the game reads only the level it ticks).
|
||||
fn smp_level_tick_post(game: &mut crate::game::Game<'_>, dimension: &str) {
|
||||
crate::game::smp_glue::level_ticked(game, dimension);
|
||||
}
|
||||
+255
-372
@@ -7,16 +7,16 @@
|
||||
// log refuses gives the items back. Nobody gets rich from it: buying always
|
||||
// costs more than selling (an item priced otherwise isn't bought), no chain
|
||||
// of recipes turns bought items into dearer ones (smp_recipes; such an item
|
||||
// isn't bought either), and what a player sells is capped each UTC day, in
|
||||
// coins and per item (`market.json` counts it). No game types: the glue
|
||||
// isn't bought either). No daily sale caps. No game types: the glue
|
||||
// lends the 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 super::smp_market_prices::{
|
||||
Book, Dynamic, MINUTE_MS, Point, Quote, Span, arrow, base_mid, one_price, to_get, to_pay,
|
||||
Book, Dynamic, MIN_FACTOR, MINUTE_MS, OLD_MIN_FACTOR, Point, Quote, Span, arrow, base_mid,
|
||||
one_price, to_get, to_pay,
|
||||
};
|
||||
use super::smp_store::{read_json, uuid_string, write_json};
|
||||
use super::smp_store::read_json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
|
||||
@@ -39,12 +39,6 @@ pub struct Config {
|
||||
pub enabled: bool,
|
||||
/// By item id, `minecraft:diamond`. Items that stack to 64.
|
||||
pub items: BTreeMap<String, Price>,
|
||||
/// The most a player earns selling to the market in a UTC day (0: no
|
||||
/// limit).
|
||||
pub daily_sell_coins: i64,
|
||||
/// The most of one item a player sells to the market in a UTC day (0:
|
||||
/// no limit).
|
||||
pub daily_sell_items: u32,
|
||||
/// Prices that move with supply and demand (smp_market_prices); the
|
||||
/// item prices above are then the base ones.
|
||||
pub dynamic: Dynamic,
|
||||
@@ -119,8 +113,7 @@ pub const PRICES: &[(&str, i64, i64)] = &[
|
||||
];
|
||||
|
||||
impl Default for Config {
|
||||
/// `PRICES` (one price where the market trades both ways), and the
|
||||
/// daily limits.
|
||||
/// `PRICES` (one price where the market trades both ways).
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
@@ -137,8 +130,6 @@ impl Default for Config {
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
daily_sell_coins: 2000,
|
||||
daily_sell_items: 640,
|
||||
dynamic: Dynamic::default(),
|
||||
}
|
||||
}
|
||||
@@ -167,9 +158,20 @@ const OLD_PRICES: [(&str, i64, i64); 18] = [
|
||||
("torch", 1, 0),
|
||||
];
|
||||
|
||||
/// A config whose prices are still the old defaults, untouched: today's
|
||||
/// defaults instead. Whether it changed (the file is written again).
|
||||
/// A config whose prices, or price floor, are still the old defaults,
|
||||
/// untouched: today's defaults instead. Whether it changed (the file is
|
||||
/// written again).
|
||||
pub fn migrate(config: &mut Config) -> bool {
|
||||
// 0.33 dropped the daily sale caps: the floor (60% of the base before)
|
||||
// goes down to 20%, so selling without end pays less and less.
|
||||
let floor = (config.dynamic.min_factor - OLD_MIN_FACTOR).abs() < 1e-9;
|
||||
if floor {
|
||||
config.dynamic.min_factor = MIN_FACTOR;
|
||||
}
|
||||
migrate_prices(config) || floor
|
||||
}
|
||||
|
||||
fn migrate_prices(config: &mut Config) -> bool {
|
||||
let old: BTreeMap<String, Price> = OLD_PRICES
|
||||
.iter()
|
||||
.map(|(id, buy, sell)| {
|
||||
@@ -189,29 +191,11 @@ pub fn migrate(config: &mut Config) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// What one player sold today.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Sold {
|
||||
/// The UTC day (days since 1970) the counts are for.
|
||||
pub day: i64,
|
||||
pub coins: i64,
|
||||
pub items: BTreeMap<String, u32>,
|
||||
}
|
||||
|
||||
/// `market.json`: what each player sold today, by UUID.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Ledger {
|
||||
pub sold: BTreeMap<String, Sold>,
|
||||
}
|
||||
|
||||
/// A config's prices made safe: an amount out of range closes that side; an
|
||||
/// item traded both ways gets one price; an item a chain of recipes makes
|
||||
/// for less than it sells for is no longer bought. What changed, to log.
|
||||
pub fn validate(config: &mut Config) -> Vec<String> {
|
||||
let mut closed = Vec::new();
|
||||
config.daily_sell_coins = config.daily_sell_coins.clamp(0, MAX_AMOUNT);
|
||||
config.dynamic.validate();
|
||||
for (id, p) in config.items.iter_mut() {
|
||||
if !valid_id(id) {
|
||||
@@ -315,10 +299,8 @@ pub fn item_name(id: &str) -> String {
|
||||
|
||||
pub struct Market {
|
||||
pub config: Config,
|
||||
ledger: Ledger,
|
||||
/// `market.json`; None in tests that keep nothing.
|
||||
path: Option<PathBuf>,
|
||||
dirty: bool,
|
||||
/// The SMP data directory; None in tests that keep nothing.
|
||||
dir: Option<PathBuf>,
|
||||
/// The moving prices and their history (`market_prices.json`, written
|
||||
/// by the glue off the tick thread: `book_to_write`).
|
||||
pub book: Book,
|
||||
@@ -368,9 +350,7 @@ const BOOK_EVERY_MS: i64 = 5 * MINUTE_MS;
|
||||
|
||||
impl Market {
|
||||
/// The market of `dir` (`world/horizon-smp/`), its config made safe
|
||||
/// (`validate`), with what to log. A `market.json` that can't be read
|
||||
/// starts the day's counts from nothing (they only limit, never hold
|
||||
/// money).
|
||||
/// (`validate`), with what to log.
|
||||
pub fn open(dir: &std::path::Path, mut config: Config) -> (Self, Vec<String>) {
|
||||
let mids = |c: &Config| -> BTreeMap<String, f64> {
|
||||
c.items
|
||||
@@ -383,11 +363,6 @@ impl Market {
|
||||
.into_iter()
|
||||
.map(|l| format!("market: {l}"))
|
||||
.collect();
|
||||
let path = dir.join("market.json");
|
||||
let ledger = read_json(&path).unwrap_or_else(|e| {
|
||||
problems.push(format!("{e}: today's sale limits start again"));
|
||||
Ledger::default()
|
||||
});
|
||||
let mut book: Book = read_json(&dir.join("market_prices.json")).unwrap_or_else(|e| {
|
||||
problems.push(format!("{e}: prices start again from their base"));
|
||||
Book::default()
|
||||
@@ -401,9 +376,7 @@ impl Market {
|
||||
}
|
||||
let mut market = Self {
|
||||
config,
|
||||
ledger,
|
||||
path: Some(path),
|
||||
dirty: false,
|
||||
dir: Some(dir.to_owned()),
|
||||
book,
|
||||
book_written: 0,
|
||||
book_dirty: !rebased.is_empty(),
|
||||
@@ -419,9 +392,7 @@ impl Market {
|
||||
pub fn new(config: Config) -> Self {
|
||||
let mut m = Self {
|
||||
config,
|
||||
ledger: Ledger::default(),
|
||||
path: None,
|
||||
dirty: false,
|
||||
dir: None,
|
||||
book: Book::default(),
|
||||
book_written: 0,
|
||||
book_dirty: false,
|
||||
@@ -446,18 +417,21 @@ impl Market {
|
||||
}
|
||||
|
||||
/// How far one `id` moves the log of its factor: in a family, by value
|
||||
/// (a block of 9 moves it as 9 ingots do), so crafting can't beat it.
|
||||
/// (a block of 9 moves it as 9 ingots do), so crafting can't beat it,
|
||||
/// from its cheapest member's depth (so that every member, not only
|
||||
/// the cheapest, reaches the floor within `MAX_DEPTH` items a 1%).
|
||||
fn unit_move(&self, id: &str) -> f64 {
|
||||
let d = &self.config.dynamic;
|
||||
let mid = |id: &str| {
|
||||
let b = self.base(id);
|
||||
base_mid(b.buy, b.sell)
|
||||
};
|
||||
match self
|
||||
.family
|
||||
.get(id)
|
||||
.and_then(|f| f.iter().chain([&id.to_owned()]).min().cloned())
|
||||
{
|
||||
match self.family.get(id).and_then(|f| {
|
||||
f.iter()
|
||||
.chain([&id.to_owned()])
|
||||
.min_by(|a, b| mid(a).total_cmp(&mid(b)).then_with(|| a.cmp(b)))
|
||||
.cloned()
|
||||
}) {
|
||||
Some(first) if mid(&first) > 0.0 => {
|
||||
Book::unit_move(d, &first, mid(&first)) / mid(&first) * mid(id)
|
||||
}
|
||||
@@ -485,82 +459,6 @@ impl Market {
|
||||
self.spot(id).map_or(0.0, |q| q.mid)
|
||||
}
|
||||
|
||||
pub fn save(&mut self) -> Result<(), String> {
|
||||
if !self.dirty {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(path) = &self.path {
|
||||
write_json(path, &self.ledger)?;
|
||||
}
|
||||
self.dirty = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What `uuid` sold today (`now`: milliseconds since 1970).
|
||||
fn sold(&self, uuid: u128, now: i64) -> Sold {
|
||||
match self.ledger.sold.get(&uuid_string(uuid)) {
|
||||
Some(s) if s.day == now.div_euclid(DAY_MS) => s.clone(),
|
||||
_ => Sold::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The coins `uuid` may still earn selling today (None: no limit).
|
||||
pub fn coins_left(&self, uuid: u128, now: i64) -> Option<i64> {
|
||||
let cap = self.config.daily_sell_coins;
|
||||
(cap > 0).then(|| (cap - self.sold(uuid, now).coins).max(0))
|
||||
}
|
||||
|
||||
/// How many of `id` `uuid` may still sell today (None: no limit).
|
||||
pub fn items_left(&self, uuid: u128, now: i64, id: &str) -> Option<u32> {
|
||||
let cap = self.config.daily_sell_items;
|
||||
(cap > 0)
|
||||
.then(|| cap.saturating_sub(self.sold(uuid, now).items.get(id).copied().unwrap_or(0)))
|
||||
}
|
||||
|
||||
/// Whether `uuid` may be paid `amount` by another player now (`/pay`):
|
||||
/// money received counts toward the same daily limit as selling to the
|
||||
/// market, so selling on alts and paying it all to a main doesn't get
|
||||
/// past it. Err: what they may still receive today.
|
||||
pub fn may_receive(&self, uuid: u128, amount: i64, now: i64) -> Result<(), i64> {
|
||||
match self.coins_left(uuid, now) {
|
||||
Some(left) if amount > left => Err(left),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts `coins` paid to `uuid` (`/pay`) against today's limit.
|
||||
pub fn count_received(&mut self, uuid: u128, now: i64, coins: i64) {
|
||||
let day = now.div_euclid(DAY_MS);
|
||||
let s = self.ledger.sold.entry(uuid_string(uuid)).or_default();
|
||||
if s.day != day {
|
||||
*s = Sold {
|
||||
day,
|
||||
..Sold::default()
|
||||
};
|
||||
}
|
||||
s.coins = s.coins.saturating_add(coins.max(0));
|
||||
self.ledger.sold.retain(|_, s| s.day == day);
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Counts a sale against today's limits.
|
||||
fn count_sale(&mut self, uuid: u128, now: i64, id: &str, n: u32, coins: i64) {
|
||||
let day = now.div_euclid(DAY_MS);
|
||||
let s = self.ledger.sold.entry(uuid_string(uuid)).or_default();
|
||||
if s.day != day {
|
||||
*s = Sold {
|
||||
day,
|
||||
..Sold::default()
|
||||
};
|
||||
}
|
||||
s.coins = s.coins.saturating_add(coins);
|
||||
let c = s.items.entry(id.to_owned()).or_default();
|
||||
*c = c.saturating_add(n);
|
||||
// Yesterday's counts go.
|
||||
self.ledger.sold.retain(|_, s| s.day == day);
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// The price of `id` now (nothing on either side when it isn't
|
||||
/// traded): the base one moved by supply and demand.
|
||||
pub fn price(&self, id: &str) -> Price {
|
||||
@@ -642,7 +540,7 @@ impl Market {
|
||||
if !self.book_dirty || (!force && now - self.book_written < BOOK_EVERY_MS) {
|
||||
return None;
|
||||
}
|
||||
let path = self.path.as_ref()?.with_file_name("market_prices.json");
|
||||
let path = self.dir.as_ref()?.join("market_prices.json");
|
||||
self.book_dirty = false;
|
||||
self.book_written = now;
|
||||
Some((path, self.book.clone()))
|
||||
@@ -696,26 +594,6 @@ impl Market {
|
||||
}
|
||||
}
|
||||
|
||||
/// Today's limits for `uuid`, as a line (None when there are none).
|
||||
pub fn limits_line(&self, economy: &Economy, uuid: u128) -> Option<String> {
|
||||
let now = economy.now;
|
||||
let coins = self.coins_left(uuid, now).map(|left| {
|
||||
format!(
|
||||
"{} left of your {} a day",
|
||||
economy.money(left),
|
||||
economy.money(self.config.daily_sell_coins)
|
||||
)
|
||||
});
|
||||
let items = (self.config.daily_sell_items > 0).then(|| {
|
||||
format!(
|
||||
"at most {} of each item a day",
|
||||
self.config.daily_sell_items
|
||||
)
|
||||
});
|
||||
let parts: Vec<String> = coins.into_iter().chain(items).collect();
|
||||
(!parts.is_empty()).then(|| format!("Selling today: {}.", parts.join(", ")))
|
||||
}
|
||||
|
||||
/// Runs `/worth`, `/sell` or `/buy`; None for another command. `trades`
|
||||
/// is false for a player in creative or spectator mode.
|
||||
pub fn command(
|
||||
@@ -735,7 +613,7 @@ impl Market {
|
||||
return Some(vec![Out::Error("The market is closed.".into())]);
|
||||
}
|
||||
if name == "worth" {
|
||||
return Some(self.worth(economy, me, bag, args));
|
||||
return Some(self.worth(economy, bag, args));
|
||||
}
|
||||
if !trades {
|
||||
return Some(vec![Out::Error(
|
||||
@@ -748,7 +626,7 @@ impl Market {
|
||||
})
|
||||
}
|
||||
|
||||
fn worth(&self, economy: &Economy, me: &Player, bag: &mut dyn Bag, args: &[&str]) -> Vec<Out> {
|
||||
fn worth(&self, economy: &Economy, bag: &mut dyn Bag, args: &[&str]) -> Vec<Out> {
|
||||
let id = match args {
|
||||
[] => match bag.held() {
|
||||
Some((id, ..)) => id,
|
||||
@@ -781,19 +659,10 @@ impl Market {
|
||||
id.trim_start_matches("minecraft:")
|
||||
)));
|
||||
}
|
||||
if p.sell > 0 {
|
||||
if let Some(left) = self.items_left(me.uuid, economy.now, &id) {
|
||||
out.push(Out::Reply(format!(
|
||||
"You may sell {left} more {name} today."
|
||||
)));
|
||||
}
|
||||
out.extend(self.limits_line(economy, me.uuid).map(Out::Reply));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Sells what `args` names (`hand [n]`, `all`, `<item> [n]`), within
|
||||
/// today's limits.
|
||||
/// Sells what `args` names (`hand [n]`, `all`, `<item> [n]`).
|
||||
pub fn sell(
|
||||
&mut self,
|
||||
economy: &mut Economy,
|
||||
@@ -846,39 +715,15 @@ impl Market {
|
||||
continue;
|
||||
}
|
||||
let price = self.each(&id).max(0.01);
|
||||
// Never more than the account can be paid for at once, nor
|
||||
// than today's limits leave.
|
||||
let now = economy.now;
|
||||
let mut cap = (MAX_AMOUNT as f64 / price).min(f64::from(u32::MAX)) as u32;
|
||||
if let Some(left) = self.items_left(me.uuid, now, &id) {
|
||||
cap = cap.min(left);
|
||||
}
|
||||
// What they bought here lately can't come back yet.
|
||||
let back = self.recently_bought(me.uuid, &id, now);
|
||||
if let Some(left) = self.coins_left(me.uuid, now) {
|
||||
cap = cap.min((left as f64 / price).min(f64::from(u32::MAX)) as u32);
|
||||
}
|
||||
// Never more than the account can be paid for at once.
|
||||
let cap = (MAX_AMOUNT as f64 / price).min(f64::from(u32::MAX)) as u32;
|
||||
let carried = bag.count(&id).min(at_most);
|
||||
if carried == 0 {
|
||||
out.push(Out::Error(format!("You have no plain {name}.")));
|
||||
continue;
|
||||
}
|
||||
let limited = carried.min(cap);
|
||||
if limited == 0 {
|
||||
out.push(Out::Error(format!(
|
||||
"You reached today's selling limit for {name}; it starts again at midnight UTC."
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
let have = limited.min(carried.saturating_sub(back));
|
||||
if have == 0 {
|
||||
let hours = self.config.dynamic.round_trip_hours;
|
||||
out.push(Out::Error(format!(
|
||||
"You bought {back} {name} from the market in the last {hours} h: those can't be sold back yet."
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
let taken = bag.take(&id, have);
|
||||
let taken = bag.take(&id, limited);
|
||||
if taken == 0 {
|
||||
continue;
|
||||
}
|
||||
@@ -905,7 +750,6 @@ impl Market {
|
||||
let kind = format!("sell {taken} {id}");
|
||||
match economy.adjust(&kind, me.uuid, pay, "") {
|
||||
Ok(_) => {
|
||||
self.count_sale(me.uuid, now, &id, taken, pay);
|
||||
self.moved(&id, taken, false);
|
||||
total = total.saturating_add(pay);
|
||||
let each = each_money(economy, pay as f64 / f64::from(taken));
|
||||
@@ -913,19 +757,6 @@ impl Market {
|
||||
"Sold {taken} {name} at {each} each: {}.",
|
||||
economy.money(pay)
|
||||
)));
|
||||
if taken < limited {
|
||||
let hours = self.config.dynamic.round_trip_hours;
|
||||
out.push(Out::Reply(format!(
|
||||
"{} {name} kept: bought from the market in the last {hours} h.",
|
||||
limited - taken
|
||||
)));
|
||||
}
|
||||
if limited < carried {
|
||||
out.push(Out::Reply(format!(
|
||||
"{} {name} kept: today's selling limit.",
|
||||
carried - limited
|
||||
)));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let back = bag.give(&id, taken);
|
||||
@@ -1008,9 +839,6 @@ impl Market {
|
||||
};
|
||||
if given > 0 {
|
||||
self.moved(&id, given, true);
|
||||
let now = economy.now;
|
||||
self.book.bought(&uuid_string(me.uuid), &id, given, now);
|
||||
self.book_dirty = true;
|
||||
}
|
||||
let each = each_money(economy, paid as f64 / f64::from(given.max(1)));
|
||||
let mut out = vec![Out::Reply(format!(
|
||||
@@ -1069,15 +897,6 @@ impl Market {
|
||||
);
|
||||
self.book_dirty = true;
|
||||
}
|
||||
|
||||
/// How many of `id` `uuid` bought within the round-trip window.
|
||||
pub fn recently_bought(&self, uuid: u128, id: &str, now: i64) -> u32 {
|
||||
if !self.config.dynamic.enabled {
|
||||
return 0;
|
||||
}
|
||||
self.book
|
||||
.recently_bought(&self.config.dynamic, &uuid_string(uuid), id, now)
|
||||
}
|
||||
}
|
||||
|
||||
/// `minecraft:iron_ingot` as `Iron Ingot`.
|
||||
@@ -1207,6 +1026,7 @@ impl WebView {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::smp_economy::Config as EconomyConfig;
|
||||
use super::super::smp_store::{uuid_string, write_json};
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
@@ -1279,31 +1099,58 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn money_received_by_pay_counts_toward_the_daily_selling_limit() {
|
||||
let config = Config {
|
||||
daily_sell_coins: 250,
|
||||
..Config::default()
|
||||
fn legacy_daily_caps_do_not_limit_sales_or_payments() {
|
||||
// Existing configs and today's saved ledger may still contain the
|
||||
// removed daily caps and receipts from before the upgrade.
|
||||
let config: Config = serde_json::from_value(serde_json::json!({
|
||||
"daily_sell_coins": 2000,
|
||||
"daily_sell_items": 640,
|
||||
"dynamic": {"enabled": false}
|
||||
}))
|
||||
.unwrap();
|
||||
let (mut e, mut m, me, dir) = setup_with("no-coin-cap", config);
|
||||
e.now = 5 * 86_400_000;
|
||||
fs::write(
|
||||
dir.join("market.json"),
|
||||
serde_json::json!({
|
||||
"sold": { uuid_string(ALEX): {
|
||||
"day": 5, "coins": 2000, "items": {"minecraft:diamond": 640}
|
||||
}}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let (loaded, problems) = Market::open(&dir, m.config.clone());
|
||||
assert!(problems.is_empty(), "{problems:?}");
|
||||
m = loaded;
|
||||
let mut b = bag(&[("minecraft:diamond", 700)], 0);
|
||||
m.command(&mut e, &me, true, &mut b, "sell diamond")
|
||||
.unwrap();
|
||||
assert_eq!(b.count("minecraft:diamond"), 0);
|
||||
assert_eq!(e.balance(ALEX), 140_000);
|
||||
let earned = e.balance(ALEX);
|
||||
e.seen(2, "Steve");
|
||||
e.adjust("test", 2, 10_000, "").unwrap();
|
||||
let sender = Player {
|
||||
uuid: 2,
|
||||
name: "Steve".into(),
|
||||
level: 0,
|
||||
};
|
||||
let (_e, mut m, _me, _dir) = setup_with("received", config);
|
||||
let now = 1_793_577_600_000;
|
||||
// An alt pays the main 200: fine, and it counts.
|
||||
assert_eq!(m.may_receive(ALEX, 200, now), Ok(()));
|
||||
m.count_received(ALEX, now, 200);
|
||||
assert_eq!(m.coins_left(ALEX, now), Some(50));
|
||||
// A second alt's 100 is refused: 50 left today, for sales or payments.
|
||||
assert_eq!(m.may_receive(ALEX, 100, now), Err(50));
|
||||
assert_eq!(m.may_receive(ALEX, 50, now), Ok(()));
|
||||
// The next day the limit is whole again.
|
||||
assert_eq!(m.may_receive(ALEX, 250, now + DAY_MS), Ok(()));
|
||||
// No limit configured: anything goes.
|
||||
let (_e, m, _me, _dir) = setup_with(
|
||||
"unlimited",
|
||||
Config {
|
||||
daily_sell_coins: 0,
|
||||
..Config::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(m.may_receive(ALEX, 1_000_000, now), Ok(()));
|
||||
// Repeated receipts over the former cap, on the same day as sales.
|
||||
for _ in 0..2 {
|
||||
let out = e.command(&sender, &[], "pay Alex 3000").unwrap();
|
||||
assert!(matches!(&out[0], Out::Reply(_)), "{out:?}");
|
||||
}
|
||||
assert_eq!(e.balance(ALEX), earned + 6000);
|
||||
assert_eq!(e.balance(2), 4000);
|
||||
// Reopening on the same day ignores the old full daily ledger too.
|
||||
let (mut m, problems) = Market::open(&dir, m.config.clone());
|
||||
assert!(problems.is_empty(), "{problems:?}");
|
||||
b.items.insert("minecraft:diamond".into(), 700);
|
||||
m.command(&mut e, &me, true, &mut b, "sell all").unwrap();
|
||||
assert_eq!(b.count("minecraft:diamond"), 0);
|
||||
assert_eq!(e.balance(ALEX), 286_000);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1433,95 +1280,6 @@ mod tests {
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sales_stop_at_the_daily_limits_and_start_again_the_next_day() {
|
||||
let config = Config {
|
||||
daily_sell_coins: 250,
|
||||
daily_sell_items: 30,
|
||||
..fixed()
|
||||
};
|
||||
let (mut e, mut m, me, dir) = setup_with("limits", config);
|
||||
e.now = 5 * DAY_MS + 1000;
|
||||
let mut b = bag(&[("minecraft:diamond", 5), ("minecraft:wheat", 64)], 0);
|
||||
// 250 coins a day: one diamond (200), then 25 wheat (50 coins), the
|
||||
// coins' limit before the 30 items of one kind.
|
||||
let out = m
|
||||
.command(&mut e, &me, true, &mut b, "sell diamond")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out[0],
|
||||
Out::Reply("Sold 1 diamond at 200 coins each: 200 coins.".into()),
|
||||
"{out:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
out[1],
|
||||
Out::Reply("4 diamond kept: today's selling limit.".into())
|
||||
);
|
||||
let out = m
|
||||
.command(&mut e, &me, true, &mut b, "sell wheat 5")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out[0],
|
||||
Out::Reply("Sold 5 wheat at 2 coins each: 10 coins.".into()),
|
||||
"{out:?}"
|
||||
);
|
||||
// 40 coins left, but 25 more wheat would pass the 30 items.
|
||||
e.adjust("test", ALEX, 0, "").ok();
|
||||
m.config.daily_sell_coins = 1000;
|
||||
let out = m.command(&mut e, &me, true, &mut b, "sell wheat").unwrap();
|
||||
assert_eq!(
|
||||
out[0],
|
||||
Out::Reply("Sold 25 wheat at 2 coins each: 50 coins.".into()),
|
||||
"{out:?}"
|
||||
);
|
||||
m.config.daily_sell_coins = 250;
|
||||
let out = m.command(&mut e, &me, true, &mut b, "sell wheat").unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
[Out::Error(
|
||||
"You reached today's selling limit for wheat; it starts again at midnight UTC."
|
||||
.into()
|
||||
)]
|
||||
);
|
||||
let worth = m
|
||||
.command(&mut e, &me, true, &mut b, "worth diamond")
|
||||
.unwrap();
|
||||
assert!(
|
||||
worth.contains(&Out::Reply("You may sell 29 more diamond today.".into())),
|
||||
"{worth:?}"
|
||||
);
|
||||
assert!(
|
||||
worth.contains(&Out::Reply(
|
||||
"Selling today: 0 coins left of your 250 coins a day, at most 30 of each item a day.".into()
|
||||
)),
|
||||
"{worth:?}"
|
||||
);
|
||||
assert_eq!((e.balance(ALEX), b.items["minecraft:diamond"]), (260, 4));
|
||||
// Kept across a restart, in market.json.
|
||||
m.path = Some(dir.join("market.json"));
|
||||
m.dirty = true;
|
||||
m.save().unwrap();
|
||||
let (mut m, _) = Market::open(&dir, m.config.clone());
|
||||
let out = m
|
||||
.command(&mut e, &me, true, &mut b, "sell diamond")
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Error(s) if s.contains("limit")),
|
||||
"{out:?}"
|
||||
);
|
||||
// A new UTC day.
|
||||
e.now = 6 * DAY_MS;
|
||||
let out = m
|
||||
.command(&mut e, &me, true, &mut b, "sell diamond")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out[0],
|
||||
Out::Reply("Sold 1 diamond at 200 coins each: 200 coins.".into()),
|
||||
"{out:?}"
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prices_that_would_make_money_are_closed() {
|
||||
let mut config = fixed();
|
||||
@@ -1556,13 +1314,7 @@ mod tests {
|
||||
);
|
||||
assert!(config.items.keys().all(|k| valid_id(k)));
|
||||
// At the largest price: one at a time, without overflow.
|
||||
let (mut e, mut m, me, dir) = setup_with(
|
||||
"validate",
|
||||
Config {
|
||||
daily_sell_coins: 0,
|
||||
..config
|
||||
},
|
||||
);
|
||||
let (mut e, mut m, me, dir) = setup_with("validate", Config { ..config });
|
||||
let mut b = bag(&[("minecraft:rotten_flesh", 64)], 0);
|
||||
let out = m
|
||||
.command(&mut e, &me, true, &mut b, "sell rotten_flesh")
|
||||
@@ -1630,15 +1382,9 @@ mod tests {
|
||||
assert_eq!(edited, before);
|
||||
}
|
||||
|
||||
/// Moving prices, validated, with no daily caps and no round-trip
|
||||
/// window: only the prices themselves stand between a trader and money.
|
||||
/// Moving prices, validated: only the prices stand between a trader and money.
|
||||
fn moving() -> Config {
|
||||
let mut c = Config {
|
||||
daily_sell_coins: 0,
|
||||
daily_sell_items: 0,
|
||||
..Config::default()
|
||||
};
|
||||
c.dynamic.round_trip_hours = 0;
|
||||
let mut c = Config::default();
|
||||
let _ = validate(&mut c);
|
||||
c
|
||||
}
|
||||
@@ -1797,11 +1543,157 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_prices_pay_the_whole_receipt_and_keep_bought_items_from_coming_back() {
|
||||
fn the_old_price_floor_becomes_the_new_one_and_an_edited_one_stays() {
|
||||
let mut c = Config::default();
|
||||
let _ = validate(&mut c);
|
||||
c.dynamic.min_factor = 0.6;
|
||||
assert!(migrate(&mut c));
|
||||
assert_eq!(c.dynamic.min_factor, 0.2);
|
||||
assert!(!migrate(&mut c), "once");
|
||||
c.dynamic.min_factor = 0.5;
|
||||
assert!(!migrate(&mut c));
|
||||
assert_eq!(c.dynamic.min_factor, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selling_ten_thousand_of_any_item_drives_its_price_to_the_floor() {
|
||||
// No daily cap: an AFK farm sells 10,000 of one item, a minute at a
|
||||
// time as the band allows. The price falls to a fifth of its base
|
||||
// and the farm gets far less than 10,000 at the base price.
|
||||
let c = moving();
|
||||
let ids: Vec<String> = c
|
||||
.items
|
||||
.iter()
|
||||
.filter(|(_, p)| p.sell > 0)
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
assert!(ids.len() > 30, "{ids:?}");
|
||||
let t0 = 1_800_000_000_000i64;
|
||||
for id in ids {
|
||||
let (mut e, _, me, dir) = setup(&format!("farm-{id}"));
|
||||
let mut m = Market::new(c.clone());
|
||||
let base = m.base(&id).sell;
|
||||
// Room for what a minute's band keeps.
|
||||
let mut b = bag(&[(id.as_str(), 10_000)], u32::MAX);
|
||||
let mut now = t0;
|
||||
e.now = now;
|
||||
m.tick(now);
|
||||
let mut minutes = 0;
|
||||
while b.count(&id) > 0 && minutes < 600 {
|
||||
m.sell(&mut e, &me, &mut b, &[&id]);
|
||||
minutes += 1;
|
||||
now += MINUTE_MS;
|
||||
e.now = now;
|
||||
m.tick(now);
|
||||
}
|
||||
// A few left worth less than a coin at the floor: kept.
|
||||
assert!(b.count(&id) < 5, "{id}: {} unsold", b.count(&id));
|
||||
let got = e.balance(ALEX);
|
||||
let at_base = 10_000 * base;
|
||||
assert!(
|
||||
got * 2 < at_base,
|
||||
"{id}: 10,000 sold for {got}, {at_base} at the base price"
|
||||
);
|
||||
let f = m.book.factor(&id);
|
||||
assert!(f < c.dynamic.min_factor * 1.01, "{id}: factor {f}");
|
||||
if std::env::var_os("SMP_MARKET_REPORT").is_some() {
|
||||
eprintln!("{id}: 10,000 sold for {got} ({at_base} at the base) in {minutes} min");
|
||||
}
|
||||
// The price comes back once the selling stops: in a day, to
|
||||
// within 10% of the base.
|
||||
m.tick(now + 24 * 60 * MINUTE_MS);
|
||||
assert!(m.book.factor(&id) > 0.9, "{id}: {}", m.book.factor(&id));
|
||||
fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buying_and_selling_straight_back_never_earns_without_a_resale_delay() {
|
||||
// Every item the market trades both ways, bought and sold back the
|
||||
// same moment, from a fresh price, from the floor (after another
|
||||
// player's dump) and from the top (after another's pumping): the
|
||||
// trader never ends with more coins than they started with.
|
||||
let c = moving();
|
||||
let ids: Vec<String> = c
|
||||
.items
|
||||
.iter()
|
||||
.filter(|(_, p)| p.buy > 0 && p.sell > 0)
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
assert!(ids.len() > 10, "{ids:?}");
|
||||
let t0 = 1_800_000_000_000i64;
|
||||
let mut cases = 0;
|
||||
let mut worst = (0i64, String::new());
|
||||
for id in &ids {
|
||||
for start in ["fresh", "floor", "top"] {
|
||||
for n in [1u32, 7, 64, 640, 64 * 36] {
|
||||
let (mut e, _, me, dir) = setup("loop");
|
||||
let mut m = Market::new(c.clone());
|
||||
let mut now = t0;
|
||||
m.tick(now);
|
||||
// Others push the price to a bound, a minute at a time.
|
||||
let to_bound = |m: &mut Market, now: &mut i64, buy: bool| {
|
||||
for _ in 0..200 {
|
||||
m.moved(id, 100_000, buy);
|
||||
*now += MINUTE_MS;
|
||||
m.tick(*now);
|
||||
}
|
||||
};
|
||||
match start {
|
||||
"floor" => to_bound(&mut m, &mut now, false),
|
||||
"top" => to_bound(&mut m, &mut now, true),
|
||||
_ => {}
|
||||
}
|
||||
match start {
|
||||
"floor" => assert!(m.book.factor(id) < c.dynamic.min_factor * 1.01),
|
||||
"top" => assert!(m.book.factor(id) > c.dynamic.max_factor * 0.99),
|
||||
_ => {}
|
||||
}
|
||||
e.now = now;
|
||||
e.adjust("test_funds", me.uuid, 100_000_000, "").unwrap();
|
||||
let start_coins = e.balance(me.uuid);
|
||||
let mut b = bag(&[], u32::MAX);
|
||||
m.buy(&mut e, &me, &mut b, &[id, &n.to_string()]);
|
||||
let bought = b.count(id);
|
||||
assert!(bought > 0, "{id} {start} {n}: nothing bought");
|
||||
// At once; what the minute's band keeps, in the next
|
||||
// minutes (the price only drifts back down meanwhile).
|
||||
m.sell(&mut e, &me, &mut b, &[id]);
|
||||
let mut minutes = 0;
|
||||
while b.count(id) >= 5 && minutes < 2_000 {
|
||||
now += MINUTE_MS;
|
||||
e.now = now;
|
||||
m.tick(now);
|
||||
m.sell(&mut e, &me, &mut b, &[id]);
|
||||
minutes += 1;
|
||||
}
|
||||
assert!(b.count(id) < 5, "{id} {start} {n}: {} kept", b.count(id));
|
||||
let end = e.balance(me.uuid);
|
||||
assert!(
|
||||
end <= start_coins,
|
||||
"{id} from {start}: bought {bought} and sold back, {} coins earned",
|
||||
end - start_coins
|
||||
);
|
||||
worst = worst.max((end - start_coins, format!("{id} {start} {n}")));
|
||||
cases += 1;
|
||||
fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(cases, ids.len() * 15);
|
||||
if std::env::var_os("SMP_MARKET_REPORT").is_some() {
|
||||
eprintln!("round trips: {cases}, best for the trader {worst:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_prices_allow_immediate_resale_with_legacy_purchase_history() {
|
||||
let c: Config = serde_json::from_value(serde_json::json!({
|
||||
"dynamic": {"round_trip_hours": 24}
|
||||
}))
|
||||
.unwrap();
|
||||
let (mut e, _, me, dir) = setup("dyn");
|
||||
let mut m = Market::new(c);
|
||||
let (mut m, problems) = Market::open(&dir, c);
|
||||
assert!(problems.is_empty(), "{problems:?}");
|
||||
let t0 = 1_800_000_000_000i64;
|
||||
e.now = t0;
|
||||
e.adjust("test_funds", me.uuid, 10_000, "").unwrap();
|
||||
@@ -1819,31 +1711,22 @@ mod tests {
|
||||
Out::Reply("Bought 10 iron ingot at 15 coins each: 151 coins.".into())
|
||||
);
|
||||
assert_eq!(e.balance(me.uuid), 10_000 + 595 - 151);
|
||||
// Old files can still contain a purchase within the former 24 h
|
||||
// window. Preserve the moving price history and ignore that receipt.
|
||||
let mut old_book = serde_json::to_value(&m.book).unwrap();
|
||||
old_book["bought"] = serde_json::json!({
|
||||
uuid_string(me.uuid): [["minecraft:iron_ingot", t0, 10]]
|
||||
});
|
||||
write_json(&dir.join("market_prices.json"), &old_book).unwrap();
|
||||
let (mut m, problems) = Market::open(&dir, m.config.clone());
|
||||
assert!(problems.is_empty(), "{problems:?}");
|
||||
let out = m.sell(&mut e, &me, &mut b, &["iron_ingot"]);
|
||||
assert_eq!(
|
||||
out[0],
|
||||
Out::Error("You bought 10 iron ingot from the market in the last 24 h: those can't be sold back yet.".into())
|
||||
);
|
||||
// Five more found in a mine: those sell, the bought ones stay.
|
||||
b.items.insert("minecraft:iron_ingot".into(), 15);
|
||||
let out = m.sell(&mut e, &me, &mut b, &["iron_ingot"]);
|
||||
assert!(
|
||||
matches!(&out[0], Out::Reply(r) if r.starts_with("Sold 5 iron ingot")),
|
||||
"{out:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
out[1],
|
||||
Out::Reply("10 iron ingot kept: bought from the market in the last 24 h.".into())
|
||||
);
|
||||
b.items.insert("minecraft:iron_ingot".into(), 10);
|
||||
// A day later they may.
|
||||
e.now = t0 + 25 * 3_600_000;
|
||||
m.tick(e.now);
|
||||
let out = m.sell(&mut e, &me, &mut b, &["iron_ingot"]);
|
||||
assert!(
|
||||
matches!(&out[0], Out::Reply(r) if r.starts_with("Sold 10 iron ingot")),
|
||||
"{out:?}"
|
||||
Out::Reply("Sold 10 iron ingot at 15 coins each: 150 coins.".into())
|
||||
);
|
||||
assert_eq!(b.count("minecraft:iron_ingot"), 0);
|
||||
assert_eq!(e.balance(me.uuid), 10_000 + 595 - 151 + 150);
|
||||
fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,17 @@ pub const HOUR_MS: i64 = 3_600_000;
|
||||
pub const MINUTES: usize = 1440;
|
||||
pub const HOURS: usize = 720;
|
||||
|
||||
/// The least share of its base price the market pays (since 0.33, with no
|
||||
/// daily sale caps: an item sold without end falls to it and stays there
|
||||
/// while the selling goes on).
|
||||
pub const MIN_FACTOR: f64 = 0.2;
|
||||
/// The default floor before 0.33: a config that still has it gets
|
||||
/// `MIN_FACTOR` (`smp_market::migrate`).
|
||||
pub const OLD_MIN_FACTOR: f64 = 0.6;
|
||||
/// The most items that move a price 1% (unless `depth` says otherwise): the
|
||||
/// cheap items farms make reach the floor within 10,000 sold.
|
||||
pub const MAX_DEPTH: f64 = 50.0;
|
||||
|
||||
/// `config.json`'s `market.dynamic`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -43,9 +54,6 @@ pub struct Dynamic {
|
||||
pub max_player_impact_percent: f64,
|
||||
/// How far the price may move within one minute (either way).
|
||||
pub max_minute_move_percent: f64,
|
||||
/// What a player bought from the market can't be sold back to it for
|
||||
/// this long (that many of that item).
|
||||
pub round_trip_hours: i64,
|
||||
/// Items that move the price 1%, by item (else from its price: dear
|
||||
/// items move faster).
|
||||
pub depth: BTreeMap<String, f64>,
|
||||
@@ -58,13 +66,12 @@ impl Default for Dynamic {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
min_factor: 0.6,
|
||||
min_factor: MIN_FACTOR,
|
||||
max_factor: 1.6,
|
||||
half_life_hours: 6.0,
|
||||
max_trade_impact_percent: 2.0,
|
||||
max_player_impact_percent: 2.0,
|
||||
max_minute_move_percent: 5.0,
|
||||
round_trip_hours: 24,
|
||||
depth: BTreeMap::new(),
|
||||
news_percent: 30.0,
|
||||
news_min_volume: 64,
|
||||
@@ -98,7 +105,6 @@ impl Dynamic {
|
||||
50.0,
|
||||
d.max_minute_move_percent,
|
||||
);
|
||||
self.round_trip_hours = self.round_trip_hours.clamp(0, 24 * 30);
|
||||
self.depth.retain(|_, v| v.is_finite() && *v > 0.0);
|
||||
}
|
||||
}
|
||||
@@ -175,9 +181,6 @@ pub struct Book {
|
||||
pub minute: i64,
|
||||
/// The UTC day the last summary was for.
|
||||
pub summary_day: i64,
|
||||
/// What each player bought (item, when, how many), for the round-trip
|
||||
/// rule.
|
||||
pub bought: BTreeMap<String, Vec<(String, i64, u32)>>,
|
||||
/// The last daily summary's lines (for the web page and Kira).
|
||||
pub last_summary: Vec<String>,
|
||||
/// The base price each item's history is quoted in (since 0.29).
|
||||
@@ -262,7 +265,7 @@ impl Book {
|
||||
.depth
|
||||
.get(id)
|
||||
.copied()
|
||||
.unwrap_or_else(|| (400.0 / mid.max(0.01)).clamp(2.0, 400.0))
|
||||
.unwrap_or_else(|| (400.0 / mid.max(0.01)).clamp(2.0, MAX_DEPTH))
|
||||
}
|
||||
|
||||
/// How far one of `id` (based at `mid`) moves the log of its factor.
|
||||
@@ -448,34 +451,9 @@ impl Book {
|
||||
// Items no longer traded go.
|
||||
self.items.retain(|id, _| mids.contains_key(id));
|
||||
self.minute = minute;
|
||||
// Purchases past the round-trip window go.
|
||||
let since = now - config.round_trip_hours * HOUR_MS;
|
||||
for v in self.bought.values_mut() {
|
||||
v.retain(|(_, t, _)| *t > since);
|
||||
}
|
||||
self.bought.retain(|_, v| !v.is_empty());
|
||||
true
|
||||
}
|
||||
|
||||
/// `uuid` bought `n` of `id` at `now`.
|
||||
pub fn bought(&mut self, uuid: &str, id: &str, n: u32, now: i64) {
|
||||
self.bought
|
||||
.entry(uuid.to_owned())
|
||||
.or_default()
|
||||
.push((id.to_owned(), now, n));
|
||||
}
|
||||
|
||||
/// How many of `id` `uuid` bought within the round-trip window.
|
||||
pub fn recently_bought(&self, config: &Dynamic, uuid: &str, id: &str, now: i64) -> u32 {
|
||||
let since = now - config.round_trip_hours * HOUR_MS;
|
||||
self.bought.get(uuid).map_or(0, |v| {
|
||||
v.iter()
|
||||
.filter(|(i, t, _)| i == id && *t > since)
|
||||
.map(|e| e.2)
|
||||
.sum()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn history(&self, id: &str, span: Span) -> &[Point] {
|
||||
match self.items.get(id) {
|
||||
Some(t) => match span {
|
||||
@@ -667,10 +645,10 @@ mod tests {
|
||||
assert!((b.factor(D) - (-0.005f64).exp()).abs() < 1e-12, "at once");
|
||||
assert_eq!(deal(&mut b, 1, true), (-250, 1));
|
||||
assert!((b.factor(D) - 1.0).abs() < 1e-12, "and back");
|
||||
// Wheat is deep: 64 move it far less than one diamond.
|
||||
// Wheat is deep: a whole stack moves it under 1.5%.
|
||||
let w = Book::unit_move(&c, "minecraft:wheat", 2.0);
|
||||
b.trade(&c, "minecraft:wheat", &[], 2.0, w, 64, false);
|
||||
assert!(1.0 - b.factor("minecraft:wheat") < 0.004);
|
||||
assert!(1.0 - b.factor("minecraft:wheat") < 0.015);
|
||||
// Its family moves with it.
|
||||
let fam = vec!["minecraft:hay_block".to_string()];
|
||||
b.trade(&c, "minecraft:wheat", &fam, 2.0, w, 64, true);
|
||||
@@ -843,20 +821,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_round_trip_window_and_one_price() {
|
||||
let c = Dynamic::default();
|
||||
let mut b = book();
|
||||
b.bought("u", "minecraft:diamond", 5, T0);
|
||||
assert_eq!(
|
||||
b.recently_bought(&c, "u", "minecraft:diamond", T0 + HOUR_MS),
|
||||
5
|
||||
);
|
||||
b.tick(&c, &mids(), T0 + 25 * HOUR_MS);
|
||||
assert_eq!(
|
||||
b.recently_bought(&c, "u", "minecraft:diamond", T0 + 25 * HOUR_MS),
|
||||
0
|
||||
);
|
||||
assert!(b.bought.is_empty());
|
||||
fn one_price_and_whole_coin_rounding() {
|
||||
// One price from the old two: their geometric mean, whole.
|
||||
assert_eq!(one_price(400, 100), 200);
|
||||
assert_eq!(one_price(30, 8), 15);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Places: the server's spawn (`/spawn`, `/setspawn`), each player's homes
|
||||
// (`/home`, `/sethome`, `/delhome`, `/homes`), warps (`/warp`, `/warps`,
|
||||
// `/setwarp`, `/delwarp`), teleport requests between players (`/tpa`,
|
||||
// `/tpaccept`, `/tpdeny`), `/back` (where the player died, or was before
|
||||
// their last teleport), `/playtime` and `/rules`. A teleport waits `warmup_seconds`,
|
||||
// `/tpaccept`, `/tpdeny`), `/rtp` (`/wild`: a random safe spot far from the
|
||||
// spawn), `/back` (where the player died, or was before their last
|
||||
// teleport), `/playtime` and `/rules`. A teleport waits `warmup_seconds`,
|
||||
// and a player who moves or is hurt meanwhile stays where they are; the glue
|
||||
// moves them with a vanilla `tp` run as the server. The number of homes grows
|
||||
// with the time a player has played here, counted by the mod. Everything is
|
||||
@@ -12,7 +13,7 @@
|
||||
use super::smp_economy::{Out, Player};
|
||||
use super::smp_store;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// `config.json`'s `places` section.
|
||||
@@ -34,6 +35,17 @@ pub struct Config {
|
||||
/// spawn point, like vanilla's `respawn_radius` for a first join or a
|
||||
/// respawn (0: exactly the spawn point).
|
||||
pub spawn_radius: i64,
|
||||
/// `/rtp` lands at least this many blocks from the spawn,
|
||||
pub rtp_min_distance: i64,
|
||||
/// and at most this many (uniform over the ring's area, inside the
|
||||
/// world border; the Overworld only).
|
||||
pub rtp_max_distance: i64,
|
||||
/// Between two `/rtp`s of one player, kept over a restart.
|
||||
pub rtp_cooldown_seconds: i64,
|
||||
/// Chunks tried, one after another, before `/rtp` gives up.
|
||||
pub rtp_attempts: i64,
|
||||
/// How long `/rtp` waits, after the warmup, for the land to load.
|
||||
pub rtp_search_seconds: i64,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
@@ -49,6 +61,11 @@ impl Default for Config {
|
||||
"The currency is earned in game only: never bought or sold for real money.".into(),
|
||||
],
|
||||
spawn_radius: 250,
|
||||
rtp_min_distance: 500,
|
||||
rtp_max_distance: 5000,
|
||||
rtp_cooldown_seconds: 1800,
|
||||
rtp_attempts: 6,
|
||||
rtp_search_seconds: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +99,40 @@ struct Data {
|
||||
homes: BTreeMap<String, BTreeMap<String, Place>>,
|
||||
/// Seconds played here, by player UUID.
|
||||
played: BTreeMap<String, i64>,
|
||||
/// The last `/rtp` (ms), by player UUID.
|
||||
rtp: BTreeMap<String, i64>,
|
||||
/// The Overworld chunks `/rtp` holds loaded (`forceload add`): let go at
|
||||
/// the next start if the server stopped first.
|
||||
forced: BTreeSet<(i32, i32)>,
|
||||
}
|
||||
|
||||
/// The world border, `[min_x, min_z, max_x, max_z]`.
|
||||
pub type Bounds = [f64; 4];
|
||||
|
||||
/// The Overworld, the only level `/rtp` goes to.
|
||||
pub const OVERWORLD: &str = "minecraft:overworld";
|
||||
|
||||
/// An `/rtp` looking for its spot.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Rtp {
|
||||
/// Where the distances count from.
|
||||
centre: (f64, f64),
|
||||
border: Option<Bounds>,
|
||||
/// The chunks left to try, the one loading first.
|
||||
chunks: Vec<(i32, i32)>,
|
||||
/// The spot found, waiting for the warmup's end.
|
||||
spot: Option<Place>,
|
||||
/// "Looking for a safe spot" was said.
|
||||
told: bool,
|
||||
}
|
||||
|
||||
/// A loaded chunk's columns, as the game read them: each column's `x`,
|
||||
/// `z`, height (the first block above its ground) and its ground, feet and
|
||||
/// head blocks, as indexes into `palette`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Ground {
|
||||
pub palette: Vec<String>,
|
||||
pub columns: Vec<(i32, i32, i32, [u16; 3])>,
|
||||
}
|
||||
|
||||
/// Where a teleport goes.
|
||||
@@ -93,6 +144,8 @@ enum To {
|
||||
Spawn(Place),
|
||||
/// A player, wherever they are when the warmup ends.
|
||||
Player(u128, String),
|
||||
/// `/rtp`: a safe spot found in the chunks it loads.
|
||||
Random(Rtp),
|
||||
}
|
||||
|
||||
/// A teleport waiting for its warmup.
|
||||
@@ -126,8 +179,17 @@ pub struct Teleport {
|
||||
/// Land on a safe column within this many blocks of `to` instead of on it
|
||||
/// (0: on it). Only `/spawn` sets it.
|
||||
pub spread: i64,
|
||||
/// The cooldown and `/back` before it: `call_off` puts them back.
|
||||
undo: (Option<i64>, Option<(Place, bool)>),
|
||||
/// What it changed: `call_off` puts it back.
|
||||
undo: Undo,
|
||||
}
|
||||
|
||||
/// The cooldown and `/back` before a teleport, and the `/rtp` cooldown
|
||||
/// when it is one.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Undo {
|
||||
last: Option<i64>,
|
||||
back: Option<(Place, bool)>,
|
||||
rtp: Option<Option<i64>>,
|
||||
}
|
||||
|
||||
impl Teleport {
|
||||
@@ -146,9 +208,9 @@ impl Teleport {
|
||||
}
|
||||
|
||||
/// The places' commands.
|
||||
pub const COMMANDS: [&str; 16] = [
|
||||
pub const COMMANDS: [&str; 18] = [
|
||||
"spawn", "setspawn", "home", "sethome", "delhome", "homes", "warp", "warps", "setwarp",
|
||||
"delwarp", "tpa", "tpaccept", "tpdeny", "rules", "back", "playtime",
|
||||
"delwarp", "tpa", "tpaccept", "tpdeny", "rules", "back", "playtime", "rtp", "wild",
|
||||
];
|
||||
|
||||
pub struct Places {
|
||||
@@ -166,7 +228,93 @@ pub struct Places {
|
||||
/// over a restart.
|
||||
back: HashMap<u128, (Place, bool)>,
|
||||
/// The players seen dead (health 0) and not respawned yet.
|
||||
dead: std::collections::HashSet<u128>,
|
||||
dead: HashSet<u128>,
|
||||
/// Chunks an `/rtp` landed in, held loaded until then (ms): the
|
||||
/// player's own view takes over.
|
||||
release: Vec<((i32, i32), i64)>,
|
||||
/// Chunks someone else forced already: wanted, never let go by `/rtp`.
|
||||
others: HashSet<(i32, i32)>,
|
||||
/// `/rtp`'s random (never the world's).
|
||||
pub rng: u64,
|
||||
/// The world border, as the game last told (None: the default one).
|
||||
pub border: Option<Bounds>,
|
||||
}
|
||||
|
||||
/// `2 h 05 min`, `12 min`, `40 s` for the time left on a cooldown.
|
||||
fn wait_text(ms: i64) -> String {
|
||||
let s = (ms + 999) / 1000;
|
||||
if s < 60 {
|
||||
format!("{s} s")
|
||||
} else {
|
||||
duration(s + 59)
|
||||
}
|
||||
}
|
||||
|
||||
/// SplitMix64: the next of `state`'s numbers.
|
||||
fn next(state: &mut u64) -> u64 {
|
||||
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = *state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// A number in `0..1`.
|
||||
fn unit(state: &mut u64) -> f64 {
|
||||
(next(state) >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
/// A point `min..=max` blocks from `centre`, uniform over the ring's area,
|
||||
/// from two numbers in `0..1`.
|
||||
pub fn ring_point(centre: (f64, f64), min: f64, max: f64, u: f64, v: f64) -> (f64, f64) {
|
||||
let r = (min * min + u * (max * max - min * min)).sqrt();
|
||||
let a = v * std::f64::consts::TAU;
|
||||
(centre.0 + r * a.cos(), centre.1 + r * a.sin())
|
||||
}
|
||||
|
||||
/// Whether `(x, z)` is at least 16 blocks inside the border.
|
||||
fn inside(border: Option<Bounds>, x: f64, z: f64) -> bool {
|
||||
border.is_none_or(|[x0, z0, x1, z1]| {
|
||||
x >= x0 + 16.0 && x <= x1 - 16.0 && z >= z0 + 16.0 && z <= z1 - 16.0
|
||||
})
|
||||
}
|
||||
|
||||
/// A block a player may stand on: solid, no fluid, nothing that hurts.
|
||||
fn ground_ok(name: &str) -> bool {
|
||||
let n = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
!(matches!(
|
||||
n,
|
||||
"air"
|
||||
| "cave_air"
|
||||
| "void_air"
|
||||
| "water"
|
||||
| "lava"
|
||||
| "fire"
|
||||
| "soul_fire"
|
||||
| "cactus"
|
||||
| "magma_block"
|
||||
| "powder_snow"
|
||||
| "campfire"
|
||||
| "soul_campfire"
|
||||
| "sweet_berry_bush"
|
||||
| "pointed_dripstone"
|
||||
| "wither_rose"
|
||||
| "bubble_column"
|
||||
| "kelp"
|
||||
| "kelp_plant"
|
||||
| "seagrass"
|
||||
| "tall_seagrass"
|
||||
| "bedrock"
|
||||
) || n.ends_with("_leaves"))
|
||||
}
|
||||
|
||||
/// A block a player's feet or head may be in: air, or a plant they walk through.
|
||||
fn room_ok(name: &str) -> bool {
|
||||
let n = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
matches!(
|
||||
n,
|
||||
"air" | "cave_air" | "short_grass" | "tall_grass" | "fern" | "large_fern" | "snow"
|
||||
)
|
||||
}
|
||||
|
||||
/// `2 h 05 min`, `12 min`.
|
||||
@@ -209,6 +357,14 @@ impl Places {
|
||||
last: HashMap::new(),
|
||||
back: HashMap::new(),
|
||||
dead: Default::default(),
|
||||
release: Vec::new(),
|
||||
others: HashSet::new(),
|
||||
rng: {
|
||||
use std::hash::BuildHasher;
|
||||
std::collections::hash_map::RandomState::new()
|
||||
.hash_one(std::time::SystemTime::now())
|
||||
},
|
||||
border: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -269,11 +425,19 @@ impl Places {
|
||||
/// A teleport the game couldn't make: no
|
||||
/// cooldown, and `/back` goes where it went before.
|
||||
pub fn call_off(&mut self, t: &Teleport) {
|
||||
match t.undo.0 {
|
||||
if let Some(rtp) = t.undo.rtp {
|
||||
let key = smp_store::uuid_string(t.uuid);
|
||||
match rtp {
|
||||
Some(at) => self.data.rtp.insert(key, at),
|
||||
None => self.data.rtp.remove(&key),
|
||||
};
|
||||
self.changed = true;
|
||||
}
|
||||
match t.undo.last {
|
||||
Some(at) => self.last.insert(t.uuid, at),
|
||||
None => self.last.remove(&t.uuid),
|
||||
};
|
||||
match &t.undo.1 {
|
||||
match &t.undo.back {
|
||||
Some(b) => self.back.insert(t.uuid, b.clone()),
|
||||
None => self.back.remove(&t.uuid),
|
||||
};
|
||||
@@ -308,6 +472,182 @@ impl Places {
|
||||
}
|
||||
}
|
||||
|
||||
/// `/rtp`: refused outside the Overworld and during its cooldown;
|
||||
/// otherwise the chunks to try are drawn and the warmup starts. The
|
||||
/// distances count from the spawn (`/setspawn`), 0 0 without one.
|
||||
fn rtp(&mut self, me: &Body, now: i64) -> Vec<Out> {
|
||||
if me.at.dimension != OVERWORLD {
|
||||
return vec![Out::Error("/rtp only works in the Overworld.".into())];
|
||||
}
|
||||
let key = smp_store::uuid_string(me.uuid);
|
||||
if let Some(&t) = self.data.rtp.get(&key) {
|
||||
let wait = t + self.config.rtp_cooldown_seconds * 1000 - now;
|
||||
if wait > 0 {
|
||||
return vec![Out::Error(format!(
|
||||
"Wait {} before your next /rtp.",
|
||||
wait_text(wait)
|
||||
))];
|
||||
}
|
||||
}
|
||||
let centre = match &self.data.spawn {
|
||||
Some(s) if s.dimension == OVERWORLD => (s.x, s.z),
|
||||
_ => (0.0, 0.0),
|
||||
};
|
||||
let chunks = self.rtp_chunks(centre, self.border);
|
||||
if chunks.is_empty() {
|
||||
return vec![Out::Error(
|
||||
"No land for /rtp: the world border is too close to the spawn.".into(),
|
||||
)];
|
||||
}
|
||||
let border = self.border;
|
||||
self.start(
|
||||
me,
|
||||
To::Random(Rtp {
|
||||
centre,
|
||||
border,
|
||||
chunks,
|
||||
spot: None,
|
||||
told: false,
|
||||
}),
|
||||
now,
|
||||
)
|
||||
}
|
||||
|
||||
/// Up to `rtp_attempts` distinct chunks around points drawn on the ring,
|
||||
/// inside the border (none when the border leaves no room).
|
||||
fn rtp_chunks(&mut self, centre: (f64, f64), border: Option<Bounds>) -> Vec<(i32, i32)> {
|
||||
let min = self.config.rtp_min_distance.max(0) as f64;
|
||||
let max = (self.config.rtp_max_distance as f64).max(min);
|
||||
let mut chunks = Vec::new();
|
||||
// Drawn until enough fall inside the border: a border that cuts the
|
||||
// ring takes more draws (a few microseconds each).
|
||||
for _ in 0..2_000 {
|
||||
if chunks.len() >= self.config.rtp_attempts.max(1) as usize {
|
||||
break;
|
||||
}
|
||||
let (u, v) = (unit(&mut self.rng), unit(&mut self.rng));
|
||||
let (x, z) = ring_point(centre, min, max, u, v);
|
||||
let c = ((x.floor() as i32) >> 4, (z.floor() as i32) >> 4);
|
||||
if inside(border, x, z) && !chunks.contains(&c) {
|
||||
chunks.push(c);
|
||||
}
|
||||
}
|
||||
chunks
|
||||
}
|
||||
|
||||
/// The safe columns of a loaded chunk that are within the ring and the
|
||||
/// border, and one of them at random.
|
||||
fn pick(&mut self, rtp: &Rtp, ground: &Ground) -> Option<Place> {
|
||||
let ok: Vec<bool> = ground.palette.iter().map(|n| ground_ok(n)).collect();
|
||||
let room: Vec<bool> = ground.palette.iter().map(|n| room_ok(n)).collect();
|
||||
let (min, max) = (
|
||||
self.config.rtp_min_distance.max(0) as f64,
|
||||
self.config.rtp_max_distance as f64,
|
||||
);
|
||||
let safe: Vec<&(i32, i32, i32, [u16; 3])> = ground
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|(x, z, _, [g, f, h])| {
|
||||
let (px, pz) = (f64::from(*x) + 0.5, f64::from(*z) + 0.5);
|
||||
let d = (px - rtp.centre.0).hypot(pz - rtp.centre.1);
|
||||
let at = |i: &u16, v: &[bool]| v.get(usize::from(*i)).copied().unwrap_or(false);
|
||||
(min..=max).contains(&d)
|
||||
&& inside(rtp.border, px, pz)
|
||||
&& at(g, &ok)
|
||||
&& at(f, &room)
|
||||
&& at(h, &room)
|
||||
})
|
||||
.collect();
|
||||
if safe.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let &(x, z, y, _) = safe[(next(&mut self.rng) % safe.len() as u64) as usize];
|
||||
Some(Place {
|
||||
dimension: OVERWORLD.into(),
|
||||
x: f64::from(x) + 0.5,
|
||||
y: f64::from(y),
|
||||
z: f64::from(z) + 0.5,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether an `/rtp` waits for land: the game reads it then
|
||||
/// (`rtp_look`).
|
||||
pub fn rtp_searching(&self) -> bool {
|
||||
self.pending
|
||||
.iter()
|
||||
.any(|p| matches!(&p.to, To::Random(r) if r.spot.is_none() && !r.chunks.is_empty()))
|
||||
}
|
||||
|
||||
/// Each `/rtp` still looking reads the chunk it loads: None while it
|
||||
/// isn't loaded yet; with no safe column, the next chunk is tried.
|
||||
pub fn rtp_look(&mut self, look: &mut dyn FnMut(u128, (i32, i32)) -> Option<Ground>) {
|
||||
let mut pending = std::mem::take(&mut self.pending);
|
||||
for p in &mut pending {
|
||||
let To::Random(r) = &mut p.to else { continue };
|
||||
if r.spot.is_some() {
|
||||
continue;
|
||||
}
|
||||
let Some(&c) = r.chunks.first() else { continue };
|
||||
let Some(ground) = look(p.uuid, c) else {
|
||||
continue;
|
||||
};
|
||||
match self.pick(r, &ground) {
|
||||
Some(spot) => r.spot = Some(spot),
|
||||
None => {
|
||||
r.chunks.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.pending = pending;
|
||||
}
|
||||
|
||||
/// The chunks `/rtp` wants loaded now: each search's current one, and
|
||||
/// those just landed in.
|
||||
pub fn rtp_wanted(&mut self, now: i64) -> BTreeSet<(i32, i32)> {
|
||||
self.release.retain(|&(_, until)| until > now);
|
||||
let mut wanted: BTreeSet<(i32, i32)> = self.release.iter().map(|&(c, _)| c).collect();
|
||||
for p in &self.pending {
|
||||
if let To::Random(r) = &p.to
|
||||
&& let Some(&c) = r.chunks.first()
|
||||
{
|
||||
wanted.insert(c);
|
||||
}
|
||||
}
|
||||
wanted
|
||||
}
|
||||
|
||||
/// Holds the chunks `/rtp` wants with vanilla's `forceload` (run by
|
||||
/// `run`, true when the game did it) and lets go of the others it holds.
|
||||
/// A chunk forced already by someone else is used, never let go.
|
||||
pub fn force(&mut self, now: i64, run: &mut dyn FnMut(&str) -> bool) {
|
||||
let wanted = self.rtp_wanted(now);
|
||||
let line = |verb: &str, (x, z): (i32, i32)| {
|
||||
format!(
|
||||
"execute in {OVERWORLD} run forceload {verb} {} {}",
|
||||
x * 16,
|
||||
z * 16
|
||||
)
|
||||
};
|
||||
for &c in &wanted {
|
||||
if self.data.forced.contains(&c) || self.others.contains(&c) {
|
||||
continue;
|
||||
}
|
||||
if run(&line("add", c)) {
|
||||
self.data.forced.insert(c);
|
||||
self.changed = true;
|
||||
} else {
|
||||
self.others.insert(c);
|
||||
}
|
||||
}
|
||||
let gone: Vec<(i32, i32)> = self.data.forced.difference(&wanted).copied().collect();
|
||||
for c in gone {
|
||||
run(&line("remove", c));
|
||||
self.data.forced.remove(&c);
|
||||
self.changed = true;
|
||||
}
|
||||
self.others.retain(|c| wanted.contains(c));
|
||||
}
|
||||
|
||||
/// The places' commands; None for another command. `bodies` are the
|
||||
/// players on, `me` among them.
|
||||
pub fn command(
|
||||
@@ -353,6 +693,7 @@ impl Places {
|
||||
))],
|
||||
None => vec![Out::Error(format!("{name} isn't on."))],
|
||||
},
|
||||
("rtp" | "wild", []) => self.rtp(body, now),
|
||||
("rules", []) => {
|
||||
let mut out = vec![Out::Reply("Rules of this server:".into())];
|
||||
for (i, r) in self.config.rules.iter().enumerate() {
|
||||
@@ -537,6 +878,7 @@ impl Places {
|
||||
"warp" | "setwarp" | "delwarp" => "Usage: /warp <name>; /warps",
|
||||
"tpa" => "Usage: /tpa <player>",
|
||||
"tpaccept" | "tpdeny" => "Usage: /tpaccept [player], /tpdeny [player]",
|
||||
"rtp" | "wild" => "Usage: /rtp",
|
||||
_ => "You may not use this command.",
|
||||
}
|
||||
.into(),
|
||||
@@ -581,9 +923,46 @@ impl Places {
|
||||
));
|
||||
} else if now < p.due {
|
||||
self.pending.push(p);
|
||||
} else if let To::Random(r) = &p.to
|
||||
&& r.spot.is_none()
|
||||
{
|
||||
// `/rtp` after the warmup, its land not found yet.
|
||||
let search = self.config.rtp_search_seconds.max(0) * 1000;
|
||||
if r.chunks.is_empty() {
|
||||
out.push(Out::Tell(
|
||||
p.uuid,
|
||||
"No safe spot found for /rtp: try again.".into(),
|
||||
));
|
||||
} else if now - p.due > search {
|
||||
out.push(Out::Tell(
|
||||
p.uuid,
|
||||
"/rtp couldn't load the land in time: try again.".into(),
|
||||
));
|
||||
} else {
|
||||
let mut p = p;
|
||||
if let To::Random(r) = &mut p.to
|
||||
&& !r.told
|
||||
{
|
||||
r.told = true;
|
||||
out.push(Out::Tell(
|
||||
p.uuid,
|
||||
"Looking for a safe spot: don't move.".into(),
|
||||
));
|
||||
}
|
||||
self.pending.push(p);
|
||||
}
|
||||
} else {
|
||||
let mut spread = 0;
|
||||
let mut rtp = None;
|
||||
let (to, what) = match &p.to {
|
||||
To::Random(r) => {
|
||||
let spot = r.spot.clone();
|
||||
if let (Some(s), Some(&c)) = (&spot, r.chunks.first()) {
|
||||
let d = (s.x - r.centre.0).hypot(s.z - r.centre.1).round();
|
||||
rtp = Some((c, d as i64));
|
||||
}
|
||||
(spot, "a random spot".to_owned())
|
||||
}
|
||||
To::Place(place, what) => (Some(place.clone()), what.clone()),
|
||||
To::Spawn(place) => {
|
||||
spread = self.config.spawn_radius.max(0);
|
||||
@@ -596,15 +975,31 @@ impl Places {
|
||||
};
|
||||
match to {
|
||||
Some(to) => {
|
||||
let undo = (
|
||||
self.last.insert(p.uuid, now),
|
||||
self.back.insert(p.uuid, (p.from.clone(), false)),
|
||||
);
|
||||
let mut done = format!("Teleported to {what}.");
|
||||
let mut undo_rtp = None;
|
||||
if let Some((chunk, d)) = rtp {
|
||||
undo_rtp =
|
||||
Some(self.data.rtp.insert(smp_store::uuid_string(p.uuid), now));
|
||||
self.changed = true;
|
||||
// Held a little longer: the player's own view takes it over.
|
||||
self.release.push((chunk, now + 10_000));
|
||||
done = format!(
|
||||
"Teleported to a random spot {d} blocks from the spawn ({:.0} {:.0} {:.0}).",
|
||||
to.x.floor(),
|
||||
to.y,
|
||||
to.z.floor()
|
||||
);
|
||||
}
|
||||
let undo = Undo {
|
||||
last: self.last.insert(p.uuid, now),
|
||||
back: self.back.insert(p.uuid, (p.from.clone(), false)),
|
||||
rtp: undo_rtp,
|
||||
};
|
||||
go.push(Teleport {
|
||||
uuid: p.uuid,
|
||||
name: me.name.clone(),
|
||||
to,
|
||||
done: format!("Teleported to {what}."),
|
||||
done,
|
||||
what,
|
||||
spread,
|
||||
undo,
|
||||
@@ -899,4 +1294,400 @@ mod tests {
|
||||
assert_eq!(go[0].spread_line(), None);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
/// A chunk of the same column everywhere: `ground`, then `feet` and
|
||||
/// `head`, the ground's top at y 64.
|
||||
fn chunk(c: (i32, i32), ground: &str, feet: &str, head: &str) -> Ground {
|
||||
let palette = vec![ground.to_owned(), feet.to_owned(), head.to_owned()];
|
||||
let mut columns = Vec::new();
|
||||
for dx in 0..16 {
|
||||
for dz in 0..16 {
|
||||
columns.push((c.0 * 16 + dx, c.1 * 16 + dz, 65, [0, 1, 2]));
|
||||
}
|
||||
}
|
||||
Ground { palette, columns }
|
||||
}
|
||||
fn grass(c: (i32, i32)) -> Option<Ground> {
|
||||
Some(chunk(
|
||||
c,
|
||||
"minecraft:grass_block",
|
||||
"minecraft:air",
|
||||
"minecraft:short_grass",
|
||||
))
|
||||
}
|
||||
fn distance(t: &Teleport) -> f64 {
|
||||
t.to.x.hypot(t.to.z)
|
||||
}
|
||||
/// An `/rtp` from `here` through its warmup to the teleport, every
|
||||
/// chunk read as `land`; the time it ended.
|
||||
fn rtp_to(
|
||||
p: &mut Places,
|
||||
here: &[Body],
|
||||
now: i64,
|
||||
land: &mut dyn FnMut(u128, (i32, i32)) -> Option<Ground>,
|
||||
) -> (Vec<Out>, Vec<Teleport>) {
|
||||
let out = p.command(&player(A, 0), here, now, "rtp").unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![Out::Reply("Teleporting in 3 s: don't move.".into())]
|
||||
);
|
||||
for _ in 0..8 {
|
||||
p.rtp_look(land);
|
||||
}
|
||||
p.tick(here, now + 3_000)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtp_points_cover_the_ring_evenly() {
|
||||
let mut rng = 7;
|
||||
let n = 20_000;
|
||||
let mut inner = 0;
|
||||
for _ in 0..n {
|
||||
let (x, z) = ring_point(
|
||||
(100.0, -50.0),
|
||||
500.0,
|
||||
5000.0,
|
||||
unit(&mut rng),
|
||||
unit(&mut rng),
|
||||
);
|
||||
let d = (x - 100.0).hypot(z + 50.0);
|
||||
assert!((500.0 - 1e-6..=5000.0 + 1e-6).contains(&d), "{d}");
|
||||
// Half the ring's area is within sqrt((500² + 5000²) / 2) blocks.
|
||||
inner += usize::from(d < ((500f64.powi(2) + 5000f64.powi(2)) / 2.0).sqrt());
|
||||
}
|
||||
let share = inner as f64 / n as f64;
|
||||
assert!(
|
||||
(0.48..0.52).contains(&share),
|
||||
"uniform over the area, not the radius: {share}"
|
||||
);
|
||||
// The ring's edges themselves.
|
||||
assert_eq!(
|
||||
ring_point((0.0, 0.0), 500.0, 5000.0, 0.0, 0.0),
|
||||
(500.0, 0.0)
|
||||
);
|
||||
assert_eq!(
|
||||
ring_point((0.0, 0.0), 500.0, 5000.0, 1.0, 0.0),
|
||||
(5000.0, 0.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtp_lands_in_the_ring_inside_the_border() {
|
||||
let d = dir();
|
||||
let mut p = Places::open(&d, Config::default()).unwrap();
|
||||
p.rng = 42;
|
||||
p.rng = 1;
|
||||
// A border 2,000 blocks wide round the spawn: every chunk tried and
|
||||
// every spot is inside it, and 500 blocks out at least.
|
||||
p.border = Some([-1000.0, -1000.0, 1000.0, 1000.0]);
|
||||
for i in 0..50 {
|
||||
let now = i * 10_000_000;
|
||||
let here = [body(A, 0.0)];
|
||||
let mut asked = Vec::new();
|
||||
let (_, go) = rtp_to(&mut p, &here, now, &mut |_, c| {
|
||||
asked.push(c);
|
||||
grass(c)
|
||||
});
|
||||
assert_eq!(go.len(), 1);
|
||||
let t = &go[0];
|
||||
assert!((500.0..=5000.0).contains(&distance(t)), "{:?}", t.to);
|
||||
assert!(t.to.x.abs() <= 984.0 && t.to.z.abs() <= 984.0, "{:?}", t.to);
|
||||
assert_eq!((t.to.dimension.as_str(), t.to.y), (OVERWORLD, 65.0));
|
||||
assert_eq!(t.spread_line(), None, "the spot is exact");
|
||||
assert!(
|
||||
t.done.starts_with("Teleported to a random spot "),
|
||||
"{}",
|
||||
t.done
|
||||
);
|
||||
for (x, z) in asked {
|
||||
assert!((x * 16).abs() <= 1000 && (z * 16).abs() <= 1000, "{x} {z}");
|
||||
}
|
||||
}
|
||||
// A border closer than the ring: nothing to try.
|
||||
p.border = Some([-400.0, -400.0, 400.0, 400.0]);
|
||||
let out = p
|
||||
.command(&player(A, 0), &[body(A, 0.0)], 1_000_000_000, "rtp")
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Error(s) if s.contains("world border")),
|
||||
"{out:?}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtp_has_its_own_cooldown_kept_over_a_restart() {
|
||||
let d = dir();
|
||||
let mut p = Places::open(&d, Config::default()).unwrap();
|
||||
p.rng = 42;
|
||||
let here = [body(A, 0.0)];
|
||||
let (_, go) = rtp_to(&mut p, &here, 0, &mut |_, c| grass(c));
|
||||
assert_eq!(go.len(), 1);
|
||||
// Other teleports wait only their own short cooldown.
|
||||
p.command(&player(A, 3), &here, 20_000, "sethome").unwrap();
|
||||
let out = p.command(&player(A, 0), &here, 20_000, "home").unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Reply(s) if s.starts_with("Teleporting")),
|
||||
"{out:?}"
|
||||
);
|
||||
p.tick(&[body(A, 1.0)], 20_500);
|
||||
let out = p
|
||||
.command(&player(A, 0), &here, 18 * 60_000 + 3_000, "rtp")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![Out::Error("Wait 12 min before your next /rtp.".into())]
|
||||
);
|
||||
let out = p
|
||||
.command(&player(A, 0), &here, 1_800_000 + 2_500, "wild")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![Out::Error("Wait 1 s before your next /rtp.".into())]
|
||||
);
|
||||
// Written, and read back after a restart.
|
||||
p.save(0).unwrap();
|
||||
let mut again = Places::open(&d, Config::default()).unwrap();
|
||||
again.rng = 43;
|
||||
let out = again.command(&player(A, 0), &here, 60_000, "rtp").unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![Out::Error("Wait 30 min before your next /rtp.".into())]
|
||||
);
|
||||
let out = again
|
||||
.command(&player(A, 0), &here, 1_803_000, "rtp")
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Reply(_)),
|
||||
"allowed after it: {out:?}"
|
||||
);
|
||||
// Someone else isn't held up by it.
|
||||
let out = again
|
||||
.command(&player(B, 0), &[body(B, 0.0)], 60_000, "rtp")
|
||||
.unwrap();
|
||||
assert!(matches!(&out[0], Out::Reply(_)), "{out:?}");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtp_without_a_spot_or_called_off_costs_no_cooldown() {
|
||||
let d = dir();
|
||||
let mut p = Places::open(&d, Config::default()).unwrap();
|
||||
p.rng = 42;
|
||||
let here = [body(A, 0.0)];
|
||||
// Ocean everywhere: every chunk tried, one after another, then it says so.
|
||||
let mut asked = Vec::new();
|
||||
let (out, go) = rtp_to(&mut p, &here, 0, &mut |_, c| {
|
||||
asked.push(c);
|
||||
Some(chunk(
|
||||
c,
|
||||
"minecraft:water",
|
||||
"minecraft:air",
|
||||
"minecraft:air",
|
||||
))
|
||||
});
|
||||
assert!(go.is_empty());
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![Out::Tell(
|
||||
A,
|
||||
"No safe spot found for /rtp: try again.".into()
|
||||
)]
|
||||
);
|
||||
assert_eq!(asked.len(), 6, "rtp_attempts chunks: {asked:?}");
|
||||
// Land that never loads: given up after rtp_search_seconds.
|
||||
let (out, go) = rtp_to(&mut p, &here, 10_000, &mut |_, _| None);
|
||||
assert!(go.is_empty());
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![Out::Tell(A, "Looking for a safe spot: don't move.".into())]
|
||||
);
|
||||
let (out, _) = p.tick(&here, 10_000 + 3_000 + 30_000);
|
||||
assert!(out.is_empty(), "{out:?}");
|
||||
let (out, _) = p.tick(&here, 10_000 + 3_000 + 30_001);
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![Out::Tell(
|
||||
A,
|
||||
"/rtp couldn't load the land in time: try again.".into()
|
||||
)]
|
||||
);
|
||||
// The game couldn't make the teleport: no cooldown either.
|
||||
let (_, go) = rtp_to(&mut p, &here, 50_000, &mut |_, c| grass(c));
|
||||
p.call_off(&go[0]);
|
||||
let (_, go) = rtp_to(&mut p, &here, 60_000, &mut |_, c| grass(c));
|
||||
assert_eq!(go.len(), 1, "allowed at once");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtp_works_only_in_the_overworld() {
|
||||
let d = dir();
|
||||
let mut p = Places::open(&d, Config::default()).unwrap();
|
||||
p.rng = 42;
|
||||
for level in ["minecraft:the_nether", "minecraft:the_end"] {
|
||||
let mut b = body(A, 0.0);
|
||||
b.at.dimension = level.into();
|
||||
let out = p.command(&player(A, 0), &[b], 0, "rtp").unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![Out::Error("/rtp only works in the Overworld.".into())]
|
||||
);
|
||||
}
|
||||
assert!(p.pending.is_empty() && p.data.rtp.is_empty());
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtp_lands_only_on_safe_ground() {
|
||||
let d = dir();
|
||||
let mut p = Places::open(&d, Config::default()).unwrap();
|
||||
p.rng = 42;
|
||||
let here = [body(A, 0.0)];
|
||||
// Each chunk tried has one kind of ground, the last a safe one.
|
||||
let bad = [
|
||||
("minecraft:lava", "minecraft:air", "minecraft:air"),
|
||||
("minecraft:cactus", "minecraft:air", "minecraft:air"),
|
||||
("minecraft:magma_block", "minecraft:air", "minecraft:air"),
|
||||
("minecraft:powder_snow", "minecraft:air", "minecraft:air"),
|
||||
("minecraft:stone", "minecraft:fire", "minecraft:air"),
|
||||
("minecraft:stone", "minecraft:air", "minecraft:oak_log"),
|
||||
];
|
||||
let mut n = 0;
|
||||
let (out, go) = rtp_to(&mut p, &here, 0, &mut |_, c| {
|
||||
n += 1;
|
||||
Some(match bad.get(n - 1) {
|
||||
Some((g, f, h)) => chunk(c, g, f, h),
|
||||
None => chunk(c, "minecraft:sand", "minecraft:air", "minecraft:air"),
|
||||
})
|
||||
});
|
||||
assert_eq!(n, 6);
|
||||
assert!(
|
||||
go.is_empty(),
|
||||
"the six chunks were all unsafe: {go:?} {out:?}"
|
||||
);
|
||||
// One safe column among lava: that one.
|
||||
let (_, go) = rtp_to(&mut p, &here, 100_000, &mut |_, c| {
|
||||
let mut g = chunk(c, "minecraft:lava", "minecraft:air", "minecraft:air");
|
||||
g.palette.push("minecraft:dirt".into());
|
||||
g.columns[37].3[0] = 3;
|
||||
Some(g)
|
||||
});
|
||||
let (x, z) = (go[0].to.x - 0.5, go[0].to.z - 0.5);
|
||||
assert_eq!(x.rem_euclid(16.0), 2.0);
|
||||
assert_eq!(z.rem_euclid(16.0), 5.0);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtp_holds_its_chunks_loaded_and_lets_them_go() {
|
||||
let d = dir();
|
||||
let mut p = Places::open(&d, Config::default()).unwrap();
|
||||
p.rng = 42;
|
||||
let here = [body(A, 0.0)];
|
||||
p.command(&player(A, 0), &here, 0, "rtp").unwrap();
|
||||
let mut ran = Vec::new();
|
||||
p.force(0, &mut |l| {
|
||||
ran.push(l.to_owned());
|
||||
true
|
||||
});
|
||||
assert_eq!(ran.len(), 1, "one chunk at a time: {ran:?}");
|
||||
let first = ran[0].clone();
|
||||
assert!(
|
||||
first.starts_with("execute in minecraft:overworld run forceload add "),
|
||||
"{first}"
|
||||
);
|
||||
// The chunk isn't loaded yet: nothing more.
|
||||
p.rtp_look(&mut |_, _| None);
|
||||
ran.clear();
|
||||
p.force(100, &mut |l| {
|
||||
ran.push(l.to_owned());
|
||||
true
|
||||
});
|
||||
assert!(ran.is_empty(), "{ran:?}");
|
||||
// Ocean: the next one is held, the first let go.
|
||||
p.rtp_look(&mut |_, c| {
|
||||
Some(chunk(
|
||||
c,
|
||||
"minecraft:water",
|
||||
"minecraft:air",
|
||||
"minecraft:air",
|
||||
))
|
||||
});
|
||||
p.force(200, &mut |l| {
|
||||
ran.push(l.to_owned());
|
||||
true
|
||||
});
|
||||
assert_eq!(ran.len(), 2, "{ran:?}");
|
||||
assert!(ran[0].contains(" add "), "{ran:?}");
|
||||
assert_eq!(ran[1], first.replace(" add ", " remove "));
|
||||
let second = ran[0].replace(" add ", " remove ");
|
||||
// Held over a restart: let go at the next start.
|
||||
p.save(0).unwrap();
|
||||
let mut again = Places::open(&d, Config::default()).unwrap();
|
||||
again.rng = 43;
|
||||
ran.clear();
|
||||
again.force(0, &mut |l| {
|
||||
ran.push(l.to_owned());
|
||||
true
|
||||
});
|
||||
assert_eq!(ran, vec![second], "the one held when it stopped");
|
||||
// Landed: held 10 s more, then let go.
|
||||
p.rtp_look(&mut |_, c| grass(c));
|
||||
let (_, go) = p.tick(&here, 3_000);
|
||||
assert_eq!(go.len(), 1);
|
||||
ran.clear();
|
||||
p.force(12_000, &mut |l| {
|
||||
ran.push(l.to_owned());
|
||||
true
|
||||
});
|
||||
assert!(ran.is_empty(), "{ran:?}");
|
||||
p.force(13_000, &mut |l| {
|
||||
ran.push(l.to_owned());
|
||||
true
|
||||
});
|
||||
assert_eq!(ran.len(), 1);
|
||||
assert!(ran[0].contains(" remove "), "{ran:?}");
|
||||
// A chunk someone forced already: used, never let go.
|
||||
p.command(&player(A, 0), &here, 4_000_000, "rtp").unwrap();
|
||||
ran.clear();
|
||||
p.force(4_000_000, &mut |l| {
|
||||
ran.push(l.to_owned());
|
||||
false
|
||||
});
|
||||
assert_eq!(ran.len(), 1);
|
||||
p.rtp_look(&mut |_, c| grass(c));
|
||||
p.tick(&here, 4_003_000);
|
||||
ran.clear();
|
||||
p.force(5_000_000, &mut |l| {
|
||||
ran.push(l.to_owned());
|
||||
true
|
||||
});
|
||||
assert!(ran.is_empty(), "{ran:?}");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_move_calls_an_rtp_off_while_it_looks() {
|
||||
let d = dir();
|
||||
let mut p = Places::open(&d, Config::default()).unwrap();
|
||||
p.rng = 42;
|
||||
let here = [body(A, 0.0)];
|
||||
p.command(&player(A, 0), &here, 0, "rtp").unwrap();
|
||||
let (out, _) = p.tick(&here, 3_000);
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![Out::Tell(A, "Looking for a safe spot: don't move.".into())]
|
||||
);
|
||||
let (out, go) = p.tick(&[body(A, 2.0)], 4_000);
|
||||
assert!(go.is_empty());
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![Out::Tell(A, "Teleport called off: you moved.".into())]
|
||||
);
|
||||
assert!(p.rtp_wanted(4_000).is_empty(), "its chunk is let go");
|
||||
let out = p.command(&player(A, 0), &here, 5_000, "rtp").unwrap();
|
||||
assert!(matches!(&out[0], Out::Reply(_)), "no cooldown: {out:?}");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
}
|
||||
@@ -447,7 +447,7 @@ mod tests {
|
||||
assert!(recipes.len() > 1000, "{} recipes", recipes.len());
|
||||
let config = Config::default();
|
||||
assert_eq!(loops(&config.items, &recipes), []);
|
||||
// Nor once prices have moved as far as they may (0.6 to 1.6 times
|
||||
// Nor once prices have moved as far as they may (0.2 to 1.6 times
|
||||
// their base): an item sold at the top while what makes it is
|
||||
// bought at the bottom, unless both share one price (a family).
|
||||
let families = super::super::smp_market::families(&config.items);
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// categories; a category's page its items, with what the market asks and
|
||||
// pays (in its name). A click buys one, a shift-click a stack of 64; a click on a stack in
|
||||
// your own inventory sells it; "Sell all" sells everything the market buys.
|
||||
// Buying and selling go through the market's own rules (its prices, today's
|
||||
// limits, items first then money, logged). This file only lays the pages out
|
||||
// Buying and selling go through the market's own rules (its prices,
|
||||
// items first then money, logged). This file only lays the pages out
|
||||
// and says what a click means; the glue opens the menu and moves the items.
|
||||
// No game types. Added to the game as `game::smp_shop`; also a module of this
|
||||
// crate, for its tests.
|
||||
@@ -26,7 +26,6 @@ const HELP: usize = 46;
|
||||
const PREV: usize = 47;
|
||||
const BALANCE: usize = 48;
|
||||
const SELL_ALL: usize = 49;
|
||||
const LIMITS: usize = 50;
|
||||
const NEXT: usize = 51;
|
||||
const CLOSE: usize = 53;
|
||||
/// Where the first page puts the categories: the middle of rows 2 to 4.
|
||||
@@ -183,7 +182,6 @@ pub enum Cell {
|
||||
Next,
|
||||
Balance,
|
||||
SellAll,
|
||||
Limits,
|
||||
Help,
|
||||
Close,
|
||||
}
|
||||
@@ -254,7 +252,6 @@ impl Shop {
|
||||
cells[HELP] = Cell::Help;
|
||||
cells[BALANCE] = Cell::Balance;
|
||||
cells[SELL_ALL] = Cell::SellAll;
|
||||
cells[LIMITS] = Cell::Limits;
|
||||
cells[CLOSE] = Cell::Close;
|
||||
match page {
|
||||
Page::Main => {
|
||||
|
||||
@@ -98,7 +98,7 @@ impl Part {
|
||||
"invsee" | "endersee" => Part::Invsee,
|
||||
"spawn" | "setspawn" | "home" | "sethome" | "delhome" | "homes" | "warp" | "warps"
|
||||
| "setwarp" | "delwarp" | "tpa" | "tpaccept" | "tpdeny" | "rules" | "back"
|
||||
| "playtime" => Part::Places,
|
||||
| "playtime" | "rtp" | "wild" => Part::Places,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
+208
-36
@@ -19,12 +19,13 @@ Steve, `horizon-ctl --offscreen`) join it. Checked from the clients' own chat an
|
||||
the pot;
|
||||
- after the server stops cleanly and starts again, the balances are the same, and audit.log holds every change;
|
||||
- an operator's /eco give, then the /shop menu: a category, one item bought by a click and a stack by
|
||||
a shift-click, a stack of the player's own sold by clicking it, "Sell all" stopped by the daily limit (the rest
|
||||
kept, the limit shown in the menu and in /worth, the day's sales in market.json), the close button;
|
||||
- after the restart, prices moving: a purchase and a sale with no tax (receipts), an item bought can't be sold
|
||||
back while one found can, one price for diamond, moved down by a sale and up by a purchase as they go, /market's item
|
||||
a shift-click, a stack of the player's own sold by clicking it, "Sell all", the close button;
|
||||
- after the restart, prices moving: a purchase and a sale with no tax (receipts), bought items can be sold
|
||||
back immediately, one price for diamond, moved down by a sale and up by a purchase as they go, /market's item
|
||||
page buying, the 24 h chart given as a map and drawn in the player's hand (market-chart.png), web/market.json, and
|
||||
market_prices.json written when the world closes;
|
||||
- a farm's dump with no daily cap: 1,000 wheat (more than the old 640 a day) sold over four minutes, each minute's
|
||||
sale stopped by the 5% band, the price lower after each, and /worth showing no daily limit;
|
||||
- the web pages (web/market.json, web/economy.json) sent gzipped with the token to a stand-in for Kira;
|
||||
- /invsee: an operator takes a stack from an offline player's saved inventory, logged before the move and summed up in
|
||||
staff.log;
|
||||
@@ -32,7 +33,12 @@ Steve, `horizon-ctl --offscreen`) join it. Checked from the clients' own chat an
|
||||
protection (the economy refuses, the sidebar goes), then everything on again, each in audit.log;
|
||||
- nicknames, for operators only: a player can't pick one; an operator's refused ones (a player's name, too short, a
|
||||
bad character), the one given in the other's chat and Tab list, /realname, someone else's taken, the player taking
|
||||
it off.
|
||||
it off;
|
||||
- /rtp: refused in the Nether; in the Overworld the player lands 500 to 5,000 blocks from the spawn on solid ground
|
||||
(and stays there), a second /rtp is refused with the time left, and the chunk it held loaded is let go.
|
||||
|
||||
`SMP_PROOF=rtp` runs one /rtp alone on a normal (not flat) world's fresh land instead, and what it cost the server's
|
||||
ticks: a performance report (`horizon-report`) over the search and landing against one over the same time before it.
|
||||
|
||||
The casino has its own proof (proof_casino.py): `SMP_PROOF=casino` runs it instead of this one.
|
||||
|
||||
@@ -100,10 +106,10 @@ def scratch_env(root):
|
||||
class Server:
|
||||
"""`horizon-client --dedicated --nogui` in a scratch server folder, its console on stdin."""
|
||||
|
||||
def __init__(self, game, resources, folder, port):
|
||||
def __init__(self, game, resources, folder, port, properties=None):
|
||||
self.folder, self.lines, self.game, self.resources, self.port = folder, [], game, resources, port
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
props = dict(PROPERTIES, **{"server-port": str(port)})
|
||||
props = dict(PROPERTIES, **(properties or {}), **{"server-port": str(port)})
|
||||
(folder / "server.properties").write_text("".join(f"{k}={v}\n" for k, v in props.items()))
|
||||
(folder / "eula.txt").write_text("eula=true\n")
|
||||
self.env = scratch_env(folder.parent / "server-env")
|
||||
@@ -342,6 +348,135 @@ def places(server, alex, steve):
|
||||
return r
|
||||
|
||||
|
||||
def landed(c, text, timeout=120):
|
||||
"""Waits for a chat line with `text` (an /rtp's end), up to `timeout` s; the line."""
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
hit = [l for l in c.chat()[-6:] if text in l or "/rtp couldn't" in l or "No safe spot" in l]
|
||||
if hit:
|
||||
return hit[-1]
|
||||
time.sleep(1)
|
||||
raise AssertionError(f"{c.name}: no {text!r} in {timeout} s: {c.chat()[-8:]}")
|
||||
|
||||
|
||||
FLAT_GROUND = "if block ~ ~-1 ~ minecraft:grass_block if block ~ ~ ~ minecraft:air if block ~ ~1 ~ minecraft:air"
|
||||
SOLID_GROUND = ("unless block ~ ~-1 ~ minecraft:air unless block ~ ~-1 ~ minecraft:water "
|
||||
"unless block ~ ~-1 ~ minecraft:lava")
|
||||
|
||||
|
||||
def standing(server, c, at, tests=FLAT_GROUND):
|
||||
"""Whether the blocks round `at` pass `tests` (execute's conditions), as the server sees them: it says so."""
|
||||
x, y, z = (math.floor(v) for v in at)
|
||||
before = len(server.lines)
|
||||
server.console(f"execute in minecraft:overworld positioned {x} {y} {z} {tests} run say rtp-ground-ok")
|
||||
time.sleep(3)
|
||||
seen = server.lines[before:] + c.chat()[-4:]
|
||||
return any("rtp-ground-ok" in l for l in seen), seen
|
||||
|
||||
|
||||
def rtp(server, steve, data):
|
||||
"""/rtp: refused in the Nether; to a random spot 500..5000 blocks from the spawn, on the ground; then its cooldown."""
|
||||
r = {}
|
||||
spawn = json.loads((data / "places.json").read_text())["spawn"]
|
||||
_, home, _ = where(steve)
|
||||
server.console("execute in minecraft:the_nether run tp Steve 0.5 128 0.5")
|
||||
time.sleep(5)
|
||||
r["nether"] = steve.say("/rtp")
|
||||
assert any("/rtp only works in the Overworld." in l for l in r["nether"]), r
|
||||
server.console(f"execute in minecraft:overworld run tp Steve {home[0]:.2f} {home[1]:.2f} {home[2]:.2f}")
|
||||
time.sleep(5)
|
||||
start = len(server.lines)
|
||||
r["asked"] = steve.say("/wild")
|
||||
assert any("Teleporting in 2 s: don't move." in l for l in r["asked"]), r
|
||||
r["landed"] = landed(steve, "Teleported to a random spot")
|
||||
assert "Teleported to a random spot" in r["landed"], r
|
||||
time.sleep(3)
|
||||
dim, at, health = where(steve)
|
||||
r["at"], r["distance"] = at, math.hypot(at[0] - spawn["x"], at[2] - spawn["z"])
|
||||
assert dim == "minecraft:overworld" and 499 <= r["distance"] <= 5001, r
|
||||
# On the ground, not falling: the same place a few seconds later, unhurt.
|
||||
time.sleep(4)
|
||||
_, still, health2 = where(steve)
|
||||
assert near(at, still) and health2 >= health, (r, still, health2)
|
||||
r["ground"], lines = standing(server, steve, at)
|
||||
assert r["ground"], (r, lines)
|
||||
log = [l for l in server.lines[start:] if "[smp] /rtp: Steve" in l]
|
||||
assert log, server.lines[start:][-20:]
|
||||
r["log"] = log[-1]
|
||||
r["slow_ticks"] = [l for l in server.lines[start:] if "slow tick" in l or "Can't keep up" in l]
|
||||
r["again"] = steve.say("/rtp")
|
||||
assert any("Wait 30 min before your next /rtp." in l for l in r["again"]), r
|
||||
# The chunk it held loaded is let go 10 s after the landing.
|
||||
time.sleep(12)
|
||||
saved = json.loads((data / "places.json").read_text())
|
||||
assert not saved.get("forced") and len(saved.get("rtp", {})) == 1, saved
|
||||
server.console(f"execute in minecraft:overworld run tp Steve {home[0]:.2f} {home[1]:.2f} {home[2]:.2f}")
|
||||
time.sleep(3)
|
||||
return r
|
||||
|
||||
|
||||
def report(server, seconds):
|
||||
"""A performance report over the next `seconds`: its ticks (`mspt`, `over_50_ms`...)."""
|
||||
folder = server.folder / "reports"
|
||||
before = set(folder.glob("perf-*.json")) if folder.exists() else set()
|
||||
server.console(f"horizon-report {seconds}")
|
||||
end = time.time() + seconds + 60
|
||||
while time.time() < end:
|
||||
made = set(folder.glob("perf-*.json")) - before if folder.exists() else set()
|
||||
if made:
|
||||
time.sleep(1)
|
||||
return json.loads(made.pop().read_text())["ticks"]
|
||||
time.sleep(1)
|
||||
raise AssertionError(f"no report after {seconds} s: {server.lines[-20:]}")
|
||||
|
||||
|
||||
def rtp_alone(ctl, resources, out):
|
||||
"""SMP_PROOF=rtp: one /rtp on a normal world's fresh land (view distance 8, as the official server), with the
|
||||
ticks' cost: a 30 s report from the /rtp on, against a 30 s report just before it."""
|
||||
out = Path(out)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
game = Path(ctl).with_name("horizon-client")
|
||||
root = Path(tempfile.mkdtemp(prefix="smp-rtp-"))
|
||||
port = free_port()
|
||||
server = Server(game, resources, root / "server", port, {"level-type": "minecraft\\:normal", "view-distance": "8",
|
||||
"generate-structures": "true"})
|
||||
server.env["HORIZON_SPIKES"] = "50"
|
||||
steve = Client(ctl, resources, "Steve", root)
|
||||
r = {}
|
||||
try:
|
||||
server.start()
|
||||
steve.join(port)
|
||||
# The spawn's land settles first.
|
||||
time.sleep(30)
|
||||
r["before"] = report(server, 30)
|
||||
start = len(server.lines)
|
||||
r["asked"] = steve.say("/rtp")
|
||||
assert any("Teleporting in 3 s: don't move." in l for l in r["asked"]), r
|
||||
server.console("horizon-report 30")
|
||||
r["landed"] = landed(steve, "Teleported to a random spot", 90)
|
||||
assert "Teleported to a random spot" in r["landed"], r
|
||||
folder = server.folder / "reports"
|
||||
end = time.time() + 120
|
||||
while len(list(folder.glob("perf-*.json"))) < 2 and time.time() < end:
|
||||
time.sleep(1)
|
||||
r["during"] = json.loads(max(folder.glob("perf-*.json"), key=lambda f: f.stat().st_mtime).read_text())["ticks"]
|
||||
time.sleep(5)
|
||||
dim, at, _ = where(steve)
|
||||
r["at"], r["distance"] = at, math.hypot(at[0], at[2])
|
||||
assert dim == "minecraft:overworld" and 499 <= r["distance"] <= 5001, r
|
||||
r["ground"], _ = standing(server, steve, at, SOLID_GROUND)
|
||||
r["log"] = [l for l in server.lines[start:] if "[smp] /rtp: Steve" in l]
|
||||
r["slow_ticks"] = [l for l in server.lines[start:] if "slow tick" in l or "Can't keep up" in l]
|
||||
print(json.dumps({"proof": "rtp", "ok": True, **r}, indent=2))
|
||||
finally:
|
||||
(out / "server.log").write_text("\n".join(server.lines))
|
||||
steve.quit()
|
||||
server.stop()
|
||||
if server.__dict__.get("process") and server.process.poll() is None:
|
||||
server.process.kill()
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
|
||||
def claims(server, alex, steve):
|
||||
"""Alex claims a chunk: Steve can't break, use or place there until trusted."""
|
||||
r = {}
|
||||
@@ -635,7 +770,7 @@ def nicks(server, alex, steve, out):
|
||||
|
||||
def shop(server, alex):
|
||||
"""Alex alone, op, in survival: given money (/eco give), then the /shop menu: a category, buying one and a stack,
|
||||
selling a stack of hers, "Sell all" up to the daily limit, closing. Returns the coins it added, and the lines."""
|
||||
selling a stack of hers, "Sell all" without a daily limit, closing. Returns the coins it added, and the lines."""
|
||||
results = {}
|
||||
# Only what the step gives her: "Sell all" would sell the rest too.
|
||||
server.console("clear Alex")
|
||||
@@ -660,42 +795,40 @@ def shop(server, alex):
|
||||
alex.run("click-slot", 300 + 0, "shift") # a stack of cobblestone
|
||||
time.sleep(1)
|
||||
assert alex.count("minecraft:cobblestone") == 64, alex.run("inventory")
|
||||
# Along the price as the stack moves it: 64 at 1 coin come to 64.05, 65 whole coins.
|
||||
# Along the price as the stack moves it (50 a 1%): 64 at 1 coin come to 64.41, 65 whole coins.
|
||||
results["stack"] = alex.chat()[-4:] # an advancement may come after
|
||||
assert any("Bought 64 cobblestone at 1.02 coins each: 65 coins." in l for l in results["stack"]), results
|
||||
assert not any("Tax" in l for l in results["stack"]), results
|
||||
# Her own stack of iron, clicked: the ingot she just bought stays (no buying to sell back), one she found sells.
|
||||
# Clear the cobblestone so the later Sell all step only tests diamonds.
|
||||
server.console("clear Alex minecraft:cobblestone 64")
|
||||
time.sleep(1)
|
||||
# The iron she just bought can be sold back immediately.
|
||||
alex.click(alex.slot_of("minecraft:iron_ingot"))
|
||||
results["round_trip"] = alex.chat()[-1:]
|
||||
assert any("You bought 1 iron ingot from the market in the last 24 h" in l for l in results["round_trip"]), results
|
||||
results["round_trip"] = alex.chat()[-3:]
|
||||
assert any("Sold 1 iron ingot at 15 coins each: 15 coins." in l for l in results["round_trip"]), results
|
||||
assert alex.count("minecraft:iron_ingot") == 0
|
||||
server.console("give Alex minecraft:iron_ingot 1")
|
||||
time.sleep(2)
|
||||
alex.click(alex.slot_of("minecraft:iron_ingot"))
|
||||
results["sold"] = alex.chat()[-4:]
|
||||
# Sold at the price she bought at: one price.
|
||||
assert any("Sold 1 iron ingot at 15 coins each: 15 coins." in l for l in results["sold"]), results
|
||||
assert any("1 iron ingot kept: bought from the market in the last 24 h." in l for l in results["sold"]), results
|
||||
assert alex.count("minecraft:iron_ingot") == 1
|
||||
# Sell all, up to the 2,000 coins a day: 1,984 left (1 from Steve's /pay in the nickname step: money received
|
||||
# counts too; 15 for the iron), 9 diamonds at 200. Each one sold lowers the price: 40,000 * (1 - e^-0.045)
|
||||
# = 1,760.1 for the 9.
|
||||
server.console("give Alex minecraft:diamond 30")
|
||||
assert alex.count("minecraft:iron_ingot") == 0
|
||||
# Sell all nine diamonds, with no daily cap. Along the curve they earn
|
||||
# 40,000 * (1 - e^-0.045) = 1,760.1, rounded down to 1,760 coins.
|
||||
server.console("give Alex minecraft:diamond 9")
|
||||
time.sleep(2)
|
||||
alex.click(300 + 49)
|
||||
results["sell_all"] = alex.chat()[-8:]
|
||||
assert any("Sold 9 diamond at 196 coins each: 1,760 coins." in l for l in results["sell_all"]), results
|
||||
assert not any("Tax" in l for l in results["sell_all"]), results
|
||||
assert any("21 diamond kept: today's selling limit." in l for l in results["sell_all"]), results
|
||||
assert alex.count("minecraft:diamond") == 21, alex.run("inventory")
|
||||
limits = alex.menu()[50]
|
||||
results["limits"] = limits
|
||||
# 1,984 - 1,760.
|
||||
assert "224 coins left of your 2,000 coins a day" in json.dumps(limits), limits
|
||||
assert alex.count("minecraft:diamond") == 0, alex.run("inventory")
|
||||
assert alex.menu()[50]["item"] == "minecraft:gray_stained_glass_pane", alex.menu()
|
||||
alex.click(300 + 53) # close
|
||||
assert not alex.menu()
|
||||
results["worth"] = alex.say("/worth diamond")
|
||||
assert any("You may sell 631 more diamond today." in l for l in results["worth"]), results
|
||||
return 2000 + 10 - 15 - 65 + 15 + 1760, results
|
||||
assert any("diamond:" in l for l in results["worth"]), results
|
||||
return 2000 + 10 - 15 - 65 + 15 + 15 + 1760, results
|
||||
|
||||
|
||||
def invsee(alex, data):
|
||||
@@ -781,6 +914,47 @@ def market(server, alex, out, data):
|
||||
return paid, results
|
||||
|
||||
|
||||
def farm(server, alex):
|
||||
"""No daily sale cap: Alex sells 1,000 wheat, more than the old 640 a day, a minute at a time. Each minute's
|
||||
sale stops at the 5% band (250 wheat at 50 a 1%), and the price is lower after each. Returns the coins and lines."""
|
||||
results = {}
|
||||
|
||||
def worth():
|
||||
lines = alex.say("/worth wheat")
|
||||
line = next(l for l in lines if "wheat: " in l and "each, to buy or sell." in l)
|
||||
return float(re.search(r"wheat: ([\d.,]+) coins? each", line)[1].replace(",", "")), lines
|
||||
|
||||
server.console("give Alex minecraft:wheat 1000")
|
||||
time.sleep(2)
|
||||
assert alex.count("minecraft:wheat") == 1000, alex.run("inventory")
|
||||
price, lines = worth()
|
||||
results["worth_before"] = lines
|
||||
assert not any("today" in l for l in lines), lines
|
||||
got, prices, sales = 0, [price], []
|
||||
for minute in range(4):
|
||||
if minute:
|
||||
time.sleep(61) # the next minute's band
|
||||
lines = alex.say("/sell wheat")
|
||||
sold = [re.search(r"Sold (\d+) wheat at [\d.,]+ coins? each: ([\d,]+) coins?\.", l) for l in lines]
|
||||
sold = [m for m in sold if m]
|
||||
assert len(sold) == 1, lines
|
||||
n, coins = int(sold[0][1]), int(sold[0][2].replace(",", ""))
|
||||
sales.append(n)
|
||||
got += coins
|
||||
# 250 a minute; what the band keeps stays in her inventory.
|
||||
assert n == 250, lines
|
||||
assert minute == 3 or any("kept: its price moved 5% this minute" in l for l in lines), lines
|
||||
assert not any("limit" in l for l in lines), lines
|
||||
price, wl = worth()
|
||||
assert price < prices[-1], (prices, wl)
|
||||
prices.append(price)
|
||||
assert alex.count("minecraft:wheat") == 0, alex.run("inventory")
|
||||
results["sales"], results["prices"], results["coins"] = sales, prices, got
|
||||
# Four 5% steps down from 2 (a little drift back between): about 2 * e^-0.2 = 1.64.
|
||||
assert 1.6 < prices[-1] < 1.7 and 1500 < got < 2000, results
|
||||
return got, results
|
||||
|
||||
|
||||
def trades(server, alex, steve, port, ctl, resources, root):
|
||||
"""Every way out of a trade, then one that goes through. Alex: 10 diamonds; Steve: 5 emeralds, 5 torches."""
|
||||
results = {}
|
||||
@@ -907,6 +1081,8 @@ def run(ctl, resources, out):
|
||||
casino = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(casino)
|
||||
return casino.run(ctl, resources, out)
|
||||
if os.environ.get("SMP_PROOF") == "rtp":
|
||||
return rtp_alone(ctl, resources, out)
|
||||
out = Path(out)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
game = Path(ctl).with_name("horizon-client")
|
||||
@@ -970,12 +1146,10 @@ def run(ctl, resources, out):
|
||||
assert any("You paid 120 coins to Steve. Balance: 380 coins" in l for l in results["pay"]), results
|
||||
results["paid"] = steve.chat()[-3:]
|
||||
assert any("Alex paid you 120 coins." in l for l in results["paid"]), results
|
||||
# 9999 is past what Steve may still receive today (2,000 a day, 120 received): money paid counts toward the
|
||||
# same daily limit as selling, so alts can't sell for a main past it.
|
||||
results["refused"] = [alex.say(l) for l in ("/pay Steve 1500", "/pay Steve 9999", "/pay Alex 1",
|
||||
"/pay Nobody 1")]
|
||||
for lines, error in zip(results["refused"], ("You only have 380 coins.",
|
||||
"Steve can only receive 1,880 coins more today",
|
||||
"You only have 380 coins.",
|
||||
"You can't pay yourself.",
|
||||
"No player named Nobody has an account.")):
|
||||
assert any(error in l for l in lines), results["refused"]
|
||||
@@ -1016,6 +1190,7 @@ def run(ctl, resources, out):
|
||||
results["trades"], steve = trades(server, alex, steve, port, ctl, resources, root)
|
||||
# Before the coinflips: their last offer must still wait when the server stops.
|
||||
results["places"] = places(server, alex, steve)
|
||||
results["rtp"] = rtp(server, steve, data)
|
||||
results["claims"] = claims(server, alex, steve)
|
||||
results["world_guard"] = world_guard(server, alex, steve)
|
||||
results["board"] = board(alex, steve, out)
|
||||
@@ -1071,6 +1246,8 @@ def run(ctl, resources, out):
|
||||
assert not any("joined for the first time" in l for l in alex.chat()), alex.chat()
|
||||
added, results["shop"] = shop(server, alex)
|
||||
spent, results["market"] = market(server, alex, out, data)
|
||||
farmed, results["farm"] = farm(server, alex)
|
||||
added += farmed
|
||||
results["invsee"] = invsee(alex, data)
|
||||
results["switches"] = switches(alex, data)
|
||||
sent = {path: (auth, doc) for path, auth, doc in receiver.got}
|
||||
@@ -1098,17 +1275,12 @@ def run(ctl, resources, out):
|
||||
assert not json.loads((data / "switches.json").read_text())["off"], "everything on again"
|
||||
kinds = [a["kind"] for a in audit if not a["kind"].startswith(("invsee_", "switch_"))]
|
||||
shop_kinds = ["eco_give", "buy 1 minecraft:iron_ingot", "buy 64 minecraft:cobblestone",
|
||||
"sell 1 minecraft:iron_ingot", "sell 9 minecraft:diamond", "buy 1 minecraft:diamond"]
|
||||
"sell 1 minecraft:iron_ingot", "sell 1 minecraft:iron_ingot", "sell 9 minecraft:diamond", "buy 1 minecraft:diamond",
|
||||
*["sell 250 minecraft:wheat"] * 4]
|
||||
assert kinds[-len(shop_kinds):] == shop_kinds, kinds[-8:]
|
||||
kinds = kinds[:-len(shop_kinds)]
|
||||
assert kinds[-2] in ("held_returned", "coinflip_cancel") and kinds[-1] == "daily_reward", kinds[-3:]
|
||||
results["stake_back"] = kinds[-2]
|
||||
sold = json.loads((data / "market.json").read_text())["sold"]
|
||||
# Steve's sales of the first run's day, and Alex's; coins received by /pay count too (#139): Steve got 120 + 1
|
||||
# from Alex, Alex 1 from Steve.
|
||||
assert sorted((s["coins"], s["items"]) for s in sold.values()) == [
|
||||
(441, {"minecraft:diamond": 1, "minecraft:wheat": 60}),
|
||||
(1776, {"minecraft:diamond": 9, "minecraft:iron_ingot": 1})], sold
|
||||
print(json.dumps({"proof": "smp", "ok": True, **results}, indent=2))
|
||||
finally:
|
||||
(out / "server.log").write_text("\n".join(server.lines))
|
||||
|
||||
+17
-2
@@ -71,9 +71,24 @@ def get(url):
|
||||
|
||||
|
||||
def latest_nightly():
|
||||
"""The commit of the newest Nightly: its permanent nightly-source/<date>-<short> tag."""
|
||||
"""The commit of the newest Nightly: its permanent nightly-source/<date>-<short> tag.
|
||||
|
||||
The newest is picked by the tag's date, and a same-date tie by the tagged commit's own
|
||||
date: comparing the ref name alone breaks a tie by the short SHA, which can be an earlier
|
||||
build than the day's latest nightly.
|
||||
"""
|
||||
refs = json.loads(get(f"https://api.github.com/repos/{REPO}/git/matching-refs/tags/nightly-source/"))
|
||||
newest = max(refs, key=lambda r: r["ref"])
|
||||
def nightly_date(ref):
|
||||
return ref.split("nightly-source/")[1][:10]
|
||||
newest_day = max(nightly_date(r["ref"]) for r in refs)
|
||||
same_day = [r for r in refs if nightly_date(r["ref"]) == newest_day]
|
||||
if len(same_day) == 1:
|
||||
newest = same_day[0]
|
||||
else:
|
||||
def commit_time(r):
|
||||
commit = json.loads(get(f"https://api.github.com/repos/{REPO}/commits/{r['object']['sha']}"))
|
||||
return commit["commit"]["committer"]["date"]
|
||||
newest = max(same_day, key=commit_time)
|
||||
return get_commit(newest["ref"].removeprefix("refs/tags/"))
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user