mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 11:05:58 -10:00
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:
1 parent
dfd2102274
commit
43fb2513a2
9 files changed
+1093
-24
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.32.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "horizon-mod-smp"
|
||||
version = "0.31.0"
|
||||
version = "0.32.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user