smp: /rtp (/wild): random safe spot 500-5000 blocks out, 30 min cooldown (#241)

* smp: /rtp (/wild) to a random safe spot 500-5000 blocks from the spawn, 30 min cooldown

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* smp: /rtp draws until enough points fall inside a close border

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* smp: /rtp undo as a struct; rustfmt

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* smp: /rtp proof (2 clients, flat world) and SMP_PROOF=rtp tick cost on a normal world; per-/rtp read cost in the log

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* smp: proof checks the /rtp landing with execute ... run say (block tags aside)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 authored and GitHub committed 2026-11-04 21:02:45 +01:00
1 parent dfd2102274
commit 43fb2513a2
9 files changed
+1093 -24

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "horizon-mod-smp"
version = "0.31.0"
version = "0.32.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "horizon-mod-smp"
version = "0.31.0"
version = "0.32.0"
edition = "2024"
license = "MIT"
publish = false
+15
View File
@@ -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 |
@@ -226,6 +227,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
+113 -2
View File
@@ -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.32.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");
@@ -392,6 +397,8 @@ pub fn commands(commands: &mut Vec<NativeCommand>) {
"tpdeny [player:word]",
"rules",
"back",
"rtp",
"wild",
"playtime [player:word]",
"claim",
"claim info",
@@ -606,6 +613,93 @@ fn pay_target(e: &Economy, line: &str) -> Option<(u128, String, i64)> {
Some((to, account.name.clone(), amount))
}
/// 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;
};
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 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.
fn bodies(players: &[NativeServerPlayer]) -> Vec<Body> {
players
@@ -820,6 +914,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()
}
@@ -2293,8 +2392,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
+11 -1
View File
@@ -1,6 +1,6 @@
[mod]
id = "smp"
version = "0.31.0"
version = "0.32.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"
+5
View File
@@ -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);
}
+806 -15
View File
@@ -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);
}
}
+1 -1
View File
@@ -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,
})
}
+140 -3
View File
@@ -32,7 +32,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 +105,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 +347,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 = {}
@@ -907,6 +1041,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")
@@ -1016,6 +1152,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)