diff --git a/crates/horizon-client-render/src/chunk.rs b/crates/horizon-client-render/src/chunk.rs index 4abb0fa9a5..f5473263f6 100644 --- a/crates/horizon-client-render/src/chunk.rs +++ b/crates/horizon-client-render/src/chunk.rs @@ -225,6 +225,8 @@ pub const BIOME_STRATEGY: Strategy = Strategy::biomes(8); /// (what vanilla servers and clients send), more when content mods add states /// past 65,536 (docs/modpacks.md). Set once, before the first chunk. static BLOCK_BITS: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(16); +/// States in the registry, set with [`BLOCK_BITS`]. +static BLOCK_STATES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(u32::MAX); /// Block states are `PalettedContainer`s over the registry: vanilla's 16-bit /// global palette, wider with enough content mods. @@ -232,6 +234,35 @@ pub fn block_strategy() -> Strategy { Strategy::blocks(BLOCK_BITS.load(std::sync::atomic::Ordering::Relaxed)) } +/// Whether the registry has this state. Every id is known until the registry is loaded. +pub fn known_block_state(id: u32) -> bool { + id < BLOCK_STATES.load(std::sync::atomic::Ordering::Relaxed) +} + +/// A server's block container in our palette width. Ids past our registry (a +/// server or host with mods we lack) read as air: every per-state table is +/// indexed by them, and a palette's unused entries are scanned too. +pub fn known_blocks_only(blocks: PalettedContainer) -> PalettedContainer { + let ours = block_strategy(); + let unknown = |id: u32| !known_block_state(id) || u64::from(id) >> ours.global_bits != 0; + let any_unknown = if blocks.is_global() { + blocks.has(unknown) + } else { + blocks.maybe_has(unknown) + }; + if !any_unknown && blocks.strategy().global_bits == ours.global_bits { + return blocks; + } + let mut values = [0u32; 4096]; + blocks.unpack(&mut values); + for value in &mut values { + if unknown(*value) { + *value = 0; + } + } + PalettedContainer::from_values(ours, &values) +} + /// Frees `columns` (`count` of them) on a short-lived thread of its own /// when there are many: a teleport or a dimension change drops a few /// thousand columns at once, and freeing their sections took the frame @@ -250,6 +281,10 @@ pub fn free_columns(columns: T, count: usize) { pub fn set_block_states(states: usize) { let bits = horizon_core::paletted::ceil_log2(states).max(16); BLOCK_BITS.store(bits, std::sync::atomic::Ordering::Relaxed); + BLOCK_STATES.store( + u32::try_from(states).unwrap_or(u32::MAX), + std::sync::atomic::Ordering::Relaxed, + ); forget_shared(); } diff --git a/crates/horizon-client-render/src/hud.rs b/crates/horizon-client-render/src/hud.rs index cc0c7f04d2..59fc459772 100644 --- a/crates/horizon-client-render/src/hud.rs +++ b/crates/horizon-client-render/src/hud.rs @@ -76,6 +76,7 @@ pub struct HudAtlas { pub sprites: HashMap, /// Resolved player-profile names and UUIDs for inline head components. pub object_skins: HashMap, + pub object_skins_revision: u64, /// Sprites with a 0 < alpha < 255 texel (`SpriteContents.transparency().hasTranslucent`), /// which puts their particles on vanilla's translucent layer. pub translucent: HashSet, @@ -786,21 +787,29 @@ impl HudAtlas { .or_else(|| self.sprites.get(MISSING_SPRITE)) .copied() } + pub fn clear_object_skins(&mut self) { + self.object_skins.clear(); + self.object_skins_revision = Self::next_revision(); + } /// Keep a ready profile skin when another source only has its default. pub fn set_object_skin(&mut self, key: String, skin: String) { use std::collections::hash_map::Entry; if self.object_skins.len() >= 1024 && !self.object_skins.contains_key(&key) { - self.object_skins.clear(); + self.clear_object_skins(); } match self.object_skins.entry(key) { Entry::Occupied(entry) if skin.starts_with("entity/player/") && self.sprites.contains_key(entry.get()) => { } Entry::Occupied(mut entry) => { - entry.insert(skin); + if entry.get() != &skin { + entry.insert(skin); + self.object_skins_revision = Self::next_revision(); + } } Entry::Vacant(entry) => { entry.insert(skin); + self.object_skins_revision = Self::next_revision(); } } } @@ -920,6 +929,7 @@ impl HudAtlas { height: 1, sprites: HashMap::new(), object_skins: HashMap::new(), + object_skins_revision: Self::next_revision(), translucent: HashSet::new(), flags: HashMap::new(), sprite_animations: Vec::new(), @@ -985,6 +995,7 @@ impl HudAtlas { translucent: HashSet::new(), flags: HashMap::new(), object_skins: HashMap::new(), + object_skins_revision: Self::next_revision(), sprite_animations: Vec::new(), glint: (vec![0; 4], 1, 1), glint_armor: (vec![0; 4], 1, 1), @@ -1436,6 +1447,7 @@ impl HudAtlas { height, sprites, object_skins: HashMap::new(), + object_skins_revision: Self::next_revision(), translucent, flags, sprite_animations, @@ -2890,6 +2902,28 @@ impl HudRenderer { mod tests { use super::*; + #[test] + fn object_skin_revision_tracks_changes_and_capacity_clear() { + let mut atlas = HudAtlas::boot(); + atlas.set_object_skin("name:player".into(), "skins/server".into()); + let revision = atlas.object_skins_revision; + atlas.set_object_skin("name:player".into(), "skins/server".into()); + assert_eq!(atlas.object_skins_revision, revision); + atlas.set_object_skin("name:player".into(), "skins/inline".into()); + assert_ne!(atlas.object_skins_revision, revision); + for i in 1..1024 { + atlas.set_object_skin(format!("name:{i}"), "skins/server".into()); + } + let revision = atlas.object_skins_revision; + atlas.set_object_skin("name:overflow".into(), "skins/server".into()); + assert_ne!(atlas.object_skins_revision, revision); + assert_eq!(atlas.object_skins.len(), 1); + let revision = atlas.object_skins_revision; + atlas.clear_object_skins(); + assert_ne!(atlas.object_skins_revision, revision); + assert!(atlas.object_skins.is_empty()); + } + /// The mod image slot is paid only by a game with a native mod loaded. #[test] fn the_mod_image_slot_exists_only_when_a_mod_is_loaded() { diff --git a/crates/horizon-client-render/src/options_ui.rs b/crates/horizon-client-render/src/options_ui.rs index 3ec11a32c5..b62160d1c6 100644 --- a/crates/horizon-client-render/src/options_ui.rs +++ b/crates/horizon-client-render/src/options_ui.rs @@ -1630,6 +1630,11 @@ pub struct OptionsUi { pub mod_browser: crate::mod_browser::ModBrowser, /// The project whose page is open (`Page::ModProject`). pub mod_project: String, + /// The Mods page's line about switches that wait for a restart. + pub mods_note: Option, + /// The content mods that switch with a world open: only assets and data, + /// in a world this game serves (docs/design/mod-hotload.md). + pub mods_live: std::collections::BTreeSet, /// Horizon's `packBrowser` extra: the pack screen's Add button. pub pack_browser: bool, /// Packs: the packs found and the selection the screen opens with. @@ -1720,6 +1725,8 @@ impl Default for OptionsUi { settings_search, mod_browser: Default::default(), mod_project: String::new(), + mods_note: None, + mods_live: Default::default(), pack_browser: false, pack_infos: Vec::new(), pack_selection: Vec::new(), @@ -2588,6 +2595,14 @@ impl OptionsUi { } /// A registry project's page: what it is, Install or Update, its switch /// once installed, its description and changelog. + /// A content mod's set feeds the block-state tables the open world's + /// chunks were decoded with: changing it mid-world remaps their ids + /// (Forge/Fabric freeze the registry before a world opens). One with only + /// assets and data reloads live, and a native mod toggles live through + /// `native_on`, so they stay available. + fn mod_frozen(&self, installed: &crate::mod_list::Installed) -> bool { + self.in_world && installed.native.is_none() && !self.mods_live.contains(&installed.id) + } fn mod_project_rows(&self, s: &Settings) -> Vec { let b = &self.mod_browser; let Some(l) = b.listing(&self.mod_project) else { @@ -2659,6 +2674,10 @@ impl OptionsUi { Text::literal(format!("{}: ", lang::get("horizon.mods.browse.enabled"))) .append(on_off(s.mods.is_on(i))); let toggle = button(0.0, 0.0, 150.0, label, Act::ToggleMod(i)); + let toggle = match self.mod_frozen(&s.mods.installed[i]) { + true => inactive(toggle), + false => toggle, + }; rows.push(Row::Buttons(install, Some(toggle))); rows.push(Row::Buttons(page, None)); } @@ -3069,6 +3088,9 @@ impl OptionsUi { Act::Push(Page::ModBrowser), ); let mut rows = vec![Row::Buttons(browse, None)]; + if let Some(note) = &self.mods_note { + rows.extend(wrap(note, 64).into_iter().map(Row::Note)); + } if s.mods.installed.is_empty() { rows.push(Row::Header("horizon.mods.empty")); return rows; @@ -3085,16 +3107,11 @@ impl OptionsUi { } else if installed.dev { label = label.append(tr("horizon.mods.dev")); } - // A content mod's set feeds the block-state tables the - // open world's chunks were decoded with: changing it - // mid-world remaps their ids (Forge/Fabric freeze the - // registry before a world opens). A native mod toggles - // live through `native_on`, so it stays available. - let content = installed.native.is_none(); - if self.in_world && content { + let frozen = self.mod_frozen(installed); + if frozen { label = label.append(tr("horizon.mods.in_world")); } - let dead = installed.dev || installed.id.is_empty() || self.in_world && content; + let dead = installed.dev || installed.id.is_empty() || frozen; // Only native mods with actual settings need a second button. if installed .native @@ -7806,9 +7823,10 @@ mod tests { /// A content mod's set builds the block-state tables an open world's /// chunks were decoded with, so its toggle freezes in-world; a native - /// mod gates live through `native_on` and stays available. + /// mod gates live through `native_on` and one of assets and data reloads + /// live, so they stay available. #[test] - fn in_a_world_only_native_mod_toggles_stay_active() { + fn in_a_world_only_live_mod_toggles_stay_active() { let mut s = Settings::default(); let installed = |id: &str, native: Option| crate::mod_list::Installed { @@ -7842,6 +7860,11 @@ mod tests { assert_eq!(toggles(&mut ui, &s), [(0, true), (1, true)]); ui.open_at(true, Page::Mods); assert_eq!(toggles(&mut ui, &s), [(0, false), (1, true)]); + // A content mod of assets and data only reloads live in a world this + // game serves (`mods_live`, filled by the game). + s.mods.installed.push(installed("data_only", None)); + ui.mods_live.insert("data_only".into()); + assert_eq!(toggles(&mut ui, &s), [(0, false), (1, true), (2, true)]); } /// An Extras or PvP & HUD label wider than half a row takes the whole row, so every label diff --git a/crates/horizon-client-render/src/pvp.rs b/crates/horizon-client-render/src/pvp.rs index 22856a3929..cb424df08a 100644 --- a/crates/horizon-client-render/src/pvp.rs +++ b/crates/horizon-client-render/src/pvp.rs @@ -751,6 +751,33 @@ impl ChatOptions { } } +/// Combat feel (`docs/pvp.md`): how fights look. Legacy draws them as 1.8.9 did; what the +/// game sends to a server never changes, so a vanilla server and its anticheat see 26.3. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Combat { + /// Legacy (1.8.9) instead of Modern. + pub legacy: bool, + /// Keep the attack indicator under Legacy (1.8.9 had none). + pub indicator: bool, + /// Under Legacy, silence the attack sounds 1.9 added (1.8.9 played none on a hit). + pub sounds: bool, +} +impl Combat { + /// Whether this client leaves out sound `name` a server sent: under Legacy with its + /// sounds, the `entity.player.attack.*` family (strong, weak, crit, sweep, knockback, + /// nodamage), which 1.9 brought with the attack cooldown. + pub fn mutes_sound(&self, name: &str) -> bool { + let name = name.strip_prefix("minecraft:").unwrap_or(name); + self.legacy && self.sounds && name.starts_with("entity.player.attack.") + } + /// Whether this client leaves out particles of `ty` a server sent: under Legacy, the + /// sweep and the damage hearts, which 1.9 brought (1.8.9's hits show crits only). + pub fn hides_particle(&self, ty: crate::particles::ParticleType) -> bool { + use crate::particles::ParticleType as T; + self.legacy && matches!(ty, T::SweepAttack | T::DamageIndicator) + } +} + #[derive(Clone, Debug, PartialEq)] pub struct Pvp { pub crosshair: Crosshair, @@ -763,6 +790,7 @@ pub struct Pvp { pub layout: crate::pvp_hud::Layout, /// The key that opens the HUD editor from the game (unbound at first). pub editor_key: String, + pub combat: Combat, } /// The HUD editor key's name for the controls (`Extras::set_key`). pub const HUD_EDITOR_KEY: &str = "horizon.hud_editor"; @@ -777,6 +805,7 @@ impl Default for Pvp { chat: ChatOptions::default(), layout: crate::pvp_hud::Layout::default(), editor_key: "key.keyboard.unknown".into(), + combat: Combat::default(), } } } @@ -877,6 +906,13 @@ impl Pvp { "chatCompact" => self.chat.compact = flag(value, self.chat.compact), "chatCopy" => self.chat.copy = flag(value, self.chat.copy), "chatSearch" => self.chat.search = flag(value, self.chat.search), + "combatFeel" => match value { + "legacy" => self.combat.legacy = true, + "modern" => self.combat.legacy = false, + _ => {} + }, + "combatIndicator" => self.combat.indicator = flag(value, self.combat.indicator), + "combatSounds" => self.combat.sounds = flag(value, self.combat.sounds), "viewTime" => { t.time = match value { "off" => None, @@ -1001,6 +1037,13 @@ impl Pvp { "chatTimestamps:{}\nchatCompact:{}\nchatCopy:{}\nchatSearch:{}\n", c.timestamps, c.compact, c.copy, c.search, ); + let c = &self.combat; + let feel = if c.legacy { "legacy" } else { "modern" }; + let _ = write!( + out, + "combatFeel:{feel}\ncombatIndicator:{}\ncombatSounds:{}\n", + c.indicator, c.sounds + ); } } @@ -1139,6 +1182,12 @@ opts! { "Clicking a message in the open chat copies its text, unless the click does something else."; ChatSearch, "chat_search", "Chat search", "Ctrl+F in the open chat: what you type shows only the messages holding it. Enter or Ctrl+F again stops."; + CombatFeel, "combat_feel", "Combat feel", + "Legacy draws fights as 1.8.9 did: the item in your hand, its swing, eating, the bow and a sword held up while you hold use, without the dip after each hit or the attack indicator. Only the look: what you send a server stays the same."; + CombatIndicator, "combat_indicator", "Attack indicator in Legacy", + "Keeps the attack indicator under Legacy, which 1.8.9 didn't have."; + CombatSounds, "combat_sounds", "1.8 hit sounds", + "Under Legacy, leaves out the attack sounds 1.9 added (strong, weak, sweep, critical...). Hurt sounds stay."; } /// The PvP & HUD page: headers (language keys) and options. pub fn rows() -> Vec> { @@ -1218,6 +1267,8 @@ pub fn rows() -> Vec> { rows.extend([ViewTime, ViewWeather].map(Ok)); rows.push(Err("horizon.pvp.header.chat")); rows.extend([ChatTimestamps, ChatCompact, ChatCopy, ChatSearch].map(Ok)); + rows.push(Err("horizon.pvp.header.combat")); + rows.extend([CombatFeel, CombatIndicator, CombatSounds].map(Ok)); rows } impl Opt { @@ -1241,6 +1292,7 @@ impl Opt { FireLower | ShieldSize | TotemSize | GlintColour | ClearWater | ClearLava => None, ViewTime | ViewWeather => None, ChatTimestamps | ChatCompact | ChatCopy | ChatSearch => None, + CombatFeel | CombatIndicator | CombatSounds => None, HudPingCorner => Some(HudPing), HudCoordsCorner => Some(HudCoords), HudServerCorner => Some(HudServer), @@ -1282,6 +1334,8 @@ impl Opt { Opt::ChatCompact => p.chat.compact, Opt::ChatCopy => p.chat.copy, Opt::ChatSearch => p.chat.search, + Opt::CombatIndicator => p.combat.indicator, + Opt::CombatSounds => p.combat.sounds, _ => return None, }) } @@ -1333,6 +1387,10 @@ impl Opt { Some(colour) => colour.label(), None => crate::lang::get_or("Off", "Off"), }, + Opt::CombatFeel => match p.combat.legacy { + true => crate::lang::get_or("horizon.pvp.combat_feel.legacy", "Legacy (1.8.9)"), + false => crate::lang::get_or("horizon.pvp.combat_feel.modern", "Modern"), + }, Opt::ViewTime => p.tweaks.time.map_or_else(world_label, ViewTime::label), Opt::ViewWeather => p .tweaks @@ -1432,6 +1490,9 @@ impl Opt { Opt::ChatCompact => p.chat.compact = !p.chat.compact, Opt::ChatCopy => p.chat.copy = !p.chat.copy, Opt::ChatSearch => p.chat.search = !p.chat.search, + Opt::CombatFeel => p.combat.legacy = !p.combat.legacy, + Opt::CombatIndicator => p.combat.indicator = !p.combat.indicator, + Opt::CombatSounds => p.combat.sounds = !p.combat.sounds, Opt::ViewWeather => { let all: Vec<_> = std::iter::once(None) .chain(ViewWeather::ALL.map(Some)) @@ -1734,4 +1795,32 @@ mod tests { assert_eq!(en.get(key), Some(*english), "{key}"); } } + + #[test] + fn legacy_leaves_out_what_1_9_added_to_a_hit() { + use crate::particles::ParticleType as T; + let modern = Combat::default(); + let legacy = Combat { + legacy: true, + ..Combat::default() + }; + let sounds = Combat { + sounds: true, + ..legacy + }; + for name in [ + "minecraft:entity.player.attack.strong", + "entity.player.attack.sweep", + "minecraft:entity.player.attack.nodamage", + ] { + assert!(sounds.mutes_sound(name), "{name}"); + assert!(!legacy.mutes_sound(name), "only with the sounds option"); + assert!(!modern.mutes_sound(name)); + } + // 1.8.9 had the hurt sound: it stays. + assert!(!sounds.mutes_sound("minecraft:entity.player.hurt")); + assert!(legacy.hides_particle(T::SweepAttack) && legacy.hides_particle(T::DamageIndicator)); + assert!(!legacy.hides_particle(T::Crit) && !legacy.hides_particle(T::EnchantedHit)); + assert!(!modern.hides_particle(T::SweepAttack)); + } } diff --git a/crates/horizon-client/assets/horizon/lang/en_us.json b/crates/horizon-client/assets/horizon/lang/en_us.json index 2e363f6a2d..3dbc13815d 100644 --- a/crates/horizon-client/assets/horizon/lang/en_us.json +++ b/crates/horizon-client/assets/horizon/lang/en_us.json @@ -115,8 +115,11 @@ "horizon.mods.browse.open_page": "Open Page", "horizon.mods.browse.enabled": "Mod", "horizon.mods.browse.changelog": "Changelog", - "horizon.mods.browse.done": "%s %s is installed and on. Its content loads when the game starts again.", + "horizon.mods.browse.done": "%s %s is installed and on.", "horizon.mods.browse.install_failed": "Couldn't install it: %s", + "horizon.mods.switch.worldgen": "changes world generation", + "horizon.mods.switch.registries": "adds blocks, items or other registry entries", + "horizon.mods.switch.restart": "Restart the game to apply %s: they add blocks, items or world generation.", "horizon.shader_packs.empty": "No shader packs: put them in the shaderpacks folder", "horizon.shader_packs.kind_horizon": " (Horizon)", "horizon.shader_options.open": "Shader Options...", @@ -815,6 +818,15 @@ "horizon.pvp.editor": "Edit HUD", "horizon.pvp.editor.tooltip": "Move the widgets on the game: drag them anywhere, resize them with the wheel, right click one to put it back in its corner.", "horizon.pvp.editor.hint": "Drag widgets · Wheel: size · Right click: back to its corner", + "horizon.pvp.combat_feel": "Combat feel", + "horizon.pvp.combat_feel.tooltip": "Legacy draws fights as 1.8.9 did: the item in your hand, its swing, eating, the bow and a sword held up while you hold use, without the dip after each hit or the attack indicator. Only the look: what you send a server stays the same.", + "horizon.pvp.combat_indicator": "Attack indicator in Legacy", + "horizon.pvp.combat_indicator.tooltip": "Keeps the attack indicator under Legacy, which 1.8.9 didn't have.", + "horizon.pvp.combat_feel.modern": "Modern", + "horizon.pvp.combat_feel.legacy": "Legacy (1.8.9)", + "horizon.pvp.header.combat": "Combat feel", + "horizon.pvp.combat_sounds": "1.8 hit sounds", + "horizon.pvp.combat_sounds.tooltip": "Under Legacy, leaves out the attack sounds 1.9 added (strong, weak, sweep, critical...). Hurt sounds stay.", "Bindings": "Bindings", "Play": "Play", "Device": "Device", diff --git a/crates/horizon-client/assets/horizon/lang/fr_fr.json b/crates/horizon-client/assets/horizon/lang/fr_fr.json index 48533192c7..1b7327a3b3 100644 --- a/crates/horizon-client/assets/horizon/lang/fr_fr.json +++ b/crates/horizon-client/assets/horizon/lang/fr_fr.json @@ -63,8 +63,11 @@ "horizon.mods.browse.open_page": "Ouvrir la page", "horizon.mods.browse.enabled": "Mod", "horizon.mods.browse.changelog": "Nouveautés", - "horizon.mods.browse.done": "%s %s est installé et activé. Son contenu se charge au prochain démarrage du jeu.", + "horizon.mods.browse.done": "%s %s est installé et activé.", "horizon.mods.browse.install_failed": "Installation impossible : %s", + "horizon.mods.switch.worldgen": "change la génération du monde", + "horizon.mods.switch.registries": "ajoute des blocs, des objets ou d'autres entrées de registre", + "horizon.mods.switch.restart": "Redémarre le jeu pour appliquer %s : ils ajoutent des blocs, des objets ou de la génération de monde.", "horizon.shader_packs.empty": "Aucun pack de shaders : placez-les dans le dossier shaderpacks", "horizon.shader_options.open": "Options des shaders...", "horizon.shader_options.profile": "Profil", diff --git a/crates/horizon-client/src/control.rs b/crates/horizon-client/src/control.rs index ec2c949d86..5b727357de 100644 --- a/crates/horizon-client/src/control.rs +++ b/crates/horizon-client/src/control.rs @@ -346,12 +346,33 @@ impl Drop for Server { } } +/// The longest request line a connection may send. The game reads and parses a request on its +/// own thread between two ticks, so one without bound could stall the server (a proxy's +/// `player-import` carries a whole player, a few hundred kilobytes at most). +const MAX_LINE: u64 = 16 << 20; + fn serve(stream: Stream, sender: &mpsc::Sender) { let Ok(mut writer) = stream.try_clone() else { return; }; - for line in BufReader::new(stream).lines() { - let Ok(line) = line else { return }; + let mut reader = BufReader::new(stream); + loop { + let mut bytes = Vec::new(); + match (&mut reader) + .take(MAX_LINE + 1) + .read_until(b'\n', &mut bytes) + { + Ok(0) | Err(_) => return, + Ok(_) => {} + } + if bytes.len() as u64 > MAX_LINE { + let _ = writeln!(writer, r#"{{"error":"the request is longer than 16 MiB"}}"#); + return; + } + let Ok(line) = String::from_utf8(bytes) else { + return; + }; + let line = line.trim_end_matches(['\n', '\r']).to_owned(); if line.trim().is_empty() { continue; } @@ -428,6 +449,35 @@ mod tests { assert!(!path.exists()); } + /// A request past the limit is answered with an error and never reaches the game. + #[test] + fn a_request_longer_than_the_limit_is_refused() { + let path = + std::env::temp_dir().join(format!("horizon-ctl-long-{}.json", std::process::id())); + let server = Server::start(&path).unwrap(); + let client = connect(&path).unwrap(); + let mut writing = client.try_clone().unwrap(); + // The server stops reading at the limit and closes: the rest may not be taken. + let writer = thread::spawn(move || { + let chunk = vec![b'x'; 1 << 20]; + for _ in 0..=(MAX_LINE >> 20) { + if writing.write_all(&chunk).is_err() { + return; + } + } + let _ = writing.write_all(b"\n"); + }); + let mut reply = String::new(); + BufReader::new(client).read_line(&mut reply).unwrap(); + assert!(reply.contains("longer than"), "{reply}"); + let _ = writer.join(); + assert!( + server.try_recv().is_none(), + "the game was handed the request" + ); + drop(server); + } + #[test] fn tcp_refuses_a_wrong_token() { let path = diff --git a/crates/horizon-client/src/first_person.rs b/crates/horizon-client/src/first_person.rs index 078dd52c19..1dd53e52de 100644 --- a/crates/horizon-client/src/first_person.rs +++ b/crates/horizon-client/src/first_person.rs @@ -168,6 +168,128 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) - m } +/// Combat feel Legacy's first-person item (`docs/pvp.md`): Minecraft 1.8.9's +/// `ItemRenderer.renderItemInFirstPerson` for a flat item, drawn through 26.3's +/// model. 1.8.9 posed the item (`transformFirstPersonItem`: the arm's place, +/// 45° about Y, the swing's turns, a 0.4 scale), then applied its own +/// `firstperson` display (`[0, -135, 25]`, `[0, 4, 2]`, `1.7`, rotated Y, X, +/// Z) and `RenderItem`'s 0.5 scale. 26.3 applies `firstperson_righthand` +/// (`[0, -90, 25]`, `[1.13, 3.2, 1.13]`, `0.68`) itself after this pose, so +/// the pose ends with its inverse ([`MODERN_FLAT_DISPLAY`]). `block` is a +/// sword held up while use is held: 1.8.9's `doBlockTransformations`, with +/// the swing's turns kept as 1.7.10 kept them (hitting while blocking swings +/// the sword). None for the poses 1.8.9 had no form of (crossbow, trident, +/// spear, brush), which stay modern. +pub fn legacy_item_pose( + left: bool, + inverse_height: f32, + attack: f32, + pose: ItemPose, + block: bool, +) -> Option { + use glam::{Mat4, Vec3}; + let deg = |d: f32| d.to_radians(); + let pi = std::f32::consts::PI; + let sin = |v: f32| v.sin(); + // transformFirstPersonItem(equipProgress, swingProgress). + let first_person = |swing: f32| { + let f = sin(swing * swing * pi); + let f1 = sin(swing.sqrt() * pi); + Mat4::from_translation(Vec3::new(0.56, -0.52 - inverse_height * 0.6, -0.72)) + * Mat4::from_rotation_y(deg(45.0)) + * Mat4::from_rotation_y(deg(f * -20.0)) + * Mat4::from_rotation_z(deg(f1 * -20.0)) + * Mat4::from_rotation_x(deg(f1 * -80.0)) + * Mat4::from_scale(Vec3::splat(0.4)) + }; + let m = match pose { + _ if block => { + // doBlockTransformations. + first_person(attack) + * Mat4::from_translation(Vec3::new(-0.5, 0.2, 0.0)) + * Mat4::from_rotation_y(deg(30.0)) + * Mat4::from_rotation_x(deg(-80.0)) + * Mat4::from_rotation_y(deg(60.0)) + } + ItemPose::Swing => { + // doItemUsedTransformations, then transformFirstPersonItem. + let root = attack.sqrt(); + Mat4::from_translation(Vec3::new( + -0.4 * sin(root * pi), + 0.2 * sin(root * pi * 2.0), + -0.2 * sin(attack * pi), + )) * first_person(attack) + } + ItemPose::Using => first_person(0.0), + ItemPose::Eat { + remaining, + duration, + partial, + } => { + // performDrinking. + let time = remaining - partial + 1.0; + let fraction = time / duration; + let bob = if fraction >= 0.8 { + 0.0 + } else { + ((time / 4.0 * pi).cos() * 0.1).abs() + }; + let j = 1.0 - horizon_core::jmath::pow(f64::from(fraction), 27.0) as f32; + Mat4::from_translation(Vec3::new(0.0, bob, 0.0)) + * Mat4::from_translation(Vec3::new(j * 0.6, j * -0.5, 0.0)) + * Mat4::from_rotation_y(deg(j * 90.0)) + * Mat4::from_rotation_x(deg(j * 10.0)) + * Mat4::from_rotation_z(deg(j * 30.0)) + * first_person(0.0) + } + ItemPose::Bow { ticks_used } => { + // doBowTransformations. + let power = (ticks_used / 20.0 * (ticks_used / 20.0) + ticks_used / 20.0 * 2.0) / 3.0; + let power = power.min(1.0); + let mut m = first_person(0.0) + * Mat4::from_rotation_z(deg(-18.0)) + * Mat4::from_rotation_y(deg(-12.0)) + * Mat4::from_rotation_x(deg(-8.0)) + * Mat4::from_translation(Vec3::new(-0.9, 0.2, 0.0)); + if power > 0.1 { + let shake = sin((ticks_used - 0.1) * 1.3) * (power - 0.1); + m *= Mat4::from_translation(Vec3::new(0.0, shake * 0.01, 0.0)); + } + m * Mat4::from_translation(Vec3::new(0.0, 0.0, power * 0.1)) + * Mat4::from_scale(Vec3::new(1.0, 1.0, 1.0 + power * 0.2)) + } + _ => return None, + }; + // 1.8.9's `firstperson` display and RenderItem's 0.5, then 26.3's display undone. + let legacy_display = Mat4::from_translation(Vec3::new(0.0, 4.0, 2.0) / 16.0) + * Mat4::from_rotation_y(deg(-135.0)) + * Mat4::from_rotation_z(deg(25.0)) + * Mat4::from_scale(Vec3::splat(1.7)) + * Mat4::from_scale(Vec3::splat(0.5)); + let right = m * legacy_display * modern_flat_display(false).inverse(); + // 1.8.9 had only a right main arm: the left arm mirrors it. + let mirror = Mat4::from_scale(Vec3::new(-1.0, 1.0, 1.0)); + Some(if left { mirror * right * mirror } else { right }) +} +/// 26.3's `firstperson_righthand` display of a flat item (`item/generated` and +/// `item/handheld`), on the `left` hand mirrored as `ItemTransform.apply` does, +/// without the model's centring translation. +pub fn modern_flat_display(left: bool) -> glam::Mat4 { + use glam::{Mat4, Vec3}; + let s = if left { -1.0 } else { 1.0 }; + Mat4::from_translation(Vec3::new(s * 1.13, 3.2, 1.13) / 16.0) + * Mat4::from_rotation_y((s * -90.0f32).to_radians()) + * Mat4::from_rotation_z((s * 25.0f32).to_radians()) + * Mat4::from_scale(Vec3::splat(0.68)) +} +/// Whether a model layer's matrix is 26.3's flat-item first-person display (a resource +/// pack's own display keeps the modern pose). +pub fn is_modern_flat_display(matrix: &glam::Mat4, left: bool) -> bool { + let expected = + modern_flat_display(left) * glam::Mat4::from_translation(glam::Vec3::splat(-0.5)); + matrix.abs_diff_eq(expected, 1e-4) +} + /// How `submitArmWithItem` holds a stack with a `minecraft:map_id`: in both /// hands when it is the main hand's and the off hand is empty /// (`renderTwoHandedMap`), else in its own hand (`renderOneHandedMap`). @@ -479,13 +601,24 @@ impl HandHeights { /// `getItemSwapScale(1.0F)`. Returns whether each hand's visible item /// is replaced at the end. pub fn tick(&mut self, hands_busy: bool, swap_scale: f32, changed: [bool; 2]) -> [bool; 2] { + self.tick_with(hands_busy, swap_scale, changed, false) + } + /// [`Self::tick`]; `legacy` (Combat feel Legacy) raises the main hand fully whatever the + /// attack strength, as 1.8.9's `ItemRenderer.updateEquippedItem` did (no dip after a hit). + pub fn tick_with( + &mut self, + hands_busy: bool, + swap_scale: f32, + changed: [bool; 2], + legacy: bool, + ) -> [bool; 2] { self.old_main = self.main; self.old_off = self.off; if hands_busy { self.main = (self.main - 0.4).clamp(0.0, 1.0); self.off = (self.off - 0.4).clamp(0.0, 1.0); } else { - let attack = swap_scale; + let attack = if legacy { 1.0 } else { swap_scale }; let main_target = if changed[0] { 0.0 } else { @@ -1099,6 +1232,67 @@ mod tests { assert_eq!(swing_duration(d, Some(1), Some(0)), 4); assert_eq!(swing_duration(d, None, Some(2)), 12); } + + #[test] + fn legacy_hand_puts_a_flat_item_where_1_8_9_drew_it() { + use glam::{Mat4, Vec3}; + // At rest, the item's centre (model (0.5, 0.5, 0.5)) ends where 1.8.9's chain puts it: + // translate(0.56, -0.52, -0.72) . rotY(45) . scale(0.4) . translate((0, 4, 2) / 16) + // . ... . scale(0.5) . translate(-0.5): the centre is (0, 0.25, 0.125) scaled by 0.4, + // turned 45 degrees about Y. + let pose = legacy_item_pose(false, 0.0, 0.0, ItemPose::Swing, false).unwrap(); + let layer = modern_flat_display(false) * Mat4::from_translation(Vec3::splat(-0.5)); + let centre = (pose * layer).transform_point3(Vec3::splat(0.5)); + let c45 = std::f32::consts::FRAC_1_SQRT_2; + let expected = Vec3::new(0.56 + 0.05 * c45, -0.52 + 0.1, -0.72 + 0.05 * c45); + assert!(centre.abs_diff_eq(expected, 1e-4), "{centre} vs {expected}"); + // The left arm mirrors it. + let left = legacy_item_pose(true, 0.0, 0.0, ItemPose::Swing, false).unwrap(); + let left_layer = modern_flat_display(true) * Mat4::from_translation(Vec3::splat(-0.5)); + let l = (left * left_layer).transform_point3(Vec3::splat(0.5)); + assert!(l.abs_diff_eq(Vec3::new(-expected.x, expected.y, expected.z), 1e-4)); + // Lowered by the equip height: 0.6 of it, straight down. + let low = legacy_item_pose(false, 0.5, 0.0, ItemPose::Swing, false).unwrap(); + let c = (low * layer).transform_point3(Vec3::splat(0.5)); + assert!(c.abs_diff_eq(expected - Vec3::new(0.0, 0.3, 0.0), 1e-4)); + // A blocking sword sits elsewhere, and poses 1.8.9 lacked stay modern. + let block = legacy_item_pose(false, 0.0, 0.0, ItemPose::Using, true).unwrap(); + assert!( + !(block * layer) + .transform_point3(Vec3::splat(0.5)) + .abs_diff_eq(expected, 0.01) + ); + let trident = ItemPose::Trident { ticks_used: 3.0 }; + assert!(legacy_item_pose(false, 0.0, 0.0, trident, false).is_none()); + } + + #[test] + fn legacy_hand_does_not_dip_after_a_hit() { + // Just after a hit the attack strength is 0: 26.3 lowers the main hand to 0, + // 1.8.9 keeps it up (it only lowers on an item change). + let mut modern = HandHeights { + main: 1.0, + old_main: 1.0, + off: 1.0, + old_off: 1.0, + }; + let mut legacy = modern; + modern.tick_with(false, 0.0, [false; 2], false); + legacy.tick_with(false, 0.0, [false; 2], true); + assert!((modern.main - 0.6).abs() < 1e-6, "falls 0.4 a tick"); + assert_eq!(legacy.main, 1.0); + // An item change still lowers it. + legacy.tick_with(false, 1.0, [true, false], true); + assert!((legacy.main - 0.6).abs() < 1e-6); + } + + #[test] + fn the_flat_display_check_knows_vanillas_matrix() { + let m = modern_flat_display(false) * glam::Mat4::from_translation(glam::Vec3::splat(-0.5)); + assert!(is_modern_flat_display(&m, false)); + assert!(!is_modern_flat_display(&m, true)); + assert!(!is_modern_flat_display(&glam::Mat4::IDENTITY, false)); + } } #[cfg(test)] diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 72b907e443..61bda7dd8f 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -8883,7 +8883,13 @@ impl Game<'_> { self.draw_spyglass(&mut batch); // Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth / // extractVehicleHealth and the experience bar, laid out by hud_layout. - let indicator = self.settings.attack_indicator; + // Combat feel Legacy hides the attack indicator unless the player keeps it. + let combat = self.settings.extras.pvp.combat; + let indicator = if combat.legacy && !combat.indicator { + 0 + } else { + self.settings.attack_indicator + }; let delay = horizon_client::attack_strength::delay(self.main_hand_attack_speed()); let strength = self.hand_strength.scale(0.0, self.main_hand_attack_speed()); // The 3D crosshair (F3) takes the 2D one's place. @@ -14527,6 +14533,21 @@ impl Game<'_> { .is_some_and(|p| p.auto_spin_attack_ticks > 0); let items = self.visible_hand_items(); let off_hand_empty = items[1].is_none(); + // Combat feel Legacy: a sword is held up while use is held, as 1.8.9's block looked. + // Only the picture changes: the clicks and what they send stay 26.3's. + let legacy_block = self.settings.extras.pvp.combat.legacy + && self.right + && self.screen == Screen::Playing + && items[0] + .as_ref() + .is_some_and(|s| s.item.ends_with("_sword")) + && match used_hand { + None => true, + Some(Hand::Off) => items[1] + .as_ref() + .is_some_and(|s| &*s.item == "minecraft:shield"), + Some(Hand::Main) => false, + }; for (i, (hand, item)) in Hand::BOTH.into_iter().zip(items).enumerate() { if !shown[i] { continue; @@ -14577,6 +14598,10 @@ impl Game<'_> { }; // The main hand on its arm, the off hand on the other one. let left = self.settings.main_hand_left != (hand == Hand::Off); + // A Legacy sword held up hides the off hand's shield. + if hand == Hand::Off && legacy_block { + continue; + } let Some(stack) = item else { // renderArmWithItem: only the main hand's empty arm shows, // on the main arm (the left one with the main hand set to @@ -14720,6 +14745,23 @@ impl Game<'_> { horizon_client::cit::Hand::Main }; let state = self.resolve_item_in_hand(&stack, context, Some(&owner), seed, cit_hand); + // Combat feel Legacy: 1.8.9's pose for a flat item drawn with vanilla's display. + if self.settings.extras.pvp.combat.legacy + && !state.layers.is_empty() + && state + .layers + .iter() + .all(|l| horizon_client::first_person::is_modern_flat_display(&l.matrix, left)) + && let Some(legacy) = horizon_client::first_person::legacy_item_pose( + left, + inverse_height, + attack, + pose, + hand == Hand::Main && legacy_block, + ) + { + m = to_world * legacy; + } runs.push((out.len() as u32, item_id)); let light = self.world.light( eye[0].floor() as i32, @@ -20427,7 +20469,9 @@ impl Game<'_> { &self.motion.health }; let health = base.with_packs(root, &overlay, reload)?; - if files.is_empty() { + // With no pack, `res` holds the tables, unless content mods switched + // live since it was loaded: their data is in the new root. + if files.is_empty() && !self.mods.data_switched { return Ok((None, health)); } let mut items = Items::load(root, self.pack.assets.registry, self.res.facts(), &overlay)?; diff --git a/crates/horizon-client/src/game/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index 2de6926a86..95a39ea35d 100644 --- a/crates/horizon-client/src/game/dedicated.rs +++ b/crates/horizon-client/src/game/dedicated.rs @@ -1132,11 +1132,12 @@ impl Game<'_> { log( "INFO", &format!( - "Transfers on (server.toml [transfer]): players expected for {} s ({} s at most, {} at once), frozen for {} ticks at most", + "Transfers on (server.toml [transfer]): players expected for {} s ({} s at most, {} at once), frozen for {} ticks and held for {} ticks at most", s.expect.as_secs(), s.max_expect.as_secs(), s.max_expected, - s.max_freeze_ticks + s.max_freeze_ticks, + s.max_hold_ticks ), ); host.hub.transfers = Some(transfers); @@ -1392,11 +1393,12 @@ impl Game<'_> { pub(super) fn update_dedicated_streaming(&mut self) { let rule = self.ui.rules.bool("spectators_generate_chunks"); let guest = self.lan.as_ref().and_then(|h| { + // A held player (arriving from another server) is sent the level too, so it + // holds the world loaded around it as a placed player does. h.hub - .guests - .iter() - .find(|g| h.hub.here(g) && super::game_mode::loads_chunks(rule, g.spectator())) - .map(|g| g.chunk()) + .loading_chunks(|g| super::game_mode::loads_chunks(rule, g.spectator())) + .first() + .copied() }); if self.standalone_server() { self.streaming.radius = if guest.is_some() { diff --git a/crates/horizon-client/src/game/dedicated/status.rs b/crates/horizon-client/src/game/dedicated/status.rs index 92ea6bb388..21ca113821 100644 --- a/crates/horizon-client/src/game/dedicated/status.rs +++ b/crates/horizon-client/src/game/dedicated/status.rs @@ -3,8 +3,9 @@ //! (including their positions) and the loaded chunks; `console ` runs a console command and answers with the lines it logged; //! `server-events ` answers the typed events after that number (events.rs); `management //! ` edits the lists as vanilla's management protocol does (management.rs). -//! `player-expect [seconds]`, `player-export [freeze]` and `player-release ...` are a -//! proxy's, for a player it moves between two servers (transfer.rs). +//! `player-expect [seconds]`, `player-drop `, `player-export [freeze]`, +//! `player-release ...`, `player-import ` and `player-confirm ...` are a proxy's, +//! for a player it moves between two servers (transfer.rs). use super::{ServerState, capture_log}; use crate::game::Game; use serde_json::{Value, json}; diff --git a/crates/horizon-client/src/game/dedicated/transfer.rs b/crates/horizon-client/src/game/dedicated/transfer.rs index aaec218f14..8cb9a78ae2 100644 --- a/crates/horizon-client/src/game/dedicated/transfer.rs +++ b/crates/horizon-client/src/game/dedicated/transfer.rs @@ -1,8 +1,10 @@ //! A proxy's requests on the control endpoint, for a player it moves between two servers: -//! `player-expect [seconds]` (the target is told who is coming), +//! `player-expect [seconds]` (the target is told who is coming; its login is held), +//! `player-drop ` (the target forgets it, and lets a held player go), //! `player-export [freeze]` (the source answers with the player's record) and -//! `player-release moved [announce] | abort` (the source lets the player -//! go). They run +//! `player-release moved [announce] | abort` (the source lets the player go), +//! `player-import ` and `player-confirm ` (the target takes the +//! record and lets the held player in). They run //! between two ticks, on the server thread, so the player is never caught half-way through a //! tick. The rules are `lan/arrivals.rs`'s. All of them are refused while `server.toml`'s //! `[transfer]` is off. @@ -14,8 +16,30 @@ use std::time::Instant; impl Game<'_> { /// A proxy's `player-*` request; the reply is one JSON line, `{"ok": true, ...}` or - /// `{"error": "..."}`. + /// `{"error": "..."}`. Each request writes one line to the log with what became of it + /// (`arrivals::request_log`): a hand-over that failed leaves a trace. pub(super) fn player_request(&mut self, verb: &str, args: &str) -> Value { + // The name is read before the request runs: a release or a drop takes the player away. + let name = args + .split_whitespace() + .next() + .and_then(arrivals::parse_uuid) + .and_then(|uuid| { + self.lan + .as_ref()? + .hub + .guests + .iter() + .find(|g| g.uuid == uuid) + .map(|g| g.name.clone()) + }); + let reply = self.run_player_request(verb, args); + let (level, line) = arrivals::request_log(verb, name.as_deref(), args, &reply); + super::log(level, &line); + reply + } + + fn run_player_request(&mut self, verb: &str, args: &str) -> Value { let Some(host) = self.lan.as_ref() else { return json!({ "error": "the server is not open to players" }); }; @@ -27,7 +51,13 @@ impl Game<'_> { Some(host) => arrivals::expect_request(&mut host.hub, args, Instant::now()), None => json!({ "error": "the server is not open to players" }), }, + "player-drop" => match self.lan.as_mut() { + Some(host) => arrivals::drop_request(&mut host.hub, args), + None => json!({ "error": "the server is not open to players" }), + }, "player-export" => self.player_export(args), + "player-import" => self.player_import(args), + "player-confirm" => self.player_confirm(args), "player-release" => match self.lan.as_mut() { Some(host) => arrivals::release_request(&mut host.hub, args), None => json!({ "error": "the server is not open to players" }), @@ -61,6 +91,168 @@ impl Game<'_> { reply } + /// `player-import `: applies a record to the held player (`lan/import.rs`), with + /// its advancements and statistics, which need the game's tree and counters, and the mods' + /// attachments. Unless the request says `"place": false`, it then places the player as + /// `player-confirm` does, its file written first. + fn player_import(&mut self, args: &str) -> Value { + let Some((id, request)) = args.trim().split_once(char::is_whitespace) else { + return json!({ "error": "player-import needs a unique id and a request" }); + }; + let Some(uuid) = arrivals::parse_uuid(id) else { + return json!({ "error": "player-import needs the player's unique id" }); + }; + let request: Value = match serde_json::from_str(request.trim()) { + Ok(v) => v, + Err(e) => return json!({ "error": format!("the request is not JSON: {e}") }), + }; + let Some(mut host) = self.lan.take() else { + return json!({ "error": "the server is not open to players" }); + }; + // A retry after the commit, whose reply was lost: the player is placed from this + // hand-over already, and the answer is the same. + let retried = request["record"]["handover"] + .as_str() + .and_then(arrivals::parse_handover) + .and_then(|id| host.hub.placed_arrival(uuid, id).map(|g| (id, g))); + if let Some((id, i)) = retried { + let reply = json!({ + "ok": true, + "handover": arrivals::handover_text(id), + "placed": true, + "announced": host.hub.guests[i].arrival_announce, + "recentered": host.hub.guests[i].arrival_recentered, + "retried": true, + }); + self.lan = Some(host); + return reply; + } + let result = horizon_client::lan::import::import_player(&mut host.hub, uuid, &request); + let reply = match result { + Ok(done) => { + self.take_arrival_records(&mut host, uuid, &done); + let handover = arrivals::handover_text(done.handover); + let placed = if done.place { + self.place_arrival(&mut host, uuid, done.handover) + } else { + Ok(false) + }; + match placed { + Ok(placed) => json!({ + "ok": true, + "handover": handover, + "parts": done.parts, + "placed": placed, + "announced": done.announce, + "recentered": done.recentered, + }), + Err(e) => json!({ "error": e }), + } + } + Err(e) => json!({ "error": e }), + }; + self.lan = Some(host); + reply + } + + /// What an import brought that the game keeps itself: the advancements (`PlayerAdvancements + /// .applyFrom` drops those this server lacks), the statistics, and the attachments of a whole + /// import, which saves copy from the game's own map (`guest_attachments_into`). + fn take_arrival_records( + &mut self, + host: &mut LanHost, + uuid: u128, + done: &horizon_client::lan::import::Imported, + ) { + let tree = self.advancement_tree(); + let Some(g) = host.hub.guests.iter_mut().find(|g| g.uuid == uuid) else { + return; + }; + if let Some(saved) = &done.advancements { + let mut progress = horizon_client::advancements::Progress::from_json(saved); + progress + .criteria + .retain(|id, _| tree.by_id.contains_key(id)); + g.advancements = progress; + g.advancements_loaded = true; + } + if done.attachments { + self.guest_attachments.insert(uuid, g.attachments.clone()); + } + if let Some(stats) = &done.stats { + let play = host.play.entry(g.conn).or_default(); + play.stats = crate::game::stats::Stats::from_json(stats); + play.stats.mark_all_dirty(); + } + } + + /// `player-confirm `: places a held player whose record of that hand-over was + /// imported without placing it. + fn player_confirm(&mut self, args: &str) -> Value { + let Some(mut host) = self.lan.take() else { + return json!({ "error": "the server is not open to players" }); + }; + let reply = match arrivals::confirm_args(&host.hub, args) { + // A retry after the commit, whose reply was lost: the same answer. + Ok((uuid, id)) if host.hub.placed_arrival(uuid, id).is_some() => { + let i = host.hub.placed_arrival(uuid, id).unwrap_or_default(); + json!({ + "ok": true, + "placed": true, + "announced": host.hub.guests[i].arrival_announce, + "retried": true, + }) + } + Ok((uuid, id)) => match self.place_arrival(&mut host, uuid, id) { + Ok(_) => { + let announced = host + .hub + .guests + .iter() + .find(|g| g.uuid == uuid) + .is_some_and(|g| g.arrival_announce); + json!({ "ok": true, "placed": true, "announced": announced }) + } + Err(e) => json!({ "error": e }), + }, + Err(e) => json!({ "error": e }), + }; + self.lan = Some(host); + reply + } + + /// The hand-over's commit point on the target: the arrival's player file, advancements and + /// statistics are written, synchronously, then it is placed. Only then does the reply say + /// `ok`, so a proxy that sends `player-release moved` to the source after it knows a copy of + /// the player is on disk here. When the file cannot be written the player stays held and + /// imported: the proxy can confirm again or drop it. + fn place_arrival(&mut self, host: &mut LanHost, uuid: u128, id: u128) -> Result { + let i = host.hub.imported_arrival(uuid, id)?; + let mut g = host.hub.guests.remove(i); + self.guest_attachments_into(&mut g); + // An autosave of this player still in flight must not land after this write. + let _ = self.finish_player_autosaves(true); + let written = horizon_client::lan::playerdata::save_now(&g, &self.world_root) + .and_then(|()| self.write_arrival_records(host, &g)); + let name = g.name.clone(); + host.hub.guests.insert(i, g); + written?; + let announced = host.hub.confirm_arrival(uuid, id)?; + self.arrival_placed(uuid, &name, announced); + Ok(true) + } + + /// What the game does once a held player is placed: the log's lines, and the mods' "logged + /// in" (`native_player_logged_in`), which a held login did not send, so a placed arrival + /// is heard of as any login and its leaving later is not the mods' first word of it. + fn arrival_placed(&mut self, uuid: u128, name: &str, announced: bool) { + super::log("INFO", &arrivals::placed_line(name)); + if announced { + super::log("INFO", &format!("System chat: {name} joined the game")); + } + self.native_player_logged_in(uuid, name); + } + fn export_from(&mut self, host: &mut LanHost, uuid: u128, freeze: bool) -> Value { let i = match arrivals::check_exportable(&host.hub, uuid) { Ok(i) => i, diff --git a/crates/horizon-client/src/game/hand.rs b/crates/horizon-client/src/game/hand.rs index d9a5835dee..dc27ace4c0 100644 --- a/crates/horizon-client/src/game/hand.rs +++ b/crates/horizon-client/src/game/hand.rs @@ -510,10 +510,11 @@ impl Game<'_> { changed[h] = !same; } let speed = self.main_hand_attack_speed(); - let replace = self.heights.tick( + let replace = self.heights.tick_with( self.hands_busy(), self.hand_strength.swap_scale(1.0, speed), changed, + self.settings.extras.pvp.combat.legacy, ); for h in 0..2 { if replace[h] { diff --git a/crates/horizon-client/src/game/integrated.rs b/crates/horizon-client/src/game/integrated.rs index d14ebc3014..34bae1e9cc 100644 --- a/crates/horizon-client/src/game/integrated.rs +++ b/crates/horizon-client/src/game/integrated.rs @@ -234,6 +234,11 @@ impl Integrated { Err(RecvTimeoutError::Disconnected) => None, } } + /// `execute`: run `f` on the server thread between two ticks, without + /// waiting for it. False once the server has stopped. + pub fn post(&self, f: impl FnOnce(&mut Game<'_>) + Send + 'static) -> bool { + self.tasks.send(Box::new(f)).is_ok() + } /// `IntegratedServer.halt(true)`: returns once the world is saved and let go. pub fn stop(mut self) -> Result<()> { self.halt() diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 56e6f90365..53cf640712 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1709,6 +1709,37 @@ impl Game<'_> { .write(&super::stats::player_json(&self.world_root, "stats", uuid)); } } + /// The advancements and statistics files of a held arrival, before it is placed + /// (`player-confirm`): each through a new file, synced and renamed into place + /// (`save::write_durable`), so the reply's `ok` means both are on the disk whole. + pub(super) fn write_arrival_records( + &self, + host: &LanHost, + guest: &horizon_client::lan::guest::Guest, + ) -> std::result::Result<(), String> { + if self.adv.tree.list.is_empty() { + return Ok(()); + } + let Some(uuid) = self.records_uuid(host, guest.uuid) else { + return Ok(()); + }; + let write = |dir: &str, json: &serde_json::Value| { + let path = super::stats::player_json(&self.world_root, dir, uuid); + let text = serde_json::to_string_pretty(json).map_err(|e| e.to_string())?; + horizon_client::save::write_durable(&path, text.as_bytes()) + .map_err(|e| format!("{} is not saved: {e}", path.display())) + }; + write( + "advancements", + &guest + .advancements + .to_json(&self.adv.tree, horizon_world::DATA_VERSION), + )?; + if let Some(play) = host.play.get(&guest.conn) { + write("stats", &play.stats.to_json())?; + } + Ok(()) + } /// `PlayerAdvancements.load` and `ServerStatsCounter.parseLocal` of a /// network player who joined; every statistic goes out on the first /// request (`PlayerList.placeNewPlayer`'s `markAllDirty`). @@ -2048,10 +2079,18 @@ impl Game<'_> { super::dedicated::java_double(z) ), ); - super::dedicated::log( - "INFO", - &format!("System chat: {} joined the game", g.name), - ); + // A held player is not announced: it is not in the world yet. + if g.held { + super::dedicated::log( + "INFO", + &horizon_client::lan::arrivals::held_line(&g.name), + ); + } else { + super::dedicated::log( + "INFO", + &format!("System chat: {} joined the game", g.name), + ); + } } else if self.player.is_some() && let Some(g) = host.hub.guest(conn) && g.placed @@ -2102,20 +2141,33 @@ impl Game<'_> { self.guest_attachments.remove(&guest.uuid); host.play.remove(&guest.conn); self.guest_combat.remove(&guest.conn); - if self.options.dedicated.is_some() && guest.placed { + // A held player was recorded as joined at its login: it leaves too. + if self.options.dedicated.is_some() && (guest.placed || guest.held) { super::dedicated::events::record( "leave", &guest.name, guest.uuid, serde_json::json!({ "reason": guest.left_reason }), ); - super::dedicated::log( - "INFO", - &format!("{} lost connection: {}", guest.name, guest.left_reason), - ); + if guest.held && !guest.placed { + // Never announced, so no "left the game" either. + super::dedicated::log( + "INFO", + &horizon_client::lan::arrivals::dropped_line( + &guest.name, + &guest.left_reason, + ), + ); + } else { + super::dedicated::log( + "INFO", + &format!("{} lost connection: {}", guest.name, guest.left_reason), + ); + } // A player moved to another server without `announce` was not - // announced as leaving, so the line is not logged either. - if !guest.quiet_leave { + // announced as leaving, so the line is not logged either; a held + // player was never announced at all. + if guest.placed && !guest.quiet_leave { super::dedicated::log( "INFO", &format!("System chat: {} left the game", guest.name), @@ -3106,12 +3158,12 @@ impl Game<'_> { // distance + 1) and those chunks' lit neighbours. let radius = host.hub.config.view_distance + 1; let rule = self.ui.rules.bool("spectators_generate_chunks"); + // The held players are sent the level, so their chunks are kept as the placed ones'. self.streaming.extra = host .hub - .guests - .iter() - .filter(|g| host.hub.here(g) && super::game_mode::loads_chunks(rule, g.spectator())) - .map(|g| (g.chunk(), radius)) + .loading_chunks(|g| super::game_mode::loads_chunks(rule, g.spectator())) + .into_iter() + .map(|chunk| (chunk, radius)) .chain( self.arrival_chunks(self.dim) .into_iter() diff --git a/crates/horizon-client/src/game/lan_particles.rs b/crates/horizon-client/src/game/lan_particles.rs index 14bfa65c5a..3c99da7cfd 100644 --- a/crates/horizon-client/src/game/lan_particles.rs +++ b/crates/horizon-client/src/game/lan_particles.rs @@ -540,6 +540,10 @@ impl Game<'_> { let Some(options) = self.received_particle(&p.particle, p.pos) else { return; }; + // Combat feel Legacy draws a hit as 1.8.9 did: no sweep, no damage hearts. + if self.settings.extras.pvp.combat.hides_particle(options.ty) { + return; + } let (d, s) = (p.dist.map(f64::from), p.max_speed.map(f64::from)); // ClientLevel.addParticle: the camera's 32 blocks and the Particles // option, unless the packet or the type overrides the limiter. diff --git a/crates/horizon-client/src/game/mod_browser.rs b/crates/horizon-client/src/game/mod_browser.rs index 8713c5e17e..2b2203ee1b 100644 --- a/crates/horizon-client/src/game/mod_browser.rs +++ b/crates/horizon-client/src/game/mod_browser.rs @@ -153,6 +153,15 @@ impl Game<'_> { if installed { self.settings.save(&Self::options_path()); self.start_mods(); + self.switch_mods(); + // A mod with blocks, items or worldgen waits for a restart: the + // project page says so instead of "installed and on". + if let (Some(note), Some((message, _))) = ( + &self.ui.options.mods_note, + &mut self.ui.options.mod_browser.message, + ) { + *message = format!("{message} {note}"); + } } } } diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 8b2d36c54a..f15cc9dbd1 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -3,6 +3,7 @@ //! (`native_hooks.rs`); WebAssembly code mods don't run any more. use super::Game; use horizon_client::{ + lang, mod_list::{self, Installed}, text::Text, }; @@ -16,6 +17,20 @@ pub(super) struct ModsState { stopped: Vec, /// Browse mods' worker (`mod_browser`). pub(super) jobs: super::mod_browser::Jobs, + /// The content mods the loaded resources were built with: a switch the + /// game can apply live moves it to the player's choice (`switch_mods`). + pub(super) applied: Option>, + /// A live switch's resource reload is running: when it applied, the server + /// reloads its data from the new root. Its start, for the log. + pub(super) live: Option, + /// The content mods installed, and those of them with only assets and + /// data (`mod_content::live_switch`): they switch with a world open. + content_ids: std::collections::BTreeSet, + live_ids: std::collections::BTreeSet, + /// The server's data comes from a root a live switch built, not the one + /// `res` was loaded from: the world tables are built from it even with no + /// data pack (`build_world_tables`). + pub(super) data_switched: bool, } /// Freeze native component IDs before reading content or opening a connection. @@ -310,6 +325,29 @@ fn native_mods_detail(built: &str) -> Option { (!list.is_empty()).then_some(list) } +/// Notes a content mod's id, and whether its files can switch with a world +/// open (`mod_content::live_switch`). +fn live_switch_of( + path: &std::path::Path, + package: &horizon_mods::Package, + content_ids: &mut std::collections::BTreeSet, + live_ids: &mut std::collections::BTreeSet, +) { + let id = &package.manifest.id; + content_ids.insert(id.clone()); + let content = horizon_mods::Content::read(path, &package.manifest); + match content + .as_ref() + .map(horizon_client::mod_content::live_switch) + { + Ok(Ok(())) if package.wasm.is_none() => { + live_ids.insert(id.clone()); + } + Ok(Err(why)) => log::debug!("Horizon: mod {id} switches on a restart: {why}"), + _ => {} + } +} + /// A row for each native mod the launcher installed in `dir` (`.hnmod`) that this game wasn't /// built with (`built`: its [`BUILD_INFO`], empty for a release or Nightly) and that has no row /// yet. The page said "No mods" before, and players copied the files into `horizon-mods` (#4770). @@ -362,6 +400,7 @@ impl Game<'_> { let mut paths = horizon_mods::find(&dir); paths.retain(|p| !self.mods.dev.contains(p)); paths.extend(self.mods.dev.iter().cloned()); + let (mut content_ids, mut live_ids) = Default::default(); self.settings.mods.installed = paths .iter() .map(|path| { @@ -381,6 +420,7 @@ impl Game<'_> { }; let m = &package.manifest; let problem = package.wasm.is_some().then(|| STOPPED.to_owned()); + live_switch_of(path, &package, &mut content_ids, &mut live_ids); Installed { id: m.id.clone(), title: format!("{} {}", m.name, m.version), @@ -391,6 +431,11 @@ impl Game<'_> { } }) .collect(); + (self.mods.content_ids, self.mods.live_ids) = (content_ids, live_ids); + self.ui.options.mods_live = self.mods_live_now(); + if self.mods.applied.is_none() { + self.mods.applied = Some(self.settings.mods.enabled.clone()); + } // A mod with a native version built in says so instead, and isn't news. self.list_native_mods(); self.sync_native_switches(); @@ -422,6 +467,107 @@ impl Game<'_> { .map(|row| row.title.clone()) .collect(); } + /// The content mods the Mods page lets the player switch with a world + /// open: in single player and on an open LAN world, those with only + /// assets and data; none on another server, which owns its data. + pub(super) fn mods_live_now(&self) -> std::collections::BTreeSet { + match self.integrated.is_some() || self.remote.is_none() { + true => self.mods.live_ids.clone(), + false => Default::default(), + } + } + /// The content mods the resources load with: the ones applied, which a + /// switch that needs a restart doesn't change. + pub(super) fn applied_mods(&self) -> std::collections::BTreeSet { + self.mods + .applied + .clone() + .unwrap_or_else(|| self.settings.mods.enabled.clone()) + } + /// The player turned content mods on or off (Options > Mods, or an + /// install from Browse Mods): those with only assets and data apply now + /// (docs/design/mod-hotload.md), through the resource reload and then the + /// server's data reload (`mods_reloaded`); those that add blocks, items or + /// worldgen wait for a restart, and the Mods page says so. + pub(super) fn switch_mods(&mut self) { + let Some(applied) = self.mods.applied.clone() else { + return; + }; + let wanted = self.settings.mods.enabled.clone(); + if applied == wanted { + return; + } + // Native mods' `mod.` lines follow the player's choice as they are + // (`sync_native_switches`); a content mod with blocks, items or + // worldgen keeps the state it loaded with until a restart. + let content = &self.mods.content_ids; + let mut next = wanted.clone(); + let mut waiting = Vec::new(); + for id in applied.symmetric_difference(&wanted) { + if !content.contains(id) || self.mods.live_ids.contains(id) { + continue; + } + log::info!("Horizon: mod {id} needs a restart to switch"); + waiting.push(id.clone()); + if applied.contains(id) { + next.insert(id.clone()); + } else { + next.remove(id); + } + } + self.ui.options.mods_note = (!waiting.is_empty()) + .then(|| lang::tr("horizon.mods.switch.restart", &[&waiting.join(", ")])); + let changed = applied + .symmetric_difference(&next) + .any(|id| content.contains(id)); + if !changed { + self.mods.applied = Some(next); + return; + } + log::info!( + "Horizon: content mods switched live: {:?} -> {:?}", + applied, + next + ); + self.mods.applied = Some(next); + self.mods.live = Some(std::time::Instant::now()); + self.reload_packs(); + } + /// A live switch's resources applied: the server reloads its data packs + /// from the new root, as `/reload` does (recipes, tags, loot tables, + /// advancements, functions), and sends them to its players. + pub(super) fn mods_reloaded(&mut self, applied: bool) { + let Some(started) = self.mods.live.take() else { + return; + }; + let resources_ms = started.elapsed().as_millis(); + if !applied { + return; + } + let root = self.options.resources.clone(); + let enabled = self.applied_mods(); + let reload = move |g: &mut Game<'_>| { + let at = std::time::Instant::now(); + g.options.resources = root; + g.settings.mods.enabled = enabled.clone(); + g.mods.applied = Some(enabled); + g.mods.data_switched = true; + if g.world_root.as_os_str().is_empty() { + return; + } + g.reload_datapacks(true); + log::info!( + "Horizon: content mods switched live: server data reloaded in {} ms", + at.elapsed().as_millis() + ); + }; + log::info!("Horizon: content mods switched live: resources reloaded in {resources_ms} ms"); + if let Some(server) = &self.integrated { + server.post(reload); + } else if self.remote.is_none() { + reload(self); + } + } /// Trait mods follow the player's switches at once /// (`mod_api::set_mod_on`): the next dispatch skips a mod turned off. /// Mixin code checks `native_on` itself. diff --git a/crates/horizon-client/src/game/names.rs b/crates/horizon-client/src/game/names.rs index 09490a6e88..7ff18c86ef 100644 --- a/crates/horizon-client/src/game/names.rs +++ b/crates/horizon-client/src/game/names.rs @@ -163,9 +163,10 @@ impl Game<'_> { /// The other players' models and names: the players a server sends. fn other_players(&self) -> Vec<(&Mob, String)> { let mut out = Vec::new(); + let indices = first_mob_indices(&self.mobs); for (id, name) in self.remote_player_names() { - if let Some(m) = self.mobs.iter().find(|m| m.id == id) { - out.push((m, name)); + if let Some(&index) = indices.get(&id) { + out.push((&self.mobs[index], name)); } } out @@ -223,6 +224,15 @@ impl Game<'_> { self.name_tags_with_hooks(camera, alpha, !mod_api::mods().is_empty()) } fn name_tags_with_hooks(&self, camera: glam::DVec3, alpha: f64, hooks: bool) -> Vec { + self.collect_name_tags(camera, alpha, hooks, None) + } + fn collect_name_tags( + &self, + camera: glam::DVec3, + alpha: f64, + hooks: bool, + frustum: Option<&horizon_client::gfx::Frustum>, + ) -> Vec { let target = self.mob_hit().map(|(i, _, _)| self.mobs[i].id); let viewer = self.player_team(); let mut tags = Vec::new(); @@ -272,9 +282,27 @@ impl Game<'_> { // The name tag distance (64) is measured to the entity. let distance_sqr = glam::DVec3::new(m.position.x, m.position.y, m.position.z).distance_squared(camera); - if distance_sqr >= 64.0 * 64.0 { + // Match the model's renderer-specific box, not the tag anchor: the + // name must survive when only the entity's feet are in view. + let (lo, hi) = super::entity_culling::cull_box(m, alpha); + let inflate = glam::DVec3::splat(0.5); + if !name_tag_in_view( + frustum, + distance_sqr, + (lo - camera - inflate).as_vec3(), + (hi - camera + inflate).as_vec3(), + ) { continue; } + let marks = if player || self.cosmetics_local() { + self.cosmetics_marks(m, Some(m.id) == own) + } else { + Marks::default() + }; + let at = match marks.top { + Some(top) => p + glam::DVec3::Y * cosmetic_lift(top, m.model_scale()), + None => p, + }; // A player's scores and team go by name, a mob's by UUID. let holder = if player { Some(name.clone()) @@ -389,17 +417,6 @@ impl Game<'_> { (m.position.y + f64::from(m.height())).floor() as i32, m.position.z.floor() as i32, ); - // Horizon's mark before a Horizon player's name and their name tag style (cosmetics). - let marks = if player || self.cosmetics_local() { - self.cosmetics_marks(m, Some(m.id) == own) - } else { - Marks::default() - }; - // Horizon: over a tall cosmetic. Without one, vanilla's height, untouched. - let at = match marks.top { - Some(top) => p + glam::DVec3::Y * cosmetic_lift(top, m.model_scale()), - None => p, - }; tags.push(NameTag { at, lines, @@ -427,7 +444,27 @@ impl Game<'_> { }; let distance_sqr = glam::DVec3::new(a.position.x, a.position.y, a.position.z).distance_squared(camera); - if distance_sqr >= 64.0 * 64.0 { + let position = a.render_position(alpha); + // Same dimensions and dragon-head growth as armor_stand_geometry. + let width = if a.marker { + 0.0 + } else if a.small { + 0.25 + } else { + 0.5 + }; + let dragon = a.equipment[5] + .as_ref() + .is_some_and(|i| &*i.item == "minecraft:dragon_head"); + let grow = if dragon { 1.0 } else { 0.5 }; + let half = glam::DVec3::new(width / 2.0 + grow, grow, width / 2.0 + grow); + let origin = super::entity_culling::at(position) - camera; + if !name_tag_in_view( + frustum, + distance_sqr, + (origin - half).as_vec3(), + (origin + glam::DVec3::new(half.x, height + grow, half.z)).as_vec3(), + ) { continue; } let team = self.mob_team(a.id); @@ -572,7 +609,12 @@ impl Game<'_> { let mut patches = Vec::new(); let mut layouts = std::mem::take(&mut self.name_tag_layouts); layouts.begin_frame(&self.gui.atlas); - for tag in self.name_tags(camera, alpha) { + for tag in self.collect_name_tags( + camera, + alpha, + !mod_api::mods().is_empty(), + self.view_frustum.as_ref(), + ) { let o = tag.at - camera; // `gbuffers_entities` draws name tag text (2.3); a sneaking player's faded tag, on its // background, blends. @@ -779,6 +821,25 @@ impl Game<'_> { } } +/// Use the renderer's camera-relative clip test before measuring or emitting text. +fn name_tag_in_view( + frustum: Option<&impl horizon_client::gfx::Cull>, + distance_sqr: f64, + min: glam::Vec3, + max: glam::Vec3, +) -> bool { + distance_sqr < 64.0 * 64.0 && frustum.is_none_or(|view| view.intersects(min, max)) +} + +fn first_mob_indices(mobs: &[Mob]) -> std::collections::HashMap { + let mut indices = std::collections::HashMap::with_capacity(mobs.len()); + for (index, mob) in mobs.iter().enumerate() { + // Match iter().find even if a transient snapshot contains duplicate IDs. + indices.entry(mob.id).or_insert(index); + } + indices +} + /// Collect entity IDs of all vehicles: entities that have passengers /// (mobs, players, text displays, armor stands, or interactions riding them). /// @@ -1028,6 +1089,73 @@ mod tests { assert_ne!(tag_bytes(&mut cache, &atlas, &text, 255, false), first); } + #[test] + fn name_tag_culling_keeps_intersecting_entity_boxes_within_distance() { + // A 90-degree camera looking down -Z, with near/far at 1/100. + struct View; + impl horizon_client::gfx::Cull for View { + fn intersects(&self, lo: glam::Vec3, hi: glam::Vec3) -> bool { + lo.z <= -1.0 + && hi.z >= -100.0 + && lo.x + lo.z <= 0.0 + && hi.x - lo.z >= 0.0 + && lo.y + lo.z <= 0.0 + && hi.y - lo.z >= 0.0 + } + fn contains(&self, lo: glam::Vec3, hi: glam::Vec3) -> bool { + self.intersects(lo, hi) + } + } + // The head/anchor at y=12 is outside the top plane, but the + // entity's feet are inside: vanilla still submits its name. + assert!(name_tag_in_view( + Some(&View), + 181.0, + glam::Vec3::new(-0.8, 8.5, -10.8), + glam::Vec3::new(0.8, 11.3, -9.2) + )); + for (offset, distance, expected) in [ + ([0.0, 0.0, -10.0], 100.0, true), + ([0.0, 0.0, 10.0], 100.0, false), + ([12.0, 0.0, -10.0], 244.0, false), + ([0.0, 12.0, -10.0], 244.0, false), + ([0.0, 0.0, -65.0], 4225.0, false), + ([0.0, 0.0, -64.0], 4096.0, false), + ([0.0, 0.0, -63.0], 3969.0, true), + ] { + assert_eq!( + name_tag_in_view( + Some(&View), + distance, + glam::Vec3::from_array(offset) - glam::Vec3::splat(0.5), + glam::Vec3::from_array(offset) + glam::Vec3::splat(0.5) + ), + expected + ); + } + } + + #[test] + fn name_tag_id_lookup_matches_scan_on_randomized_duplicate_lists() { + let mut seed = 4664_u32; + for len in [0, 1, 10, 150, 300] { + let mut mobs = Vec::new(); + for _ in 0..len { + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); + let mut mob = player_mob(); + mob.id = seed % 80; + mobs.push(mob); + } + let indices = first_mob_indices(&mobs); + for id in 0..100 { + assert_eq!( + indices.get(&id).copied(), + mobs.iter().position(|m| m.id == id) + ); + } + } + } + /// The player model `perspective::player_type` builds from the prepared /// resources, without them: its box only needs the id and the pointer. fn player_mob() -> Mob { diff --git a/crates/horizon-client/src/game/options.rs b/crates/horizon-client/src/game/options.rs index d7d22415ed..985ef18024 100644 --- a/crates/horizon-client/src/game/options.rs +++ b/crates/horizon-client/src/game/options.rs @@ -125,8 +125,10 @@ impl Game<'_> { self.options_from_title = from_title; self.options_to_game = false; let minecart_improvements = self.minecart_improvements(); + let mods_live = self.mods_live_now(); let ui = &mut self.ui.options; ui.open_at(!from_title && self.title.is_none(), page); + ui.mods_live = mods_live; ui.minecart_improvements = minecart_improvements; let root = self.options.resources.clone(); ui.languages = lang::available(&[&root]); @@ -346,6 +348,7 @@ impl Game<'_> { Event::Changed => { self.settings.save(&Self::options_path()); self.apply_settings(); + self.switch_mods(); self.sync_native_switches(); self.sync_pregen_option(); self.sync_agent_control(); diff --git a/crates/horizon-client/src/game/packs.rs b/crates/horizon-client/src/game/packs.rs index b231d31d1d..e6a3e7f370 100644 --- a/crates/horizon-client/src/game/packs.rs +++ b/crates/horizon-client/src/game/packs.rs @@ -279,7 +279,7 @@ impl Game<'_> { let (selected, server, mods, dev_mods) = ( self.settings.resource_packs.clone(), self.server_packs.active.clone(), - self.settings.mods.enabled.clone(), + self.applied_mods(), self.options.dev_mods.clone(), ); let (assets, res, context) = ( @@ -309,6 +309,7 @@ impl Game<'_> { staged.and_then(|staged| self.apply_staged_pack(staged).map_err(|e| e.to_string())); match kind { ReloadKind::Selection => { + self.mods_reloaded(result.is_ok()); if let Err(e) = result { self.selection_reload_failed(&e); } diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index ab35228212..f8230df783 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -643,6 +643,8 @@ pub(super) struct Remote { own_profile: OwnProfile, /// The skin texture each profile's players show, once its download is ready. skins: HashMap, + skins_revision: u64, + published_skins: Option<(usize, u128, u64, u64)>, listed: std::collections::HashSet, /// `ClientboundLoginPacket.onlineMode`: the player list shows heads. pub(super) online_mode: bool, @@ -1144,6 +1146,8 @@ impl Game<'_> { skin_textures: HashMap::new(), own_profile: OwnProfile::Idle, skins: HashMap::new(), + skins_revision: 0, + published_skins: None, listed: Default::default(), online_mode: false, level_players: HashMap::new(), @@ -1233,7 +1237,7 @@ impl Game<'_> { r.session.disconnect(); } super::lan::forget_sync_data(); - self.gui.atlas.object_skins.clear(); + self.gui.atlas.clear_object_skins(); self.server_packs_disconnect(); self.code_of_conduct = None; self.reset_tutorial(); @@ -2614,7 +2618,10 @@ impl Game<'_> { } } fn set_block(&mut self, pos: [i32; 3], state: i32) { - let state = BlockStateId(state.max(0) as u32); + // A state we don't have (a server with mods we lack) reads as air, as in its chunks. + let state = state.max(0) as u32; + let known = horizon_client::chunk::known_block_state(state); + let state = BlockStateId(if known { state } else { 0 }); let previous = self.world.block(pos[0], pos[1], pos[2]); if self.world.loaded(pos[0], pos[2]) && self.world.set(pos[0], pos[1], pos[2], state).is_ok() @@ -3425,7 +3432,9 @@ impl Game<'_> { C::LevelParticles(p) => self.remote_level_particles(&p), C::Explode(p) => self.remote_explode(&p), C::Sound(p) => { - if let Some(name) = self.remote_sound_name(&p.sound) { + if let Some(name) = self.remote_sound_name(&p.sound) + && !self.settings.extras.pvp.combat.mutes_sound(&name) + { let at = [ f64::from(p.x) / 8.0, f64::from(p.y) / 8.0, @@ -3446,6 +3455,7 @@ impl Game<'_> { } C::SoundEntity(p) => { if let Some(name) = self.remote_sound_name(&p.sound) + && !self.settings.extras.pvp.combat.mutes_sound(&name) && let Some(state) = self.remote_sound_entity_state(p.id) { let source = usize::try_from(p.source) @@ -5243,10 +5253,50 @@ impl Game<'_> { /// The player list's view of the connection: the listed players (with /// their downloaded skins once ready), online mode, and which players /// are in the level with an upside-down name. + pub(super) fn publish_tab_object_skins(&mut self) { + let Some(r) = self.remote.as_mut() else { + return; + }; + let fingerprint = ( + r.listed.len(), + r.listed.iter().fold(0, |xor, uuid| xor ^ uuid), + r.skins_revision, + self.gui.atlas.object_skins_revision, + ); + if r.published_skins == Some(fingerprint) { + return; + } + for uuid in &r.listed { + let Some(p) = r.tab_players.get(uuid) else { + continue; + }; + let skin = r.skins.get(uuid).map_or_else( + || horizon_client::tab_list::default_skin(*uuid), + String::as_str, + ); + for key in [ + format!("id:{uuid:032x}"), + format!("name:{}", p.name.to_ascii_lowercase()), + ] { + if object_skin_changed(&self.gui.atlas.object_skins, &key, skin) { + self.gui.atlas.set_object_skin(key, skin.to_owned()); + } + } + } + // Publication itself mutates the atlas; cache its final revision. + r.published_skins = Some(( + fingerprint.0, + fingerprint.1, + fingerprint.2, + self.gui.atlas.object_skins_revision, + )); + } + pub(super) fn remote_tab_players( &mut self, ) -> Option<(Vec, bool, Vec)> { self.update_remote_skins(); + self.publish_tab_object_skins(); let mut players: Vec<_> = { let r = self.remote.as_ref()?; r.listed @@ -5260,13 +5310,6 @@ impl Game<'_> { .as_ref() .and_then(|r| r.skins.get(&p.uuid).cloned()) .unwrap_or_else(|| horizon_client::tab_list::default_skin(p.uuid).to_owned()); - self.gui - .atlas - .set_object_skin(format!("id:{:032x}", p.uuid), p.skin.clone()); - self.gui.atlas.set_object_skin( - format!("name:{}", p.name.to_ascii_lowercase()), - p.skin.clone(), - ); } let r = self.remote.as_ref()?; let upside_down = players @@ -5476,14 +5519,13 @@ impl Game<'_> { (skin.to_owned(), skin.contains("/slim/")) } }; - let Some((level, skins, own)) = self.remote.as_mut().map(|r| { - r.skins = resolved.into_iter().collect(); + let skins: HashMap<_, _> = resolved.into_iter().collect(); + let Some((level, own)) = self.remote.as_ref().map(|r| { ( r.level_players .iter() .map(|(id, uuid)| (*id as u32, *uuid)) .collect::>(), - r.skins.clone(), r.profile, ) }) else { @@ -5491,12 +5533,16 @@ impl Game<'_> { }; // Only the skins drawn hold atlas slots: the level's players, not // every entry of a busy server's player list. + // Build once: resolving each player must not scan every level entity. + let players: HashMap<_, _> = self + .mobs + .iter() + .enumerate() + .filter(|(_, m)| &*m.kind.id == "minecraft:player") + .map(|(i, m)| (m.id, i)) + .collect(); for (id, uuid) in level { - let Some(i) = self - .mobs - .iter() - .position(|m| m.id == id && &*m.kind.id == "minecraft:player") - else { + let Some(&i) = players.get(&id) else { continue; }; let (skin, is_slim) = self.drawn_skin(uuid, skin_of(&skins, uuid)); @@ -5528,14 +5574,21 @@ impl Game<'_> { mob.cape = cape; } } + if let Some(r) = self.remote.as_mut() + && r.skins != skins + { + r.skins_revision = r.skins_revision.wrapping_add(1); + r.skins = skins; + } // ClientMannequin.updateSkin (PlayerSkinRenderCache.lookup): a // mannequin's DATA_PROFILE `textures` property resolves like a // player's, never required to be signed. - let mannequins: Vec<(u32, horizon_client::skins::Textures)> = self + let mannequins: Vec<(usize, horizon_client::skins::Textures)> = self .mobs .iter() - .filter(|m| &*m.kind.id == "minecraft:mannequin" && m.profile.is_some()) - .map(|m| { + .enumerate() + .filter(|(_, m)| &*m.kind.id == "minecraft:mannequin" && m.profile.is_some()) + .map(|(i, m)| { let profile = m.profile.as_ref(); let t = profile.and_then(|p| p.properties.iter().find(|p| p.name == "textures")); let textures = if let Some(t) = t { @@ -5573,10 +5626,10 @@ impl Game<'_> { }) .unwrap_or_default() }; - (m.id, textures) + (i, textures) }) .collect(); - let mut resolved: Vec<(u32, Option, bool)> = Vec::new(); + let mut resolved: Vec<(usize, Option, bool)> = Vec::new(); for (id, textures) in mannequins { let skin = { let mut downloads = self.skin_downloads.borrow_mut(); @@ -5587,16 +5640,12 @@ impl Game<'_> { let slim = skin.is_some() && textures.slim; resolved.push((id, skin, slim)); } - for (id, skin, slim) in resolved { + for (i, skin, slim) in resolved { let skin = skin.filter(|s| !s.starts_with("skins/") || self.again_skin(s)); - if let Some(mob) = self - .mobs - .iter_mut() - .find(|m| m.id == id && &*m.kind.id == "minecraft:mannequin") - { - mob.slim = slim && skin.is_some(); - mob.skin = skin; - } + // Resolution and atlas uploads do not insert or remove mobs. + let mob = &mut self.mobs[i]; + mob.slim = slim && skin.is_some(); + mob.skin = skin; } } /// Puts a downloaded skin in an atlas skin slot (`SkinSlots::place`), @@ -10896,6 +10945,10 @@ impl Remote { .or_else(|| self.players.get(uuid)) .map(String::as_str) } + /// Listed UUIDs always have a tab entry; removal clears both together. + pub(super) fn listed_player_count(&self) -> usize { + self.listed.len() + } /// Players in the server's player list, this one included. pub(super) fn player_count(&self) -> usize { self.players.len() @@ -10917,6 +10970,10 @@ impl Remote { } } +fn object_skin_changed(published: &HashMap, key: &str, skin: &str) -> bool { + published.get(key).is_none_or(|previous| previous != skin) +} + /// The textures to resolve skins from: every list entry's, and for a player /// entity whose entry is gone the profile it kept. `kept` first takes, for each /// player entity without one, its entry's textures (`AbstractClientPlayer.getPlayerInfo` @@ -11396,6 +11453,49 @@ mod tests { assert_eq!(super::skin_entries(&players, &listed, &mut kept).len(), 1); } + #[test] + fn tab_publication_fingerprint_tracks_all_inputs() { + let fingerprint = |ids: &[u128], skins, atlas| { + ( + ids.len(), + ids.iter().fold(0, |xor, uuid| xor ^ uuid), + skins, + atlas, + ) + }; + let published = fingerprint(&[7, 8], 1, 2); + assert_eq!(published, fingerprint(&[8, 7], 1, 2)); + assert_ne!(published, fingerprint(&[7, 9], 1, 2)); + assert_ne!(published, fingerprint(&[7, 8], 2, 2)); + assert_ne!(published, fingerprint(&[7, 8], 1, 3)); + } + + #[test] + fn tab_object_skin_publication_tracks_changes_and_atlas_clear() { + let mut published = std::collections::HashMap::new(); + let mut writes = 0; + for skin in ["skins/first", "skins/first", "skins/second"] { + for key in ["id:7", "name:player"] { + if super::object_skin_changed(&published, key, skin) { + published.insert(key.to_owned(), skin.to_owned()); + writes += 1; + } + } + } + assert_eq!(writes, 4, "unchanged skins do not publish either entry"); + published.clear(); + assert!(super::object_skin_changed( + &published, + "id:7", + "skins/second" + )); + assert!(super::object_skin_changed( + &published, + "name:player", + "skins/second" + )); + } + /// An NPC plugin's scale: a base the plugin sets, or a modifier on 1. #[test] fn attribute_values_add_then_multiply_as_vanilla() { diff --git a/crates/horizon-client/src/game/remote_chunks.rs b/crates/horizon-client/src/game/remote_chunks.rs index 49c02bc5a9..58f0f95663 100644 --- a/crates/horizon-client/src/game/remote_chunks.rs +++ b/crates/horizon-client/src/game/remote_chunks.rs @@ -4,7 +4,9 @@ //! order only inserts the result (`ClientPacketListener.handleLevelChunkWithLight`). use super::Game; use horizon_client::{ - chunk::{BIOME_STRATEGY, Chunk, ColumnLayout, Light, Section, block_strategy}, + chunk::{ + BIOME_STRATEGY, Chunk, ColumnLayout, Light, Section, block_strategy, known_blocks_only, + }, streaming::Streaming, }; use horizon_core::paletted::{PalettedContainer, Strategy}; @@ -85,20 +87,10 @@ impl ChunkFormat { }; let palette = s.states.palette.iter().map(|&p| p as u32).collect(); let words = s.states.data.iter().map(|&w| w as u64).collect(); - let mut blocks = PalettedContainer::from_wire(strategy, s.states.bits, palette, words) - .map_err(|e| e.to_string())?; - if strategy.global_bits != ours.global_bits { - let mut values = [0u32; 4096]; - blocks.unpack(&mut values); - // Ids past our registry (a host with mods we lack) read as air. - let limit = 1u64 << ours.global_bits; - for v in &mut values { - if u64::from(*v) >= limit { - *v = 0; - } - } - blocks = PalettedContainer::from_values(ours, &values); - } + let blocks = known_blocks_only( + PalettedContainer::from_wire(strategy, s.states.bits, palette, words) + .map_err(|e| e.to_string())?, + ); let mut section = Section { blocks, biomes: PalettedContainer::new(BIOME_STRATEGY, 0), diff --git a/crates/horizon-client/src/game/scoreboard.rs b/crates/horizon-client/src/game/scoreboard.rs index f1f1948731..0ec1678f23 100644 --- a/crates/horizon-client/src/game/scoreboard.rs +++ b/crates/horizon-client/src/game/scoreboard.rs @@ -812,6 +812,24 @@ impl Game<'_> { // slots: a new frame, with what the last one drew. self.flush_skin_slots(); let key = self.screen == super::Screen::Playing && self.keys.held(Action::Playerlist); + let count = self.remote.as_ref().map_or_else( + || { + 1 + self + .lan + .as_ref() + .map_or(0, |h| h.hub.guests.iter().filter(|g| g.placed).count()) + }, + |r| r.listed_player_count(), + ); + let has_objective = self.score.board.display_objective(sb::SLOT_LIST).is_some(); + let shown = tab_list::visible(key, self.has_singleplayer_server(), count, has_objective); + let changed = self.tab_list.set_visible(shown); + if !tab_players_needed(shown, changed) { + // World skins still need download polling and atlas residency checks. + self.update_remote_skins(); + self.publish_tab_object_skins(); + return; + } let (mut players, online_mode, upside_down) = match self.remote_tab_players() { Some(remote) => remote, None => { @@ -833,14 +851,7 @@ impl Game<'_> { (players, true, upside_down) } }; - let has_objective = self.score.board.display_objective(sb::SLOT_LIST).is_some(); - let shown = tab_list::visible( - key, - self.has_singleplayer_server(), - players.len(), - has_objective, - ); - if self.tab_list.set_visible(shown) { + if changed { // PlayerTabOverlay.setVisible: the names, said as the list comes up. self.say_system_now(&tab_list::narration(&players, &self.score.board)); } @@ -882,6 +893,25 @@ impl Game<'_> { } } +fn tab_players_needed(shown: bool, changed: bool) -> bool { + shown || changed +} + +#[test] +fn hidden_tab_skips_players_except_for_visibility_narration() { + use horizon_client::tab_list; + for count in [1, 150, 1000] { + let shown = tab_list::visible(false, false, count, false); + assert!(!tab_players_needed(shown, false)); + assert!( + tab_players_needed(shown, true), + "closing narration needs names" + ); + let shown = tab_list::visible(true, false, count, false); + assert!(tab_players_needed(shown, false)); + } +} + /// The host and guests that have entered the world, rebuilt from live state. pub(super) fn local_tab_players(me: TabPlayer, guests: &[Guest]) -> Vec { let mut players = vec![me]; diff --git a/crates/horizon-client/src/game/stats.rs b/crates/horizon-client/src/game/stats.rs index 73ecb223d6..f370433ab6 100644 --- a/crates/horizon-client/src/game/stats.rs +++ b/crates/horizon-client/src/game/stats.rs @@ -110,16 +110,18 @@ impl Stats { } /// `ServerStatsCounter.parseLocal`: the counters of a stats file. pub(super) fn read(path: &Path) -> Self { + let json = std::fs::read_to_string(path) + .ok() + .and_then(|t| serde_json::from_str::(&t).ok()) + .unwrap_or_default(); + Self::from_json(&json) + } + /// The counters of a stats file's JSON, or of a hand-over's record (`player-import`). + pub(super) fn from_json(json: &serde_json::Value) -> Self { let mut stats = Self { loaded: true, ..Self::default() }; - let Some(json) = std::fs::read_to_string(path) - .ok() - .and_then(|t| serde_json::from_str::(&t).ok()) - else { - return stats; - }; if let Some(all) = json.get("stats").and_then(|s| s.as_object()) { for (ty, entries) in all { let Some(entries) = entries.as_object() else { diff --git a/crates/horizon-client/src/lan/arrivals.rs b/crates/horizon-client/src/lan/arrivals.rs index 37ccabbdda..cf8024b829 100644 --- a/crates/horizon-client/src/lan/arrivals.rs +++ b/crates/horizon-client/src/lan/arrivals.rs @@ -1,14 +1,18 @@ //! Players that are about to arrive from another server, and a connected //! player read out of this one and sent away: the control endpoint's -//! `player-expect`, `player-export` and `player-release` -//! (`game/dedicated/transfer.rs` runs them). +//! `player-expect`, `player-drop`, `player-export`, `player-release` and +//! `player-confirm` (`game/dedicated/transfer.rs` runs them; `player-import` is +//! `import.rs`'s). //! //! A proxy that moves a player between two servers first tells the target -//! server who is coming (`player-expect `), and reads the player out of -//! the source (`player-export [freeze]`). Nothing here may last without -//! bound, because a proxy that dies in the middle of a move leaves its -//! expectations and its freezes behind: every expectation expires, the table -//! has a size cap, and a freeze is let go by itself. +//! server who is coming (`player-expect `); the player's next login is then +//! taken in as a *held* player: connected, receiving the world, but not +//! simulated, shown or announced until the hand-over finishes. It reads the +//! player out of the source (`player-export [freeze]`). Nothing here may +//! last without bound, because a proxy that dies in the middle of a move leaves +//! its expectations, its held players and its freezes behind: every expectation +//! expires, the table has a size cap, a held player is dropped and a freeze is +//! let go by themselves. //! //! All of it is off unless `server.toml`'s `[transfer]` turns it on //! (horizon-server passes it to the game as `--transfers`, [`Settings`]). While @@ -21,7 +25,7 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; /// `server.toml`'s `[transfer]`, as horizon-server passes it: `--transfers -/// expect_seconds=10,max_expect_seconds=60,max_expected=64,max_freeze_seconds=30`. +/// expect_seconds=10,max_expect_seconds=60,max_expected=64,max_freeze_seconds=30,max_hold_seconds=60`. /// A key left out keeps its default. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Settings { @@ -34,6 +38,9 @@ pub struct Settings { /// Server ticks a player may stay frozen for a hand-over: a proxy that dies /// in the middle of one must not leave the player stuck. pub max_freeze_ticks: u64, + /// Server ticks a held player may wait for its hand-over before the server + /// drops it. + pub max_hold_ticks: u64, } impl Default for Settings { @@ -43,6 +50,7 @@ impl Default for Settings { max_expect: Duration::from_secs(60), max_expected: 64, max_freeze_ticks: 20 * 30, + max_hold_ticks: 20 * 60, } } } @@ -69,6 +77,9 @@ impl Settings { "max_expected" => { s.max_expected = usize::try_from(n).map_err(|_| "max_expected is too large")? } + "max_hold_seconds" => { + s.max_hold_ticks = n.checked_mul(20).ok_or("max_hold_seconds is too large")? + } "max_freeze_seconds" => { s.max_freeze_ticks = n.checked_mul(20).ok_or("max_freeze_seconds is too large")? @@ -174,6 +185,11 @@ impl Expectations { } } + /// Forgets the expectation of `uuid`. Returns whether there was one. + pub fn forget(&mut self, uuid: u128) -> bool { + self.until.remove(&uuid).is_some() + } + /// Drops the expectations whose time has passed and returns them, so the /// caller can log that the player never came. pub fn expire(&mut self, now: Instant) -> Vec { @@ -240,6 +256,12 @@ fn new_handover_id() -> Result { Ok(u128::from_le_bytes(bytes)) } +/// Whether a held player that joined at tick `since` has waited too long at tick +/// `now` for its hand-over, for a wait of at most `max` ticks. +pub fn held_too_long(since: u64, now: u64, max: u64) -> bool { + now.saturating_sub(since) >= max +} + /// Whether a player frozen at tick `since` has been frozen too long at tick /// `now`, for a freeze of at most `max` ticks. pub fn frozen_too_long(since: u64, now: u64, max: u64) -> bool { @@ -247,13 +269,15 @@ pub fn frozen_too_long(since: u64, now: u64, max: u64) -> bool { } /// Why the connected player `uuid` cannot be exported now, or the index of its -/// guest. +/// guest. A held player is not exportable: it has no state of its own yet. pub fn check_exportable(hub: &Hub, uuid: u128) -> Result { let Some(i) = hub.guests.iter().position(|g| g.uuid == uuid) else { return Err("that player is not connected to this server"); }; let g = &hub.guests[i]; - if !g.placed { + if g.held { + Err("that player is held: it has not been handed over yet") + } else if !g.placed { Err("that player has not finished joining") } else if !g.left_reason.is_empty() { Err("that player is leaving") @@ -356,6 +380,116 @@ pub fn export_reply(hub: &Hub, i: usize, record: serde_json::Value) -> serde_jso reply } +/// The log line for a proxy's `player-*` request and what became of it, with its level. +/// `name` is the player's name when the server knows it. Only the player's id and a few +/// words of the outcome are written: never the record, which is large and is the player's. +pub fn request_log( + verb: &str, + name: Option<&str>, + args: &str, + reply: &serde_json::Value, +) -> (&'static str, String) { + let mut words = args.split_whitespace(); + let id = words.next().unwrap_or(""); + let who = match (parse_uuid(id), name) { + (Some(u), Some(name)) => format!("{name} ({})", horizon_protocol::crypto::uuid_string(u)), + (Some(u), None) => horizon_protocol::crypto::uuid_string(u), + (None, _) => format!("{id:?}"), + }; + if let Some(why) = reply.get("error").and_then(serde_json::Value::as_str) { + return ("WARN", format!("{verb} {who}: refused: {why}")); + } + let flag = |key: &str| reply.get(key).and_then(serde_json::Value::as_bool); + // The hand-over a request names (release, confirm, drop) or a reply gives (export, + // import): it ties the lines of one hand-over together on both servers. + let named = words.next(); + let handover = |given: Option<&serde_json::Value>| { + given + .and_then(serde_json::Value::as_str) + .or(named.filter(|w| parse_handover(w).is_some())) + .map_or(String::new(), |id| format!(", hand-over {id}")) + }; + let details = match verb { + "player-expect" => format!( + "expected for {} s{}", + reply["ttl_seconds"], + if flag("renewed") == Some(true) { + " (renewed)" + } else { + "" + } + ), + "player-export" => format!( + "record of {} bytes{}", + reply["record"]["player"] + .as_str() + .map_or(0, |p| p.len() / 4 * 3), + match reply["record"]["handover"].as_str() { + Some(id) => format!(", frozen, hand-over {id}"), + None => ", a read".to_owned(), + } + ), + "player-import" => format!( + "parts {}, placed {}, announced {}, recentered {}{}", + reply["parts"] + .as_array() + .map(|p| p + .iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(",")) + .unwrap_or_default(), + flag("placed").unwrap_or(false), + flag("announced").unwrap_or(false), + flag("recentered").unwrap_or(false), + handover(reply.get("handover")), + ), + "player-confirm" => format!( + "placed, announced {}{}", + flag("announced").unwrap_or(false), + handover(None) + ), + "player-release" => match reply.get("released").and_then(serde_json::Value::as_str) { + Some("moved") => format!( + "moved, announced {}{}", + flag("announced").unwrap_or(false), + handover(None) + ), + other => format!("{}{}", other.unwrap_or("released"), handover(None)), + }, + "player-drop" => format!( + "held {}, expected {}{}", + flag("held").unwrap_or(false), + flag("expected").unwrap_or(false), + handover(None) + ), + _ => "done".to_owned(), + }; + // A retry after the commit, whose reply was lost: nothing was done again. + let retried = if flag("retried") == Some(true) { + " (a retry: nothing done again)" + } else { + "" + }; + ("INFO", format!("{verb} {who}: ok, {details}{retried}")) +} + +/// The line written when a player is held: it logged in as the player a proxy expected. +pub fn held_line(name: &str) -> String { + format!("{name} is held: it arrives from another server") +} + +/// The line written when a held player is let into the world. +pub fn placed_line(name: &str) -> String { + format!("{name} placed: it has arrived from another server") +} + +/// The line written when a held player's connection ends before it was placed: it was +/// never announced, so it has no "left the game". +pub fn dropped_line(name: &str, reason: &str) -> String { + format!("{name} was dropped while held: {reason}") +} + /// A unique id as text, with or without dashes. pub fn parse_uuid(text: &str) -> Option { let plain: String = text.chars().filter(|c| *c != '-').collect(); @@ -409,6 +543,66 @@ pub fn expect_request(hub: &mut Hub, args: &str, now: Instant) -> serde_json::Va } } +/// `player-drop [hand-over]`: forgets the expectation of that player, and +/// disconnects it if it is already held, without a save or a word to anyone. With a +/// hand-over id, the held player must be the one whose record of that hand-over was +/// imported: a late drop of an older hand-over leaves a newer one alone. Before any +/// import there is no id to name, and the drop goes by the unique id alone. +pub fn drop_request(hub: &mut Hub, args: &str) -> serde_json::Value { + if hub.transfers.is_none() { + return error(OFF); + } + let mut parts = args.split_whitespace(); + let Some(uuid) = parts.next().and_then(parse_uuid) else { + return error("player-drop needs the player's unique id"); + }; + let id = match parts.next() { + None => None, + Some(text) => match parse_handover(text) { + Some(id) => Some(id), + None => return error("player-drop takes a unique id and at most a hand-over id"), + }, + }; + if parts.next().is_some() { + return error("player-drop takes a unique id and at most a hand-over id"); + } + if let Some(id) = id + && let Err(why) = hub.imported_arrival(uuid, id) + { + return error(why); + } + let held = hub.drop_held(uuid); + let expected = hub + .transfers + .as_ref() + .is_some_and(|t| t.table().forget(uuid)); + if !held && !expected { + return error("no player with that unique id is held or expected"); + } + serde_json::json!({ "ok": true, "held": held, "expected": expected }) +} + +/// `player-confirm `: the held player whose record of that hand-over was +/// imported, ready to be placed (`Hub::imported_arrival`). The game writes its file, then places +/// it (`Hub::confirm_arrival`). Until then `player-drop` removes it without a save or a word to +/// anyone. +pub fn confirm_args(hub: &Hub, args: &str) -> Result<(u128, u128), String> { + let mut parts = args.split_whitespace(); + let Some(uuid) = parts.next().and_then(parse_uuid) else { + return Err("player-confirm needs the player's unique id".into()); + }; + let Some(id) = parts.next().and_then(parse_handover) else { + return Err("player-confirm needs the hand-over id of the record".into()); + }; + if parts.next().is_some() { + return Err("player-confirm takes a unique id and a hand-over id".into()); + } + if hub.placed_arrival(uuid, id).is_none() { + hub.imported_arrival(uuid, id)?; + } + Ok((uuid, id)) +} + /// `player-release moved [announce]` or `player-release /// abort`: ends the hand-over the export with `freeze` started. `moved` takes the player out of /// the world, saving nothing and, unless `announce`, without the "left the game" line; `abort` @@ -504,6 +698,27 @@ mod tests { assert!(e.take(A, t0 + secs(20))); } + #[test] + fn a_held_player_waits_one_minute_of_ticks() { + let max = Settings::default().max_hold_ticks; + assert_eq!(max, 1200); + assert!(!held_too_long(100, 100 + 1199, max)); + assert!(held_too_long(100, 100 + 1200, max)); + // A clock that went backwards never drops anyone. + assert!(!held_too_long(500, 100, max)); + } + + #[test] + fn forgetting_removes_only_that_player() { + let t0 = Instant::now(); + let mut e = Expectations::default(); + e.expect(A, secs(10), t0).unwrap(); + e.expect(B, secs(10), t0).unwrap(); + assert!(e.forget(A)); + assert!(!e.forget(A)); + assert!(e.take(B, t0)); + } + #[test] fn a_long_wait_is_cut_to_the_maximum() { let t0 = Instant::now(); @@ -715,6 +930,15 @@ mod hub_tests { packets(rx).into_iter().map(|(id, _)| id).collect() } + /// What the network thread does as a login starts (`lan/conn.rs`): the login + /// uses up the player's expectation, if there is one. + fn login_starts(hub: &mut Hub, uuid: u128) { + let taken = table(hub).take(uuid, Instant::now()); + if let Some(g) = hub.guests.iter_mut().find(|g| g.uuid == uuid) { + g.expected = taken; + } + } + fn place(hub: &mut Hub, conn: u32) { let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0); let border = crate::worldborder::WorldBorder::default(); @@ -783,39 +1007,263 @@ mod hub_tests { assert!(table(&hub).is_empty()); } - /// A login takes the expectation from the table the network threads share - /// with the hub (`lan/conn.rs`, as the login starts), so nothing is reported - /// when its time would have run out; another player's stays. + /// The control: a player nobody expected is announced as always, to the + /// others by a chat line and a player-list entry. #[test] - fn a_login_uses_up_the_expectation_of_that_player_only() { + fn an_ordinary_login_is_announced_to_the_others() { let mut hub = hub(); - let now = Instant::now(); - table(&hub).expect(2, Duration::from_secs(10), now).unwrap(); - table(&hub).expect(3, Duration::from_secs(10), now).unwrap(); - let shared = hub.transfers.as_ref().unwrap().expected.clone(); - assert!(shared.lock().unwrap().take(2, now)); - let (mut newcomer, _mine) = guest(2, 2, "Newcomer"); - newcomer.expected = true; + let (mut watcher, seen) = guest(1, 1, "Watcher"); + watcher.placed = true; + hub.guests.push(watcher); + let (newcomer, mine) = guest(2, 2, "Newcomer"); hub.guests.push(newcomer); place(&mut hub, 2); - assert!(hub.guests[0].placed); - assert_eq!(table(&hub).len(), 1, "the other player is still expected"); - assert!(table(&hub).take(3, now)); + let told = ids(&seen); + assert!(told.contains(&id::SYSTEM_CHAT), "{told:?}"); + assert!(told.contains(&id::PLAYER_INFO_UPDATE), "{told:?}"); + assert!(hub.guests[1].placed && !hub.guests[1].held); + assert_eq!(ids(&mine)[0], id::LOGIN); } - /// While `[transfer]` is off every request is refused, there is no table, - /// and a tick does nothing for transfers. + /// A player the proxy announced is held: it gets the whole join sequence, + /// and the player already there is told nothing at all. #[test] - fn with_transfers_off_every_request_is_refused() { + fn an_expected_player_is_held_and_no_one_else_hears_of_it() { let mut hub = hub(); - hub.transfers = None; - let reply = super::expect_request(&mut hub, UUID, Instant::now()); - assert_eq!(reply["error"], super::OFF); + let (mut watcher, seen) = guest(1, 1, "Watcher"); + watcher.placed = true; + hub.guests.push(watcher); + let (newcomer, mine) = guest(2, 2, "Newcomer"); + hub.guests.push(newcomer); + table(&hub) + .expect(2, Duration::from_secs(10), Instant::now()) + .unwrap(); + login_starts(&mut hub, 2); + place(&mut hub, 2); + assert_eq!( + ids(&seen), + Vec::::new(), + "the watcher must hear nothing" + ); + let g = &hub.guests[1]; + assert!(g.held && !g.placed); + assert!(table(&hub).is_empty(), "the login used up the expectation"); + let sent = ids(&mine); + assert_eq!(sent[0], id::LOGIN); + for wanted in [id::PLAYER_INFO_UPDATE, id::SET_CHUNK_CACHE_CENTER] { + assert!( + sent.contains(&wanted), + "packet {wanted} missing from {sent:?}" + ); + } + // The held player is in its own list but did not announce itself. + assert!(!sent.contains(&id::SYSTEM_CHAT), "{sent:?}"); + } + + /// An expectation is for one player: another uuid logs in as usual. + #[test] + fn the_expectation_of_one_player_does_not_hold_another() { + let mut hub = hub(); + let (mut watcher, seen) = guest(1, 1, "Watcher"); + watcher.placed = true; + hub.guests.push(watcher); + let (newcomer, _mine) = guest(2, 2, "Newcomer"); + hub.guests.push(newcomer); + table(&hub) + .expect(3, Duration::from_secs(10), Instant::now()) + .unwrap(); + login_starts(&mut hub, 2); + place(&mut hub, 2); + assert!(hub.guests[1].placed && !hub.guests[1].held); + assert!(ids(&seen).contains(&id::SYSTEM_CHAT)); + assert_eq!(table(&hub).len(), 1, "the other player is still expected"); + } + + /// A held player whose hand-over never finishes is dropped after a minute of + /// ticks, and the players in the world are told nothing about it. + #[test] + fn a_held_player_that_is_never_imported_is_dropped_without_a_word() { + let mut hub = hub(); + let (mut watcher, seen) = guest(1, 1, "Watcher"); + watcher.placed = true; + hub.guests.push(watcher); + let (newcomer, mine) = guest(2, 2, "Newcomer"); + hub.guests.push(newcomer); + table(&hub) + .expect(2, Duration::from_secs(10), Instant::now()) + .unwrap(); + login_starts(&mut hub, 2); + place(&mut hub, 2); + let _ = (ids(&seen), ids(&mine)); + let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0); + let border = crate::worldborder::WorldBorder::default(); + let mut next = 100; + // Two ticks short of the limit (poll counts one): still waiting. + hub.ticks = hub.guests[1].held_since + super::Settings::default().max_hold_ticks - 2; + let mut ctx = tick_context(&mut world, &border, &mut next); + let events = hub.poll(&mut ctx); + assert!(!events.iter().any(|e| is_warn(e, "dropped"))); + // At the limit it is kicked, with a warning in the log. + let events = hub.poll(&mut ctx); + assert!( + events + .iter() + .any(|e| is_warn(e, "Newcomer") && is_warn(e, "dropped")) + ); + assert!(matches!(mine.try_recv(), Ok(Out::Disconnect(_)))); + // The connection closes: the hub removes the guest. + let gone = hub.remove_guest(1, "Timed out"); + assert!(gone.held && !gone.placed); + assert_eq!( + ids(&seen), + Vec::::new(), + "the watcher must hear nothing" + ); + } + + /// The control for the one above: when a placed player leaves, the others + /// are told. + #[test] + fn a_placed_player_leaving_is_announced() { + let mut hub = hub(); + let (mut watcher, seen) = guest(1, 1, "Watcher"); + watcher.placed = true; + hub.guests.push(watcher); + let (mut leaver, _mine) = guest(2, 2, "Leaver"); + leaver.placed = true; + hub.guests.push(leaver); + hub.remove_guest(1, "bye"); + let told = ids(&seen); + assert!(told.contains(&id::PLAYER_INFO_REMOVE), "{told:?}"); + assert!(told.contains(&id::SYSTEM_CHAT), "{told:?}"); + } + + /// A held player is sent the chunks and the entities around it, and takes + /// nothing from the players in the world: they still get their chunks, and + /// never learn the held player's entity. + #[test] + fn a_held_player_sees_the_world_without_the_world_seeing_it() { + let mut hub = hub(); + let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0); + for x in -5..=5 { + for z in -5..=5 { + // Only a lit column is sent. + let mut chunk = crate::chunk::Chunk::empty(x, z); + chunk.lit = true; + world.chunks.insert((x, z), std::sync::Arc::new(chunk)); + } + } + let border = crate::worldborder::WorldBorder::default(); + let (mut watcher, seen) = guest(1, 1, "Watcher"); + watcher.placed = true; + hub.guests.push(watcher); + let (newcomer, mine) = guest(2, 2, "Newcomer"); + hub.guests.push(newcomer); + // A second player in the world, to show that players are tracked at all. + let (mut other, _other_rx) = guest(3, 3, "Other"); + other.placed = true; + hub.guests.push(other); + table(&hub) + .expect(2, Duration::from_secs(10), Instant::now()) + .unwrap(); + login_starts(&mut hub, 2); + place(&mut hub, 2); + // The server has run a few ticks since they joined. + hub.ticks = 10; + let mut next = 100; + let mut ctx = tick_context(&mut world, &border, &mut next); + let (mut held_chunks, mut watcher_chunks) = (0, 0); + let (mut held_adds, mut watcher_adds) = (Vec::new(), Vec::new()); + for _ in 0..30 { + hub.tick_level(&mut ctx); + hub.end_tick(); + let mine_now = packets(&mine); + let seen_now = packets(&seen); + held_chunks += mine_now + .iter() + .filter(|p| p.0 == id::LEVEL_CHUNK_WITH_LIGHT) + .count(); + watcher_chunks += seen_now + .iter() + .filter(|p| p.0 == id::LEVEL_CHUNK_WITH_LIGHT) + .count(); + held_adds.extend(added_of(&mine_now)); + watcher_adds.extend(added_of(&seen_now)); + } + assert!( + held_chunks > 0, + "the held player is sent chunks (held {held_chunks}, watcher {watcher_chunks})" + ); + assert!(watcher_chunks > 0, "the placed player is still sent chunks"); + assert!( + watcher_adds.contains(&3), + "players are tracked at all: {watcher_adds:?}" + ); + assert!( + held_adds.contains(&1), + "the held player sees the watcher: {held_adds:?}" + ); + assert!( + !watcher_adds.contains(&2), + "the watcher must not see the held player: {watcher_adds:?}" + ); + } + + /// The entity ids in the Add Entity packets among some packets. + fn added_of(packets: &[(i32, Vec)]) -> Vec { + packets + .iter() + .filter(|(i, _)| *i == id::ADD_ENTITY) + .map(|(_, payload)| { + horizon_protocol::buf::Reader::new(payload) + .varint() + .unwrap() + }) + .collect() + } + + /// A held player's acknowledgements are handled (they keep the chunks + /// coming), and nothing else it sends reaches the world. + #[test] + fn a_held_player_is_heard_only_when_it_acknowledges() { + use horizon_protocol::packets::play::{ChunkBatchReceived, MovePlayerPos, Serverbound}; + let mut hub = hub(); + let (newcomer, _mine) = guest(2, 2, "Newcomer"); + hub.guests.push(newcomer); + table(&hub) + .expect(2, Duration::from_secs(10), Instant::now()) + .unwrap(); + login_starts(&mut hub, 2); + place(&mut hub, 2); + let g = &mut hub.guests[0]; + g.unacknowledged = 3; + let before = g.position; + g.inbox.push_back(( + Serverbound::MovePlayerPos(MovePlayerPos { + x: before[0] + 40.0, + y: before[1], + z: before[2], + flags: 1, + }), + 10, + )); + g.inbox.push_back(( + Serverbound::ChunkBatchReceived(ChunkBatchReceived { + chunks_per_tick: 12.0, + }), + 5, + )); + g.inbox_bytes = 15; let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0); let border = crate::worldborder::WorldBorder::default(); let mut next = 100; let mut ctx = tick_context(&mut world, &border, &mut next); - assert!(hub.poll(&mut ctx).is_empty()); + hub.drain_packets(&mut ctx); + let g = &hub.guests[0]; + assert_eq!(g.position, before, "a held player does not move"); + assert_eq!(g.unacknowledged, 2, "its acknowledgement was handled"); + assert_eq!(g.chunks_per_tick, 12.0); + assert_eq!(g.inbox_bytes, 0); } const UUID: &str = "123e4567-e89b-12d3-a456-426614174000"; @@ -914,6 +1362,69 @@ mod hub_tests { ); } + /// Dropping a held player disconnects it and says nothing to the world. + #[test] + fn player_drop_removes_a_held_player_without_a_word_to_anyone() { + let mut hub = hub(); + let (mut watcher, seen) = guest(1, 1, "Watcher"); + watcher.placed = true; + hub.guests.push(watcher); + // The proxy announces the player first; then the player logs in. + assert_eq!( + super::expect_request(&mut hub, UUID, Instant::now())["ok"], + true + ); + let (newcomer, mine) = guest(2, UUID_N, "Newcomer"); + hub.guests.push(newcomer); + login_starts(&mut hub, UUID_N); + place(&mut hub, 2); + let _ = (ids(&seen), ids(&mine)); + let reply = super::drop_request(&mut hub, UUID); + assert_eq!( + (reply["held"].as_bool(), reply["expected"].as_bool()), + (Some(true), Some(false)) + ); + assert!(matches!(mine.try_recv(), Ok(Out::Disconnect(_)))); + // The connection closes and the hub forgets the guest. + let gone = hub.remove_guest(1, "dropped"); + assert!(gone.held && !gone.placed); + assert_eq!( + ids(&seen), + Vec::::new(), + "the watcher must hear nothing" + ); + } + + #[test] + fn player_drop_forgets_an_expectation_and_refuses_a_stranger() { + let mut hub = hub(); + super::expect_request(&mut hub, UUID, Instant::now()); + let reply = super::drop_request(&mut hub, UUID); + assert_eq!( + (reply["held"].as_bool(), reply["expected"].as_bool()), + (Some(false), Some(true)) + ); + assert!(table(&hub).is_empty()); + let reply = super::drop_request(&mut hub, UUID); + assert!(reply["error"].is_string(), "{reply}"); + assert!(super::drop_request(&mut hub, "nonsense")["error"].is_string()); + } + + /// A placed player cannot be dropped this way: only a held one. + #[test] + fn player_drop_leaves_a_placed_player_alone() { + let mut hub = hub(); + let (mut here, rx) = guest(1, UUID_N, "Here"); + here.placed = true; + hub.guests.push(here); + let reply = super::drop_request(&mut hub, UUID); + assert!(reply["error"].is_string(), "{reply}"); + assert!( + rx.try_recv().is_err(), + "a placed player must not be disconnected" + ); + } + /// The keys a vanilla server writes in every player's file, read with `javap` from the /// 26.3 server jar: `Player.addAdditionalSaveData` (Inventory, SelectedItemSlot, /// EnderItems, XpLevel, XpP, XpTotal, XpSeed, abilities), `FoodData` (foodLevel, @@ -996,6 +1507,13 @@ mod hub_tests { hub.guests[0].sleeping = false; hub.guests[0].dead = true; assert_eq!(expect(&hub), Err("that player is dead")); + hub.guests[0].dead = false; + hub.guests[0].placed = false; + hub.guests[0].held = true; + assert_eq!( + expect(&hub), + Err("that player is held: it has not been handed over yet") + ); } #[test] @@ -1382,4 +1900,1361 @@ mod hub_tests { ); assert!(hub.guests[1].handover.is_none()); } + + // ---- the world is loaded for a held player; how a held player's connection ended -- + + // ---- the world loaded around a held player; how its connection ended ---- + + #[test] + fn the_world_is_loaded_around_the_players_placed_and_the_held_ones_only() { + let mut hub = hub(); + let overworld = |mut g: Guest, at: [f64; 3]| { + g.position = at; + g + }; + // Placed in the level in place. + let (mut placed, _a) = guest(1, 1, "Placed"); + placed.placed = true; + hub.guests.push(overworld(placed, [8.0, 64.0, 8.0])); + // Held: sent the level too, so the level must be loaded around it. + let (mut held, _b) = guest(2, 2, "Held"); + held.held = true; + hub.guests.push(overworld(held, [100.5, 64.0, -3.0])); + // Neither (still logging in): nothing to load for it. + let (idle, _c) = guest(3, 3, "Idle"); + hub.guests.push(overworld(idle, [500.0, 64.0, 500.0])); + // Placed in another level. + let (mut elsewhere, _d) = guest(4, 4, "Elsewhere"); + elsewhere.placed = true; + elsewhere.dimension = "minecraft:the_nether".into(); + hub.guests.push(overworld(elsewhere, [900.0, 64.0, 900.0])); + assert_eq!(hub.loading_chunks(|_| true), vec![(0, 0), (6, -1)]); + // The caller can leave a player out (a spectator that may not load chunks). + assert_eq!(hub.loading_chunks(|g| g.name != "Held"), vec![(0, 0)]); + } + + #[test] + fn a_dropped_held_player_keeps_how_its_connection_really_ended() { + let mut hub = hub(); + let (mut held, _rx) = guest(2, UUID_N, "Held"); + held.held = true; + hub.guests.push(held); + assert!(super::drop_request(&mut hub, UUID)["ok"] == true); + let gone = hub.remove_guest(0, "Invalid packet 0: unexpected end of data"); + assert_eq!( + gone.left_reason, + "dropped before its hand-over (the connection ended: Invalid packet 0: unexpected end of data)" + ); + } + + #[test] + fn a_held_player_whose_connection_ends_by_itself_logs_the_real_reason() { + let mut hub = hub(); + let (mut held, _rx) = guest(2, UUID_N, "Held"); + held.held = true; + hub.guests.push(held); + let gone = hub.remove_guest(0, "Invalid packet 0: unexpected end of data"); + assert_eq!(gone.left_reason, "Invalid packet 0: unexpected end of data"); + } + + /// The control: a placed player keeps the reason it was kicked with, as vanilla logs it. + #[test] + fn a_placed_player_keeps_the_reason_it_was_kicked_with() { + let mut hub = hub(); + let (mut placed, _rx) = guest(2, UUID_N, "Placed"); + placed.placed = true; + placed.left_reason = "Timed out".into(); + hub.guests.push(placed); + let gone = hub.remove_guest(0, "Disconnected"); + assert_eq!(gone.left_reason, "Timed out"); + } + + // ---- the held player's limits and its picture of the others ------------ + + /// A held player held under the hub's own settings. + fn held_with_a_watcher(settings: super::Settings) -> (Hub, Receiver, Receiver) { + let mut hub = hub(); + hub.transfers = Some(super::Transfers::new(settings)); + let (mut watcher, seen) = guest(1, 1, "Watcher"); + watcher.placed = true; + hub.guests.push(watcher); + table(&hub) + .expect(2, Duration::from_secs(10), Instant::now()) + .unwrap(); + let (newcomer, mine) = guest(2, 2, "Newcomer"); + hub.guests.push(newcomer); + login_starts(&mut hub, 2); + place(&mut hub, 2); + assert!(hub.guests[1].held); + let _ = (ids(&seen), ids(&mine)); + (hub, seen, mine) + } + + /// `[transfer]`'s `max_hold_seconds` sets the wait, and a held player whose + /// connection is slow to close is kicked and reported once, not on every tick. + #[test] + fn a_held_player_waits_the_servers_time_and_is_dropped_once() { + let settings = super::Settings { + max_hold_ticks: 40, + ..super::Settings::default() + }; + let (mut hub, _seen, _mine) = held_with_a_watcher(settings); + let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0); + let border = crate::worldborder::WorldBorder::default(); + let mut next = 100; + let mut ctx = tick_context(&mut world, &border, &mut next); + hub.ticks = hub.guests[1].held_since + 38; + assert!(!hub.poll(&mut ctx).iter().any(|e| is_warn(e, "dropped"))); + assert!(hub.poll(&mut ctx).iter().any(|e| is_warn(e, "dropped"))); + // The guest stays until its connection closes: no second kick. + assert_eq!(hub.guests.len(), 2); + for _ in 0..5 { + assert!(!hub.poll(&mut ctx).iter().any(|e| is_warn(e, "dropped"))); + } + } + + /// A player who joins while another is held is in the held one's list, or + /// the held client drops its entity; and one who leaves goes from that list. + #[test] + fn a_held_player_hears_of_the_players_who_join_and_leave() { + let (mut hub, seen, mine) = held_with_a_watcher(super::Settings::default()); + let (late, _late_rx) = guest(3, 3, "Late"); + hub.guests.push(late); + place(&mut hub, 3); + let told = ids(&mine); + assert!(told.contains(&id::PLAYER_INFO_UPDATE), "{told:?}"); + assert!( + !told.contains(&id::SYSTEM_CHAT), + "the held player is not in the chat yet: {told:?}" + ); + let _ = ids(&seen); + let i = hub.guests.iter().position(|g| g.uuid == 1).unwrap(); + hub.remove_guest(i, "Disconnected"); + let told = ids(&mine); + assert!(told.contains(&id::PLAYER_INFO_REMOVE), "{told:?}"); + assert!(told.contains(&id::REMOVE_ENTITIES), "{told:?}"); + } + + #[test] + fn player_drop_is_refused_with_transfers_off() { + let mut hub = hub(); + hub.transfers = None; + assert_eq!(super::drop_request(&mut hub, UUID)["error"], super::OFF); + } + + // ---- player-import ------------------------------------------------------ + + /// The record of the exported guest, and a second hub in which the same player is held + /// (with a placed watcher), ready to take it. + fn held_in_a_second_hub() -> (serde_json::Value, Hub, Receiver, Receiver) { + let (mut source, i) = exported_guest(); + let record = super::export_record(&mut source, i, true, Default::default()).unwrap(); + let mut hub = hub(); + let (mut watcher, seen) = guest(1, 1, "Watcher"); + watcher.placed = true; + hub.guests.push(watcher); + assert_eq!( + super::expect_request(&mut hub, UUID, Instant::now())["ok"], + true + ); + let (newcomer, mine) = guest(2, UUID_N, "Alex"); + hub.guests.push(newcomer); + login_starts(&mut hub, UUID_N); + place(&mut hub, 2); + let _ = (ids(&seen), ids(&mine)); + (record, hub, seen, mine) + } + + /// What the game does with a request (`game/dedicated/transfer.rs`), without the disk: the + /// import, then the placing unless `"place": false`. + fn import_player( + hub: &mut Hub, + uuid: u128, + request: &serde_json::Value, + ) -> Result { + let done = crate::lan::import::import_player(hub, uuid, request)?; + if done.place { + hub.confirm_arrival(uuid, done.handover)?; + } + Ok(done) + } + + /// `player-confirm` as the game runs it, without the disk. A request of the unique id alone + /// is given the held player's hand-over id, as a proxy would. + fn confirm(hub: &mut Hub, args: &str) -> serde_json::Value { + let mut args = args.to_owned(); + if let [one] = args.split_whitespace().collect::>().as_slice() + && let Some(id) = super::parse_uuid(one) + .and_then(|u| hub.guests.iter().find(|g| g.uuid == u)) + .and_then(|g| g.arrival_handover) + { + args = format!("{one} {}", super::handover_text(id)); + } + match super::confirm_args(hub, &args) { + Ok((uuid, id)) => match hub.confirm_arrival(uuid, id) { + Ok(announced) => { + serde_json::json!({ "ok": true, "placed": true, "announced": announced }) + } + Err(e) => serde_json::json!({ "error": e }), + }, + Err(e) => serde_json::json!({ "error": e }), + } + } + + #[test] + fn a_whole_record_makes_the_held_player_the_player_it_was_exported_from() { + let (record, mut hub, seen, mine) = held_in_a_second_hub(); + let (source, i) = exported_guest(); + let done = + import_player(&mut hub, UUID_N, &serde_json::json!({ "record": record })).unwrap(); + assert_eq!(done.parts.len(), crate::lan::import::PARTS.len()); + assert!(done.announce); + let g = &hub.guests[1]; + assert!(g.placed && !g.held && g.handover.is_none()); + // The whole player: the compound a save would write is the same one. + assert_eq!( + crate::lan::playerdata::player_tag(g), + crate::lan::playerdata::player_tag(&source.guests[i]) + ); + assert_eq!(g.position, [10.5, 70.0, -3.25]); + assert_eq!((g.xp_level, g.food, g.game_mode, g.selected), (7, 12, 1, 3)); + // The player already there is told it has come, as for a login. + let told = ids(&seen); + assert!(told.contains(&id::SYSTEM_CHAT), "{told:?}"); + assert!(told.contains(&id::PLAYER_INFO_UPDATE), "{told:?}"); + // And the player is told what the import changed. + let sent = ids(&mine); + for wanted in [ + id::GAME_EVENT, + id::PLAYER_ABILITIES, + id::SET_HELD_SLOT, + id::PLAYER_POSITION, + ] { + assert!( + sent.contains(&wanted), + "packet {wanted} missing from {sent:?}" + ); + } + } + + #[test] + fn a_part_that_is_not_asked_for_keeps_what_the_held_player_has() { + let (record, mut hub, _seen, _mine) = held_in_a_second_hub(); + let before = ( + hub.guests[1].position, + hub.guests[1].health, + hub.guests[1].xp_level, + hub.guests[1].game_mode, + ); + let request = + serde_json::json!({ "record": record, "parts": ["inventory", "ender_chest"] }); + let done = import_player(&mut hub, UUID_N, &request).unwrap(); + assert_eq!(done.parts, vec!["inventory", "ender_chest"]); + let g = &hub.guests[1]; + // Only the inventory came; the position, health, experience and mode are the held player's own. + assert_eq!((g.position, g.health, g.xp_level, g.game_mode), before); + assert!( + g.inventory[0].is_some(), + "the stone came with the inventory" + ); + assert_eq!(g.selected, 3); + assert!( + g.placed && !g.held, + "a partial import still lets the player in" + ); + } + + #[test] + fn the_parts_come_in_a_fixed_order_whatever_order_they_were_asked_in() { + let (record, mut hub, _seen, _mine) = held_in_a_second_hub(); + let request = serde_json::json!({ "record": record, "parts": ["position", "inventory", "inventory"] }); + let done = import_player(&mut hub, UUID_N, &request).unwrap(); + assert_eq!(done.parts, vec!["inventory", "position"]); + } + + #[test] + fn a_silent_import_tells_no_one() { + let (record, mut hub, seen, _mine) = held_in_a_second_hub(); + let request = serde_json::json!({ "record": record, "announce": false }); + let done = import_player(&mut hub, UUID_N, &request).unwrap(); + assert!(!done.announce); + assert!(hub.guests[1].placed); + // No join line, but the list entry: the watcher will be sent the player's entity, and a + // client adds a player's entity only with its list entry. + let told = ids(&seen); + assert!(!told.contains(&id::SYSTEM_CHAT), "{told:?}"); + assert!(told.contains(&id::PLAYER_INFO_UPDATE), "{told:?}"); + } + + /// Every refusal leaves the held player exactly as it was, and tells the world nothing. + #[test] + fn a_record_that_cannot_be_taken_changes_nothing() { + let (record, mut hub, seen, mine) = held_in_a_second_hub(); + let mut cases: Vec<(&str, serde_json::Value)> = Vec::new(); + let with = |key: &str, value: serde_json::Value| { + let mut r = record.clone(); + r[key] = value; + serde_json::json!({ "record": r }) + }; + cases.push(("version", with("version", serde_json::json!(2)))); + cases.push(("no version", with("version", serde_json::Value::Null))); + cases.push(("data version", with("data_version", serde_json::json!(1)))); + cases.push(( + "another player", + with( + "uuid", + serde_json::json!("00000000-0000-0000-0000-000000000001"), + ), + )); + cases.push(("not base64", with("player", serde_json::json!("%%%")))); + cases.push(("not NBT", with("player", serde_json::json!("AAAA")))); + cases.push(("no record", serde_json::json!({}))); + cases.push(( + "unknown part", + serde_json::json!({ "record": record, "parts": ["luck"] }), + )); + cases.push(( + "parts not a list", + serde_json::json!({ "record": record, "parts": "all" }), + )); + cases.push(( + "announce not a bool", + serde_json::json!({ "record": record, "announce": "yes" }), + )); + let before = crate::lan::playerdata::player_tag(&hub.guests[1]); + for (what, request) in &cases { + let error = import_player(&mut hub, UUID_N, request).expect_err(what); + assert!(!error.is_empty(), "{what}"); + let g = &hub.guests[1]; + assert!(g.held && !g.placed, "{what}: the player must still be held"); + assert_eq!( + crate::lan::playerdata::player_tag(g), + before, + "{what}: nothing may change" + ); + } + assert_eq!(ids(&seen), Vec::::new()); + assert_eq!(ids(&mine), Vec::::new()); + } + + #[test] + fn a_position_in_another_dimension_is_refused_but_the_other_parts_can_come() { + let (record, mut hub, _seen, _mine) = held_in_a_second_hub(); + // Make the held player's world the Nether: the record is in the Overworld. + hub.guests[1].dimension = "minecraft:the_nether".into(); + let all = serde_json::json!({ "record": record }); + let error = import_player(&mut hub, UUID_N, &all).unwrap_err(); + assert!(error.contains("position cannot be taken"), "{error}"); + assert!(hub.guests[1].held); + let without = serde_json::json!({ "record": record, "parts": ["inventory", "experience"] }); + import_player(&mut hub, UUID_N, &without).unwrap(); + assert_eq!( + hub.guests[1].dimension, "minecraft:the_nether", + "the client's world did not change" + ); + assert_eq!(hub.guests[1].xp_level, 7); + } + + #[test] + fn only_a_held_player_can_be_imported_into() { + let (record, mut hub, _seen, _mine) = held_in_a_second_hub(); + let request = serde_json::json!({ "record": record }); + // A placed player is not a target. + hub.guests[1].held = false; + hub.guests[1].placed = true; + let error = import_player(&mut hub, UUID_N, &request).unwrap_err(); + assert!( + error.contains("no player with that unique id is held"), + "{error}" + ); + // Nor a player nobody holds. + let mut empty = self::hub(); + let error = import_player(&mut empty, UUID_N, &request).unwrap_err(); + assert!( + error.contains("no player with that unique id is held"), + "{error}" + ); + } + + // ---- player-import without placing, player-confirm, and the cancel ------ + + fn import_without_placing( + request_extra: serde_json::Value, + ) -> (Hub, Receiver, Receiver) { + let (record, mut hub, seen, mine) = held_in_a_second_hub(); + let mut request = serde_json::json!({ "record": record, "place": false }); + for (k, v) in request_extra.as_object().unwrap() { + request[k] = v.clone(); + } + let done = import_player(&mut hub, UUID_N, &request).unwrap(); + assert!(!done.place); + (hub, seen, mine) + } + + /// The import changes the player and tells its client, but the world still does not know it. + #[test] + fn an_import_that_does_not_place_leaves_the_player_held_and_tells_only_its_client() { + let (hub, seen, mine) = import_without_placing(serde_json::json!({})); + let g = &hub.guests[1]; + assert!(g.held && !g.placed && g.imported && g.arrival_announce); + assert_eq!( + (g.xp_level, g.food, g.game_mode, g.position), + (7, 12, 1, [10.5, 70.0, -3.25]) + ); + assert_eq!( + ids(&seen), + Vec::::new(), + "the watcher must hear nothing" + ); + let sent = ids(&mine); + for wanted in [ + id::GAME_EVENT, + id::PLAYER_ABILITIES, + id::SET_HELD_SLOT, + id::PLAYER_POSITION, + ] { + assert!( + sent.contains(&wanted), + "packet {wanted} missing from {sent:?}" + ); + } + } + + #[test] + fn confirming_places_the_player_and_announces_it_as_the_import_asked() { + let (mut hub, seen, mine) = import_without_placing(serde_json::json!({})); + let _ = ids(&mine); + let reply = confirm(&mut hub, UUID); + assert_eq!( + reply, + serde_json::json!({"ok": true, "placed": true, "announced": true}) + ); + let g = &hub.guests[1]; + assert!(g.placed && !g.held && !g.imported); + let told = ids(&seen); + assert!( + told.contains(&id::SYSTEM_CHAT) && told.contains(&id::PLAYER_INFO_UPDATE), + "{told:?}" + ); + // What the client did since the import was not acted on: it is put back where the + // server has it, the position to confirm again and the inventory. + let sent = ids(&mine); + assert!(sent.contains(&id::PLAYER_POSITION), "{sent:?}"); + assert!(sent.contains(&id::CONTAINER_SET_CONTENT), "{sent:?}"); + } + + /// The arrival's join line names the player as the server's own join line does: by its + /// nickname and team (Player.getDisplayName), not only by its account name. + #[test] + fn the_join_line_of_an_arrival_shows_its_nickname() { + let (mut hub, seen, _mine) = import_without_placing(serde_json::json!({})); + hub.set_nickname(UUID_N, Some("Captain".into())); + let _ = packets(&seen); + let reply = confirm(&mut hub, UUID); + assert_eq!(reply["placed"], true); + let lines: Vec> = packets(&seen) + .into_iter() + .filter(|(id, _)| *id == id::SYSTEM_CHAT) + .map(|(_, body)| body) + .collect(); + assert_eq!(lines.len(), 1, "one join line"); + assert!( + lines[0].windows(b"Captain".len()).any(|w| w == b"Captain"), + "the join line names the nickname" + ); + } + + #[test] + fn a_silent_import_stays_silent_when_it_is_confirmed() { + let (mut hub, seen, _mine) = + import_without_placing(serde_json::json!({ "announce": false })); + let reply = confirm(&mut hub, UUID); + assert_eq!( + reply, + serde_json::json!({"ok": true, "placed": true, "announced": false}) + ); + assert!(hub.guests[1].placed); + let told = ids(&seen); + assert!(!told.contains(&id::SYSTEM_CHAT), "{told:?}"); + assert!(told.contains(&id::PLAYER_INFO_UPDATE), "{told:?}"); + } + + /// The cancel: a failure after the import, before anything reached the client, removes the + /// player from this server with no file and no word to anyone. + #[test] + fn dropping_an_imported_but_unconfirmed_player_cancels_the_arrival_without_a_word() { + let (mut hub, seen, mine) = import_without_placing(serde_json::json!({})); + let _ = ids(&mine); + let reply = super::drop_request(&mut hub, UUID); + assert_eq!( + (reply["held"].as_bool(), reply["expected"].as_bool()), + (Some(true), Some(false)) + ); + assert!(matches!(mine.try_recv(), Ok(Out::Disconnect(_)))); + let gone = hub.remove_guest(1, "dropped"); + assert!( + gone.held && !gone.placed && !gone.released, + "never placed: the game saves nothing" + ); + assert_eq!( + ids(&seen), + Vec::::new(), + "the watcher must hear nothing" + ); + // And it cannot be confirmed any more. + let reply = confirm(&mut hub, UUID); + assert!(reply["error"].is_string(), "{reply}"); + } + + #[test] + fn a_confirmation_is_refused_for_a_player_that_is_not_imported_and_changes_nothing() { + let (record, mut hub, seen, _mine) = held_in_a_second_hub(); + let _ = record; + for args in ["", "nonsense", &format!("{UUID} now")] { + assert!(confirm(&mut hub, args)["error"].is_string(), "{args:?}"); + } + // Held, but nothing was imported. + let reply = confirm(&mut hub, &format!("{UUID} {}", "0".repeat(32))); + assert!( + reply["error"] + .as_str() + .unwrap() + .contains("not been imported"), + "{reply}" + ); + assert!(hub.guests[1].held && !hub.guests[1].placed); + // Not held at all. + let reply = confirm( + &mut hub, + &format!("00000000000000000000000000000009 {}", "0".repeat(32)), + ); + assert!( + reply["error"].as_str().unwrap().contains("no player"), + "{reply}" + ); + assert_eq!(ids(&seen), Vec::::new()); + } + + #[test] + fn place_is_true_or_false() { + let (record, mut hub, _seen, _mine) = held_in_a_second_hub(); + let request = serde_json::json!({ "record": record, "place": "later" }); + assert!(import_player(&mut hub, UUID_N, &request).is_err()); + assert!(hub.guests[1].held && !hub.guests[1].imported); + } + + /// A proxy that dies between the import and the confirmation leaves nothing behind. + #[test] + fn an_imported_player_nobody_confirms_is_dropped_like_any_held_player() { + let (mut hub, seen, mine) = import_without_placing(serde_json::json!({})); + let _ = ids(&mine); + let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0); + let border = crate::worldborder::WorldBorder::default(); + let mut next = 100; + let mut ctx = tick_context(&mut world, &border, &mut next); + hub.ticks = hub.guests[1].held_since + super::Settings::default().max_hold_ticks - 1; + let events = hub.poll(&mut ctx); + assert!(events.iter().any(|e| is_warn(e, "dropped"))); + assert!(matches!(mine.try_recv(), Ok(Out::Disconnect(_)))); + assert_eq!(ids(&seen), Vec::::new()); + } + + // ---- an import replaces the inventory, it does not mix with the target's own ---- + + /// A player who has played on the target before has its own inventory in the file the + /// target loaded when it logged in. The imported inventory must REPLACE it: a slot the + /// record leaves empty is empty afterwards, not the target's old item. + #[test] + fn an_imported_inventory_replaces_the_one_the_target_loaded_for_the_player() { + use crate::items::ItemStack; + let stack = |name: &str, count: u32| ItemStack { + item: std::sync::Arc::from(name), + count, + damage: 0, + extra: None, + }; + let (record, mut hub, _seen, _mine) = held_in_a_second_hub(); + // What the target's file gave the held player: items in slots the record leaves empty, + // in the armour and the off hand, and an ender chest item. + { + let g = &mut hub.guests[1]; + g.inventory[5] = Some(stack("minecraft:dirt", 3)); + g.inventory[20] = Some(stack("minecraft:cobblestone", 9)); + g.armor[0] = Some(stack("minecraft:iron_boots", 1)); + g.offhand = Some(stack("minecraft:shield", 1)); + g.ender_items[0] = Some(stack("minecraft:diamond", 2)); + } + import_player( + &mut hub, + UUID_N, + &serde_json::json!({ "record": record, "place": false }), + ) + .unwrap(); + let g = &hub.guests[1]; + // The exported player had only the stone in slot 0. + assert_eq!( + g.inventory[0].as_ref().map(|s| (&*s.item, s.count)), + Some(("minecraft:stone", 5)) + ); + for slot in 1..36 { + assert!( + g.inventory[slot].is_none(), + "slot {slot} still holds the target's old item" + ); + } + assert!(g.armor.iter().all(Option::is_none), "the old armour stayed"); + assert!(g.offhand.is_none(), "the old off hand item stayed"); + assert!( + g.ender_items.iter().all(Option::is_none), + "the old ender chest stayed" + ); + // And what the client is told is that inventory: the window content has no dirt. + let items = (0..46).filter(|&s| g.menu_slot(s).is_some()).count(); + assert_eq!(items, 1, "the inventory menu holds the stone only"); + } + + /// Parts that are not imported keep the target's own, items included. + #[test] + fn an_inventory_that_is_not_imported_keeps_the_targets_items() { + use crate::items::ItemStack; + let (record, mut hub, _seen, _mine) = held_in_a_second_hub(); + hub.guests[1].inventory[5] = Some(ItemStack { + item: std::sync::Arc::from("minecraft:dirt"), + count: 3, + damage: 0, + extra: None, + }); + let request = + serde_json::json!({ "record": record, "parts": ["experience"], "place": false }); + import_player(&mut hub, UUID_N, &request).unwrap(); + assert!( + hub.guests[1].inventory[5].is_some(), + "the target's dirt must stay" + ); + assert!( + hub.guests[1].inventory[0].is_none(), + "the record's stone must not come" + ); + } + + // ---- the review's points on the import --------------------------------- + + /// The source's guest with a body (air, fire, freezing, food exhaustion and timer, a fall + /// and a motion), a key Horizon does not model and a vehicle, exported with `freeze`. + fn record_with_a_body() -> serde_json::Value { + use horizon_world::nbt::Tag; + let (mut source, i) = exported_guest(); + let g = &mut source.guests[i]; + g.loaded_body = Some(crate::lan::guest::SavedBody { + fire_ticks: 100, + air: 40, + fall_distance: 2.5, + velocity: [0.25, 0.0, -0.125], + food_exhaustion: 1.5, + food_tick_timer: 33, + ticks_frozen: 60, + }); + let mut extra = Tag::Compound(Default::default()); + if let Tag::Compound(map) = &mut extra { + map.insert("HorizonTest".into(), Tag::Int(7)); + } + g.saved = Some(extra); + super::export_record(&mut source, i, true, Default::default()).unwrap() + } + + fn held_for(record: &serde_json::Value) -> (Hub, Receiver, Receiver) { + let (_, hub, seen, mine) = held_in_a_second_hub(); + let _ = record; + (hub, seen, mine) + } + + /// A whole import carries the whole compound: the body's values and the keys Horizon does + /// not model, which the next save writes again. + #[test] + fn a_whole_import_keeps_the_body_and_the_keys_horizon_does_not_model() { + use crate::save::number; + let record = record_with_a_body(); + let (mut hub, _seen, _mine) = held_for(&record); + import_player(&mut hub, UUID_N, &serde_json::json!({ "record": record })).unwrap(); + let g = &hub.guests[1]; + let body = g.loaded_body.unwrap(); + assert_eq!( + (body.air, body.fire_ticks, body.ticks_frozen), + (40, 100, 60) + ); + assert_eq!((body.food_exhaustion, body.food_tick_timer), (1.5, 33)); + assert_eq!( + (body.fall_distance, body.velocity), + (2.5, [0.25, 0.0, -0.125]) + ); + let tag = crate::lan::playerdata::player_tag(g); + assert_eq!(number(tag.get("HorizonTest")), Some(7)); + assert_eq!(number(tag.get("Air")), Some(40)); + assert_eq!(number(tag.get("Fire")), Some(100)); + } + + /// The body's values follow their parts: the fall and motion the position, the air, fire, + /// freezing and food exhaustion and timer the health. A part left out keeps the held + /// player's own, and its saved vehicle. + #[test] + fn the_body_values_follow_their_parts() { + let record = record_with_a_body(); + let (mut hub, _seen, _mine) = held_for(&record); + let mine = crate::lan::guest::SavedBody { + fire_ticks: -20, + air: 300, + fall_distance: 0.0, + velocity: [0.0; 3], + food_exhaustion: 0.0, + food_tick_timer: 0, + ticks_frozen: 0, + }; + hub.guests[1].loaded_body = Some(mine); + hub.guests[1].saved_vehicle = Some([1, 2, 3, 4]); + let request = serde_json::json!({ "record": record, "parts": ["health"], "place": false }); + import_player(&mut hub, UUID_N, &request).unwrap(); + let body = hub.guests[1].loaded_body.unwrap(); + assert_eq!( + (body.air, body.fire_ticks, body.ticks_frozen), + (40, 100, 60) + ); + assert_eq!((body.food_exhaustion, body.food_tick_timer), (1.5, 33)); + assert_eq!( + (body.fall_distance, body.velocity), + (0.0, [0.0; 3]), + "not the position's" + ); + assert_eq!(hub.guests[1].saved_vehicle, Some([1, 2, 3, 4])); + } + + /// Values no player can have are refused before the held player is touched. + #[test] + fn a_record_with_impossible_values_is_refused() { + let cases: [(&str, fn(&mut crate::lan::guest::Guest)); 7] = [ + ("dead", |g| g.health = 0.0), + ("health not a number", |g| g.health = f32::NAN), + ("out of the world", |g| g.position = [4.0e7, 64.0, 0.0]), + ("height out of the world", |g| { + g.position = [0.0, 3.0e7, 0.0] + }), + ("rotation not a number", |g| g.yaw = f32::INFINITY), + ("health past max_health's 1024", |g| g.health = 2000.0), + ("food exhaustion past 40", |g| { + g.loaded_body = Some(crate::lan::guest::SavedBody { + fire_ticks: -20, + air: 300, + fall_distance: 0.0, + velocity: [0.0; 3], + food_exhaustion: 41.0, + food_tick_timer: 0, + ticks_frozen: 0, + }); + }), + ]; + for (what, spoil) in cases { + let (mut source, i) = exported_guest(); + spoil(&mut source.guests[i]); + let record = super::export_record(&mut source, i, true, Default::default()).unwrap(); + let (_, mut hub, seen, _mine) = held_in_a_second_hub(); + let before = crate::lan::playerdata::player_tag(&hub.guests[1]); + let error = import_player(&mut hub, UUID_N, &serde_json::json!({ "record": record })) + .expect_err(what); + assert!(error.starts_with("the record"), "{what}: {error}"); + assert_eq!( + crate::lan::playerdata::player_tag(&hub.guests[1]), + before, + "{what}" + ); + assert!(hub.guests[1].held && !hub.guests[1].imported, "{what}"); + assert_eq!(ids(&seen), Vec::::new(), "{what}"); + } + } + + /// The record must be a hand-over's, and the held player takes one hand-over's record. + #[test] + fn an_import_names_its_hand_over() { + let (source_record, mut hub, _seen, _mine) = held_in_a_second_hub(); + // A read (an export without freeze) is not a hand-over. + let (mut source, i) = exported_guest(); + let read = super::export_record(&mut source, i, false, Default::default()).unwrap(); + let error = + import_player(&mut hub, UUID_N, &serde_json::json!({ "record": read })).unwrap_err(); + assert!(error.contains("no hand-over id"), "{error}"); + let request = serde_json::json!({ "record": source_record, "place": false }); + let done = import_player(&mut hub, UUID_N, &request).unwrap(); + assert_eq!( + source_record["handover"].as_str(), + Some(super::handover_text(done.handover).as_str()) + ); + assert_eq!(hub.guests[1].arrival_handover, Some(done.handover)); + // The same record again is taken (a retry); another hand-over's is not. + import_player(&mut hub, UUID_N, &request).unwrap(); + let mut other = source_record.clone(); + other["handover"] = serde_json::json!("0".repeat(32)); + let error = import_player( + &mut hub, + UUID_N, + &serde_json::json!({ "record": other, "place": false }), + ) + .unwrap_err(); + assert!(error.contains("another hand-over"), "{error}"); + // A confirmation of another hand-over is refused, and changes nothing. + let wrong = confirm(&mut hub, &format!("{UUID} {}", "0".repeat(32))); + assert!( + wrong["error"] + .as_str() + .unwrap() + .contains("another hand-over"), + "{wrong}" + ); + assert!(hub.guests[1].held && hub.guests[1].imported); + } + + /// `player-drop` takes the hand-over id, which must be the imported one; without it the + /// drop goes by the unique id alone. + #[test] + fn a_drop_that_names_a_hand_over_must_name_the_imported_one() { + let (mut hub, _seen, mine) = import_without_placing(serde_json::json!({})); + let id = super::handover_text(hub.guests[1].arrival_handover.unwrap()); + let _ = ids(&mine); + let reply = super::drop_request(&mut hub, &format!("{UUID} {}", "0".repeat(32))); + assert!(reply["error"].is_string(), "{reply}"); + assert!(mine.try_recv().is_err(), "nothing was dropped"); + for bad in [format!("{UUID} soon"), format!("{UUID} {id} now")] { + assert!( + super::drop_request(&mut hub, &bad)["error"].is_string(), + "{bad}" + ); + } + let reply = super::drop_request(&mut hub, &format!("{UUID} {id}")); + assert_eq!(reply["held"], true, "{reply}"); + assert!(matches!(mine.try_recv(), Ok(Out::Disconnect(_)))); + // While its connection closes, neither a confirmation nor an import takes it. + let confirmed = confirm(&mut hub, &format!("{UUID} {id}")); + assert!(confirmed["error"].is_string(), "{confirmed}"); + assert!(!hub.guests[1].placed); + } + + /// An arrival kicked or dropped, whose connection has not closed yet, is not placed. + #[test] + fn a_leaving_arrival_cannot_be_imported_or_confirmed() { + let (record, mut hub, _seen, _mine) = held_in_a_second_hub(); + hub.guests[1].left_reason = "Timed out".into(); + let error = + import_player(&mut hub, UUID_N, &serde_json::json!({ "record": record })).unwrap_err(); + assert!(error.contains("leaving"), "{error}"); + let (mut hub, _seen, _mine) = import_without_placing(serde_json::json!({})); + let id = super::handover_text(hub.guests[1].arrival_handover.unwrap()); + hub.guests[1].left_reason = "Timed out".into(); + let reply = confirm(&mut hub, &format!("{UUID} {id}")); + assert!( + reply["error"].as_str().unwrap().contains("leaving"), + "{reply}" + ); + assert!(!hub.guests[1].placed); + } + + /// The login sent the target's recipe book; the import sends the imported one. + #[test] + fn an_import_sends_the_recipe_book_again() { + let (_hub, _seen, mine) = import_without_placing(serde_json::json!({})); + let sent = ids(&mine); + assert!(sent.contains(&id::RECIPE_BOOK_SETTINGS), "{sent:?}"); + } + + /// After the commit, a retry of the confirm (or of the import) whose reply was lost finds the + /// player placed from that same hand-over, and the game answers it the same; another + /// hand-over's id is still refused. + #[test] + fn a_retry_after_the_commit_finds_the_player_placed_from_that_hand_over() { + let (mut hub, _seen, _mine) = import_without_placing(serde_json::json!({})); + let id = hub.guests[1].arrival_handover.unwrap(); + let args = format!("{UUID} {}", super::handover_text(id)); + assert_eq!(confirm(&mut hub, &args)["placed"], true); + assert_eq!(hub.placed_arrival(UUID_N, id), Some(1)); + assert_eq!(super::confirm_args(&hub, &args), Ok((UUID_N, id))); + assert_eq!(hub.placed_arrival(UUID_N, id ^ 1), None); + let other = format!("{UUID} {}", super::handover_text(id ^ 1)); + assert!(super::confirm_args(&hub, &other).is_err()); + } + + // ---- the view follows the imported position ---------------------------- + + fn held_with_a_record_at( + at: [f64; 3], + center: Option<(i32, i32)>, + ) -> (serde_json::Value, Hub, Receiver) { + held_with_a_record_of(|g| g.position = at, center) + } + + fn held_with_a_record_of( + source_player: impl FnOnce(&mut Guest), + center: Option<(i32, i32)>, + ) -> (serde_json::Value, Hub, Receiver) { + let (mut source, i) = exported_guest(); + source_player(&mut source.guests[i]); + let record = super::export_record(&mut source, i, true, Default::default()).unwrap(); + let mut hub = hub(); + assert_eq!( + super::expect_request(&mut hub, UUID, Instant::now())["ok"], + true + ); + let (newcomer, mine) = guest(2, UUID_N, "Alex"); + hub.guests.push(newcomer); + login_starts(&mut hub, UUID_N); + place(&mut hub, 2); + hub.guests[0].center = center; + let _ = ids(&mine); + (record, hub, mine) + } + + #[test] + fn an_import_to_another_chunk_moves_the_view_at_once_and_says_so() { + // Chunk (0, 0) before, chunk (6, -1) after: x 100.5 is block 100, chunk 6; z -3.25 is chunk -1. + let (record, mut hub, mine) = held_with_a_record_at([100.5, 70.0, -3.25], Some((0, 0))); + let request = serde_json::json!({ "record": record, "place": false }); + let done = import_player(&mut hub, UUID_N, &request).unwrap(); + assert!(done.recentered); + assert_eq!( + hub.guests[0].center, + Some((6, -1)), + "the view is centred on the new chunk" + ); + let sent = packets(&mine); + let centre = sent + .iter() + .position(|(i, _)| *i == id::SET_CHUNK_CACHE_CENTER) + .expect("the new chunk centre is sent with the import"); + let position = sent + .iter() + .position(|(i, _)| *i == id::PLAYER_POSITION) + .unwrap(); + assert!( + centre < position, + "the centre goes before the position the client confirms" + ); + // The centre is the vanilla pair of VarInts: chunk x then chunk z (6, then -1 as an unsigned VarInt). + let mut r = horizon_protocol::buf::Reader::new(&sent[centre].1); + assert_eq!((r.varint().unwrap(), r.varint().unwrap()), (6, -1)); + } + + #[test] + fn an_import_inside_the_same_chunk_does_not_recentre() { + let (record, mut hub, mine) = held_with_a_record_at([3.5, 64.0, 3.5], Some((0, 0))); + let request = serde_json::json!({ "record": record, "place": false }); + let done = import_player(&mut hub, UUID_N, &request).unwrap(); + assert!(!done.recentered); + assert_eq!(hub.guests[0].center, Some((0, 0))); + assert!(!ids(&mine).contains(&id::SET_CHUNK_CACHE_CENTER)); + } + + #[test] + fn without_the_position_part_the_view_stays_where_it_is() { + let (record, mut hub, mine) = held_with_a_record_at([100.5, 70.0, -3.25], Some((0, 0))); + let request = + serde_json::json!({ "record": record, "place": false, "parts": ["inventory"] }); + let done = import_player(&mut hub, UUID_N, &request).unwrap(); + assert!(!done.recentered); + assert_eq!(hub.guests[0].center, Some((0, 0))); + assert!(!ids(&mine).contains(&id::SET_CHUNK_CACHE_CENTER)); + } + + /// A held player that has not been sent any chunk has no view to move: the first tick builds it + /// around the new place. + #[test] + fn a_view_not_yet_built_is_not_recentred() { + let (record, mut hub, _mine) = held_with_a_record_at([100.5, 70.0, -3.25], None); + let request = serde_json::json!({ "record": record, "place": false }); + assert!( + !import_player(&mut hub, UUID_N, &request) + .unwrap() + .recentered + ); + } + + /// A retried import of the same hand-over (its reply was lost) finds the view already moved, + /// and still says the chunks around the new place are on their way. + #[test] + fn a_retried_import_still_reports_the_recentring() { + let (record, mut hub, _mine) = held_with_a_record_at([100.5, 70.0, -3.25], Some((0, 0))); + let request = serde_json::json!({ "record": record, "place": false }); + for _ in 0..2 { + let done = import_player(&mut hub, UUID_N, &request).unwrap(); + assert!(done.recentered); + } + } + + /// A record whose vehicle is still to be restored puts the player back on it at the vehicle's + /// own place, so a position the request names would be undone: it is refused, and the record's + /// own place is still taken. + #[test] + fn a_named_position_is_refused_for_a_record_with_a_vehicle_to_restore() { + let (record, mut hub, _mine) = held_with_a_record_of( + |g| { + g.position = [10.5, 70.0, -3.25]; + g.saved_vehicle = Some([1, 2, 3, 4]); + }, + Some((0, 0)), + ); + let named = serde_json::json!({ + "record": record.clone(), "place": false, "position": [300.5, 64.0, 300.5], + }); + let error = import_player(&mut hub, UUID_N, &named).unwrap_err(); + assert!(error.contains("vehicle"), "{error}"); + assert!(!hub.guests[0].imported); + let plain = serde_json::json!({ "record": record, "place": false }); + import_player(&mut hub, UUID_N, &plain).unwrap(); + assert_eq!(hub.guests[0].position, [10.5, 70.0, -3.25]); + assert_eq!(hub.guests[0].saved_vehicle, Some([1, 2, 3, 4])); + } + + #[test] + fn placing_at_once_reports_the_recentring_too() { + let (record, mut hub, mine) = held_with_a_record_at([100.5, 70.0, -3.25], Some((0, 0))); + let done = + import_player(&mut hub, UUID_N, &serde_json::json!({ "record": record })).unwrap(); + assert!(done.place && done.recentered); + assert!(ids(&mine).contains(&id::SET_CHUNK_CACHE_CENTER)); + } + + // ---- where the player arrives: the request's own position, rotation, on-ground ----- + + /// The position a PlayerPosition packet carries, with its look. + fn position_sent(mine: &Receiver) -> ([f64; 3], f32, f32) { + let (id_, payload) = packets(mine) + .into_iter() + .rev() + .find(|(i, _)| *i == id::PLAYER_POSITION) + .expect("the import sends a position"); + match horizon_protocol::packets::play::Clientbound::decode(id_, &payload).unwrap() { + horizon_protocol::packets::play::Clientbound::PlayerPosition(p) => { + (p.position, p.yaw, p.pitch) + } + other => panic!("not a position: {other:?}"), + } + } + + #[test] + fn the_requests_position_wins_over_the_records_and_the_client_is_told_it() { + let (record, mut hub, mine) = held_with_a_record_at([10.5, 70.0, -3.25], Some((0, 0))); + let request = serde_json::json!({ + "record": record, "place": false, + "position": [300.5, 64.0, 300.5], "rotation": [90.0, 20.0], "on_ground": false, + }); + let done = import_player(&mut hub, UUID_N, &request).unwrap(); + let g = &hub.guests[0]; + assert_eq!(g.position, [300.5, 64.0, 300.5]); + assert_eq!(g.previous_position, [300.5, 64.0, 300.5]); + assert_eq!((g.yaw, g.pitch, g.head_yaw), (90.0, 20.0, 90.0)); + assert!(!g.on_ground); + // The chunk is the one of the request's position, not the record's. + assert!(done.recentered); + assert_eq!(g.center, Some((18, 18))); + let (at, yaw, pitch) = position_sent(&mine); + assert_eq!((at, yaw, pitch), ([300.5, 64.0, 300.5], 90.0, 20.0)); + } + + #[test] + fn without_the_requests_fields_the_records_place_is_taken() { + let (record, mut hub, mine) = held_with_a_record_at([10.5, 70.0, -3.25], Some((0, 0))); + import_player( + &mut hub, + UUID_N, + &serde_json::json!({ "record": record, "place": false }), + ) + .unwrap(); + assert_eq!(hub.guests[0].position, [10.5, 70.0, -3.25]); + assert_eq!(position_sent(&mine).0, [10.5, 70.0, -3.25]); + } + + #[test] + fn a_pitch_is_taken_modulo_a_turn_then_cut_to_a_quarter_turn() { + // Entity.setXRot: Math.clamp(pitch % 360, -90, 90); setYRot keeps the yaw as it is. + for (asked, yaw, pitch) in [ + ([10.0, 120.0], 10.0, 90.0), + ([10.0, -120.0], 10.0, -90.0), + ([10.0, 370.0], 10.0, 10.0), + ([-30.0, -350.0], -30.0, -90.0), + ([725.0, 45.0], 725.0, 45.0), + ] { + let (record, mut hub, _mine) = held_with_a_record_at([10.5, 70.0, -3.25], None); + let request = + serde_json::json!({ "record": record, "place": false, "rotation": asked }); + import_player(&mut hub, UUID_N, &request).unwrap(); + assert_eq!( + (hub.guests[0].yaw, hub.guests[0].pitch), + (yaw, pitch), + "{asked:?}" + ); + } + } + + /// Every refusal leaves the held player as it was and tells its client nothing. + #[test] + fn a_place_that_is_not_one_is_refused_and_nothing_changes() { + let (record, mut hub, mine) = held_with_a_record_at([10.5, 70.0, -3.25], Some((0, 0))); + let before = crate::lan::playerdata::player_tag(&hub.guests[0]); + let place = hub.guests[0].position; + let with = |extra: serde_json::Value| { + let mut r = serde_json::json!({ "record": record.clone(), "place": false }); + for (k, v) in extra.as_object().unwrap() { + r[k] = v.clone(); + } + r + }; + let cases = [ + ( + "beyond the world", + with(serde_json::json!({ "position": [40_000_000.0, 64.0, 0.0] })), + ), + ( + "far below", + with(serde_json::json!({ "position": [0.0, -40_000_000.0, 0.0] })), + ), + ( + "two numbers", + with(serde_json::json!({ "position": [1.0, 2.0] })), + ), + ( + "a string", + with(serde_json::json!({ "position": ["a", "b", "c"] })), + ), + ("not a list", with(serde_json::json!({ "position": 5 }))), + ( + "rotation of one", + with(serde_json::json!({ "rotation": [1.0] })), + ), + ( + "a rotation past what a float holds", + with(serde_json::json!({ "rotation": [1e39, 1e39] })), + ), + ( + "a velocity that is not three numbers", + with(serde_json::json!({ "velocity": [0.25, "up", 0.0] })), + ), + ( + "on_ground a number", + with(serde_json::json!({ "on_ground": 1 })), + ), + ( + "the position part left out", + with(serde_json::json!({ "position": [1.0, 64.0, 1.0], "parts": ["inventory"] })), + ), + ]; + for (what, request) in &cases { + let error = import_player(&mut hub, UUID_N, request).expect_err(what); + assert!(!error.is_empty(), "{what}"); + let g = &hub.guests[0]; + assert!( + g.held && !g.imported, + "{what}: the player must still be held and not imported" + ); + assert_eq!(g.position, place, "{what}"); + assert_eq!( + crate::lan::playerdata::player_tag(g), + before, + "{what}: nothing may change" + ); + } + assert_eq!( + ids(&mine), + Vec::::new(), + "a refusal tells the client nothing" + ); + } + + #[test] + fn a_position_is_finite_and_inside_the_worlds_limit() { + use crate::lan::import::check_position_for_tests as check; + assert!(check([0.0, 64.0, 0.0])); + // Entity.load's clamp: 3.0000512E7 on x and z, 2.0E7 on y. + assert!(check([-30_000_512.0, -64.0, 30_000_512.0])); + assert!(check([0.0, -20_000_000.0, 0.0])); + assert!(!check([30_000_512.5, 64.0, 0.0])); + assert!(!check([0.0, 20_000_000.5, 0.0])); + assert!(!check([f64::NAN, 64.0, 0.0])); + assert!(!check([0.0, f64::INFINITY, 0.0])); + assert!(!check([0.0, 64.0, f64::NEG_INFINITY])); + } + + /// The request's velocity is the body's from now on, a component over 10 blocks a tick + /// taken as 0 as `Entity.load` takes a saved `Motion`; it belongs to the position part. + #[test] + fn the_requests_velocity_is_taken_with_the_position() { + let (record, mut hub, _mine) = held_with_a_record_at([10.5, 70.0, -3.25], None); + let request = serde_json::json!({ + "record": record, + "place": false, + "velocity": [0.25, -0.0784, 12.0], + }); + import_player(&mut hub, UUID_N, &request).unwrap(); + let body = hub.guests[0].loaded_body.unwrap(); + assert_eq!(body.velocity, [0.25, -0.0784, 0.0]); + let alone = serde_json::json!({ + "record": record, + "parts": ["inventory"], + "velocity": [0.0, 0.0, 0.0], + }); + let error = import_player(&mut hub, UUID_N, &alone).unwrap_err(); + assert!(error.contains("belong to the position part"), "{error}"); + } + + // ---- the lines of the log ------------------------------------------------ + + use super::{dropped_line, held_line, placed_line, request_log}; + use serde_json::json; + + #[test] + fn a_refused_request_is_a_warning_with_the_reason() { + let reply = json!({ "error": "that player is riding" }); + let (level, line) = request_log( + "player-export", + Some("Alex"), + &format!("{UUID} freeze"), + &reply, + ); + assert_eq!(level, "WARN"); + assert_eq!( + line, + "player-export Alex (123e4567-e89b-12d3-a456-426614174000): refused: that player is riding" + ); + } + + #[test] + fn an_accepted_request_says_what_it_did_and_never_writes_the_record() { + let id = UUID.replace('-', ""); + let (level, line) = request_log( + "player-expect", + None, + &format!("{id} 30"), + &json!({"ok": true, "renewed": true, "ttl_seconds": 30}), + ); + assert_eq!(level, "INFO"); + assert_eq!( + line, + "player-expect 123e4567-e89b-12d3-a456-426614174000: ok, expected for 30 s (renewed)" + ); + // 8 base64 characters are 6 bytes. + let reply = json!({"ok": true, "record": {"player": "AAAAAAAA", "name": "Alex", "handover": "0123456789abcdef0123456789abcdef"}}); + let (_, line) = request_log( + "player-export", + Some("Alex"), + &format!("{UUID} freeze"), + &reply, + ); + assert!( + line.ends_with( + "ok, record of 6 bytes, frozen, hand-over 0123456789abcdef0123456789abcdef" + ), + "{line}" + ); + // An export without freeze is a read. + let read = json!({"ok": true, "record": {"player": "AAAAAAAA", "handover": null}}); + let (_, line) = request_log("player-export", None, UUID, &read); + assert!(line.ends_with("ok, record of 6 bytes, a read"), "{line}"); + assert!( + !line.contains("AAAA"), + "the record itself must not be written: {line}" + ); + let reply = json!({"ok": true, "handover": "0123456789abcdef0123456789abcdef", "parts": ["inventory", "experience"], "placed": false, "announced": true, "recentered": true}); + let (_, line) = request_log( + "player-import", + Some("Alex"), + &format!("{UUID} {{\"record\":{{}}}}"), + &reply, + ); + assert!( + line.ends_with( + "ok, parts inventory,experience, placed false, announced true, recentered true, hand-over 0123456789abcdef0123456789abcdef" + ), + "{line}" + ); + assert!( + !line.contains("record"), + "the request body must not be written: {line}" + ); + let (_, line) = request_log( + "player-release", + Some("Alex"), + &format!("{UUID} 0123456789abcdef0123456789abcdef moved"), + &json!({"ok": true, "released": "moved", "announced": false}), + ); + assert!( + line.ends_with( + "ok, moved, announced false, hand-over 0123456789abcdef0123456789abcdef" + ), + "{line}" + ); + let (_, line) = request_log( + "player-drop", + None, + UUID, + &json!({"ok": true, "held": true, "expected": false}), + ); + assert!(line.ends_with("ok, held true, expected false"), "{line}"); + let (_, line) = request_log( + "player-drop", + None, + &format!("{UUID} 0123456789abcdef0123456789abcdef"), + &json!({"ok": true, "held": true, "expected": false}), + ); + assert!( + line.ends_with( + "ok, held true, expected false, hand-over 0123456789abcdef0123456789abcdef" + ), + "{line}" + ); + let (_, line) = request_log( + "player-confirm", + None, + &format!("{UUID} 0123456789abcdef0123456789abcdef"), + &json!({"ok": true, "placed": true, "announced": true}), + ); + assert!( + line.ends_with( + "ok, placed, announced true, hand-over 0123456789abcdef0123456789abcdef" + ), + "{line}" + ); + } + + #[test] + fn a_request_without_a_readable_id_is_logged_as_typed() { + let (level, line) = request_log( + "player-drop", + None, + "nonsense", + &json!({"error": "player-drop needs the player's unique id"}), + ); + assert_eq!(level, "WARN"); + assert!( + line.starts_with("player-drop \"nonsense\": refused"), + "{line}" + ); + } + + #[test] + fn the_lines_of_a_held_player_never_say_it_joined_or_left() { + assert_eq!( + held_line("Alex"), + "Alex is held: it arrives from another server" + ); + assert_eq!( + placed_line("Alex"), + "Alex placed: it has arrived from another server" + ); + let dropped = dropped_line("Alex", "dropped before its hand-over"); + assert_eq!( + dropped, + "Alex was dropped while held: dropped before its hand-over" + ); + for line in [held_line("Alex"), dropped] { + assert!( + !line.contains("joined the game") && !line.contains("left the game"), + "{line}" + ); + } + } + + // ---- the view follows the imported position ------------------------------ } diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index a55571ed8c..6f0d3da4e6 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -346,6 +346,23 @@ pub struct Guest { /// Its login started while a proxy said it would arrive from another /// server, and used that expectation up (`player-expect`). pub expected: bool, + /// A player arriving from another server (`arrivals`): connected and + /// receiving the world, but not placed, so no one else sees, hears or + /// hurts it until the hand-over finishes. + pub held: bool, + /// The hub tick at which it was held, to drop it if its hand-over never comes. + pub held_since: u64, + /// A held player whose record has been applied but that is not yet let into the world + /// (`player-import` with `"place": false`): `player-confirm` places it, `player-drop` removes it. + pub imported: bool, + /// Whether the others are told when it is placed, as the import asked. + pub arrival_announce: bool, + /// The hand-over its record came with (`player-import`): `player-confirm` and + /// `player-drop` name it, and another hand-over's record is not taken. + pub arrival_handover: Option, + /// The import of that hand-over moved the view to another chunk (`recentered` in its reply), + /// kept so a retried import or a confirm after it answers the same. + pub arrival_recentered: bool, /// Leaving for another server (`player-release moved`) without the "left the /// game" line. pub quiet_leave: bool, @@ -529,6 +546,12 @@ impl Guest { placed: false, handover: None, expected: j.expected, + held: false, + held_since: 0, + imported: false, + arrival_announce: true, + arrival_handover: None, + arrival_recentered: false, quiet_leave: false, released: false, hidden_commands: Vec::new(), diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index 3de56f70aa..d4a542785f 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -746,6 +746,14 @@ impl Hub { g.sender.send(body.clone()); } } + /// [`Self::broadcast`] to the held players as well, which keep a picture of + /// the players and the entities while they wait for their hand-over. + pub fn broadcast_watching(&self, packet: &C) { + let body = packet.encode(); + for g in self.guests.iter().filter(|g| g.placed || g.held) { + g.sender.send(body.clone()); + } + } /// MinecraftServer.onGameRuleChanged: every player is told when /// `immediate_respawn` changes; a joining player's Login carries it. pub fn set_immediate_respawn(&mut self, on: bool) { @@ -828,11 +836,27 @@ impl Hub { pub fn here(&self, g: &Guest) -> bool { g.placed && g.dimension == self.config.dimension } + /// Whether a guest is sent the level in place: the players placed in it, and + /// the held ones, which look at it without being part of it. + pub fn watches(&self, g: &Guest) -> bool { + (g.placed || g.held) && g.dimension == self.config.dimension + } + /// The chunk under each player the level in place has to keep loaded: the players placed in + /// it and the held ones, which are sent its chunks too ([`Self::watches`]), whose `loads` + /// says so (a spectator may not load chunks). A server with only a held player must load + /// and generate the world around it, or the player waits for ever for its world. + pub fn loading_chunks(&self, loads: impl Fn(&Guest) -> bool) -> Vec<(i32, i32)> { + self.guests + .iter() + .filter(|g| self.watches(g) && loads(g)) + .map(Guest::chunk) + .collect() + } /// `PlayerList.broadcastAll(packet, dimension)`: the players in the /// level in place. pub fn broadcast_level(&self, packet: &C) { let body = packet.encode(); - for g in self.guests.iter().filter(|g| self.here(g)) { + for g in self.guests.iter().filter(|g| self.watches(g)) { g.sender.send(body.clone()); } } @@ -1003,9 +1027,31 @@ impl Hub { ); } + /// Disconnects the held player `uuid` and nothing else: no save (it was + /// never placed), no word to anyone (no one was told of it). Returns whether + /// there was one. + pub fn drop_held(&mut self, uuid: u128) -> bool { + let Some(g) = self + .guests + .iter_mut() + .find(|g| g.uuid == uuid && g.held && !g.placed) + else { + return false; + }; + // Neither a confirm nor an import may take it while its connection closes. + g.imported = false; + if g.left_reason.is_empty() { + g.left_reason = "dropped before its hand-over".to_owned(); + } + g.sender + .disconnect(translatable("multiplayer.disconnect.generic", &[])); + true + } + /// Lets go of what a proxy that stopped answering left behind: the players - /// it said would come and did not, and the freezes no hand-over ended. - /// Neither may last for ever. + /// it said would come and did not, the held players whose hand-over never + /// finished, and the freezes no hand-over ended. None may last for ever, and + /// a held player goes without a word to anyone, since no one was told of it. fn expire_arrivals(&mut self, events: &mut Vec) { // Off: nothing to do (and no player can be frozen). let Some(transfers) = &self.transfers else { @@ -1017,7 +1063,32 @@ impl Hub { horizon_protocol::crypto::uuid_string(uuid) ))); } - let max = transfers.settings.max_freeze_ticks; + let (max, max_hold) = ( + transfers.settings.max_freeze_ticks, + transfers.settings.max_hold_ticks, + ); + // Not one already kicked or dropped: its connection may take a while to close, + // and it must not be kicked (and reported) again on every tick. + let stale: Vec = self + .guests + .iter() + .filter(|g| { + g.held + && !g.placed + && g.left_reason.is_empty() + && super::arrivals::held_too_long(g.held_since, self.ticks, max_hold) + }) + .map(|g| g.conn) + .collect(); + for conn in stale { + if let Some(g) = self.guest(conn) { + events.push(HostEvent::Warn(format!( + "{} was held for {max_hold} ticks and the hand-over never finished: dropped", + g.name, + ))); + } + self.kick(conn, translatable("disconnect.timeout", &[])); + } for g in &mut self.guests { let Some(h) = &g.handover else { continue; @@ -1206,6 +1277,15 @@ impl Hub { let g = &self.guests[i]; (g.name.clone(), g.uuid, g.entity_id, g.game_mode) }; + // A player whose login used up a proxy's expectation (`arrivals`, as the + // login started) is held: it gets the whole join sequence, so it can build + // its picture of the world, but no one else is told about it. + if self.guests[i].expected { + let g = &mut self.guests[i]; + g.held = true; + g.held_since = self.ticks; + } + let held = self.guests[i].held; let g = &self.guests[i]; g.send(&C::Login(play::Login { player_id: entity_id, @@ -1242,21 +1322,24 @@ impl Hub { self.send_initial_recipe_book(i); // PlayerList.placeNewPlayer broadcasts the join line before // players.add: the others read it, the newcomer doesn't (nor those - // a native mod hides it from). - let joined = C::SystemChat(play::SystemChat { - content: yellow_names( - "multiplayer.player.joined", - vec![self.player_display_name(self.guests[i].uuid, &name)], - ), - overlay: false, - }) - .encode(); - for g in self.guests.iter().filter(|g| g.placed && g.conn != conn) { - if self.sees(g.permission, uuid) { - g.sender.send(joined.clone()); + // a native mod hides it from). A held player is not announced, and + // not placed. + if !held { + let joined = C::SystemChat(play::SystemChat { + content: yellow_names( + "multiplayer.player.joined", + vec![self.player_display_name(self.guests[i].uuid, &name)], + ), + overlay: false, + }) + .encode(); + for g in self.guests.iter().filter(|g| g.placed && g.conn != conn) { + if self.sees(g.permission, uuid) { + g.sender.send(joined.clone()); + } } + self.guests[i].placed = true; } - self.guests[i].placed = true; self.resend_teleport(i, None); let g = &self.guests[i]; g.send(&C::ServerData(play::ServerData { @@ -1291,12 +1374,20 @@ impl Hub { entries: vec![self.player_info(uuid, &name, mode, 0)], }) .encode(); - let level = self.hidden.get(&uuid).copied(); - for g in self.guests.iter_mut().filter(|g| g.placed) { - if level.is_some_and(|l| g.permission < l) { - g.tab_hidden.insert(uuid); - } else { - g.sender.send(newcomer.clone()); + if held { + // Its own list has itself in it; the others' lists do not. + self.guests[i].sender.send(newcomer); + } else { + let level = self.hidden.get(&uuid).copied(); + // A held player is shown the entities around it, so it must know the players + // who join meanwhile, or its client drops their entity (`ClientPacketListener` + // adds a player only with its list entry). + for g in self.guests.iter_mut().filter(|g| g.placed || g.held) { + if level.is_some_and(|l| g.permission < l) { + g.tab_hidden.insert(uuid); + } else { + g.sender.send(newcomer.clone()); + } } } self.send_level_info(i, ctx, true); @@ -1315,6 +1406,175 @@ impl Hub { self.send_inventory(i); } + /// What an import changed, told to the held player's client the way a join tells it: game + /// mode, abilities, held slot, a position to confirm, effects, inventory, and the player + /// list as it is now (who came while it was held is in it). + /// + /// When the import moved the player to another chunk than the one its view was built around, + /// the view is moved at once (the chunk centre is sent and the chunks out of reach are + /// forgotten, as the next tick would do), and this returns true: the chunks around the new + /// place are still to come, one batch at a time, and whoever reads them must wait for them. + pub fn send_arrival_state(&mut self, i: usize) -> bool { + let (mode, conn) = { + let g = &self.guests[i]; + (g.game_mode, g.conn) + }; + // The effects the login sent from this server's own file and the import took away: + // `send_active_effects` only adds, and the client would go on predicting with them. + let gone: Vec<&'static str> = { + let g = &self.guests[i]; + g.sent_effects + .iter() + .filter(|s| !g.effects.list.iter().any(|e| e.id == s.id)) + .map(|s| s.id) + .collect() + }; + for id in gone { + if let Some(effect) = self + .sync + .id("minecraft:mob_effect", crate::brewing::namespaced(id)) + { + let g = &self.guests[i]; + g.send(&C::RemoveMobEffect(play::RemoveMobEffect { + id: g.entity_id, + effect, + })); + } + } + let recentered = self.guests[i] + .center + .is_some_and(|c| c != self.guests[i].chunk()); + if recentered { + self.update_view(i); + } + let g = &self.guests[i]; + g.send(&C::GameEvent(play::GameEvent { + event: game_event::CHANGE_GAME_MODE, + param: mode as f32, + })); + g.send(&g.abilities()); + g.send(&C::SetHeldSlot(play::SetHeldSlot { + slot: g.selected as i32, + })); + self.resend_teleport(i, None); + self.send_active_effects(i); + self.send_inventory(i); + // The login sent the book of this server's file; the import may have brought another + // (`ServerRecipeBook.sendInitialRecipeBook` replaces what the client has). + self.send_initial_recipe_book(i); + let permission = self.guests[i].permission; + let entries: Vec<_> = self + .guests + .iter() + .filter(|o| o.conn != conn && o.placed && self.sees(permission, o.uuid)) + .map(|o| { + self.player_info( + o.uuid, + &o.name, + o.game_mode, + o.latency.load(Ordering::Relaxed), + ) + }) + .collect(); + self.guests[i].send(&C::PlayerInfoUpdate(play::PlayerInfoUpdate { + actions: Self::INFO_ACTIONS, + entries, + })); + recentered + } + + /// Places the held player at `i`: it is simulated, shown to the others and takes part from + /// the next tick, when its first tick does what a new player's does (health, experience, + /// advancements). Everyone gets its player-list entry, since everyone will be sent its entity + /// (a client adds a player's entity only with its list entry); `announce` is the join line. + pub fn place_arrival(&mut self, i: usize, announce: bool) { + if !self.guests[i].held || self.guests[i].placed { + return; + } + let (name, uuid, mode, conn) = { + let g = &self.guests[i]; + (g.name.clone(), g.uuid, g.game_mode, g.conn) + }; + if announce { + let joined = C::SystemChat(play::SystemChat { + content: yellow_names( + "multiplayer.player.joined", + vec![self.player_display_name(uuid, &name)], + ), + overlay: false, + }) + .encode(); + for g in self.guests.iter().filter(|g| g.placed && g.conn != conn) { + if self.sees(g.permission, uuid) { + g.sender.send(joined.clone()); + } + } + } + let g = &mut self.guests[i]; + g.held = false; + g.imported = false; + g.placed = true; + g.placed_at = self.ticks; + // The player list entry of the newcomer, to everyone placed or held (itself included). + let newcomer = C::PlayerInfoUpdate(play::PlayerInfoUpdate { + actions: Self::INFO_ACTIONS, + entries: vec![self.player_info(uuid, &name, mode, 0)], + }) + .encode(); + let level = self.hidden.get(&uuid).copied(); + for g in self.guests.iter_mut().filter(|g| g.placed || g.held) { + if level.is_some_and(|l| g.permission < l) { + g.tab_hidden.insert(uuid); + } else { + g.sender.send(newcomer.clone()); + } + } + } + + /// The guest index of the player `uuid` placed from the hand-over `id` (its import or + /// confirmation committed): a retry of either, whose reply was lost, is answered the same. + pub fn placed_arrival(&self, uuid: u128, id: u128) -> Option { + self.guests + .iter() + .position(|g| g.uuid == uuid && g.placed && g.arrival_handover == Some(id)) + } + + /// The guest index of the held player `uuid` whose record of the hand-over `id` was + /// imported, and that has not been dropped or kicked meanwhile. + pub fn imported_arrival(&self, uuid: u128, id: u128) -> Result { + let Some(i) = self + .guests + .iter() + .position(|g| g.uuid == uuid && g.held && !g.placed) + else { + return Err("no player with that unique id is held"); + }; + let g = &self.guests[i]; + if !g.left_reason.is_empty() { + Err("that player is leaving") + } else if !g.imported { + Err("that player is held but its record has not been imported") + } else if g.arrival_handover != Some(id) { + Err("that player's record is of another hand-over") + } else { + Ok(i) + } + } + + /// Places the held player `uuid` whose record was imported without placing it + /// (`player-confirm`). Returns whether the others were told it came, as the import asked. + /// + /// What the client did since the import was not acted on, so it is put back where the + /// server has it: the position to confirm again and the inventory. + pub fn confirm_arrival(&mut self, uuid: u128, id: u128) -> Result { + let i = self.imported_arrival(uuid, id)?; + let announce = self.guests[i].arrival_announce; + self.resend_teleport(i, None); + self.send_inventory(i); + self.place_arrival(i, announce); + Ok(announce) + } + /// `ServerPlayer.sendPostEffects`: the player's `/posteffect` chains. pub fn send_post_effects(&mut self, i: usize) { let g = &mut self.guests[i]; @@ -1895,6 +2155,12 @@ impl Hub { format!("{}'s {handled} packets", g.name) }); } + } else if self.guests[i].held && self.watches(&self.guests[i]) { + // A held player only answers what the server asked of it: its + // acknowledgements keep the chunks coming. Whatever else it sends + // (moves, clicks, commands) is not for the world. + self.keep_acknowledgements(i, &mut events); + self.process_inbox(i, ctx, &mut events); } } events @@ -1906,7 +2172,7 @@ impl Hub { /// earlier (`drain_packets`) so scheduled ticks see what they placed. pub fn tick_level(&mut self, ctx: &mut TickCtx) -> Vec { let events = Vec::new(); - if !self.guests.iter().any(|g| self.here(g)) { + if !self.guests.iter().any(|g| self.watches(g)) { // ServerTickRateManager broadcasts a change to the players there // are; one who joins later is sent the state as it is then // (`updateJoiningPlayer`). @@ -2001,7 +2267,7 @@ impl Hub { } } - fn remove_guest(&mut self, i: usize, reason: &str) -> Guest { + pub(super) fn remove_guest(&mut self, i: usize, reason: &str) -> Guest { let mut g = self.guests.remove(i); if let Some(m) = g .menu @@ -2014,6 +2280,16 @@ impl Hub { window: m.window, }); } + // A held player a proxy dropped keeps, in the log, how its connection really ended + // (an unreadable packet, a reset): the drop says only that it was dropped. + if g.held + && !g.placed + && !reason.is_empty() + && !g.left_reason.is_empty() + && g.left_reason != reason + { + g.left_reason = format!("{} (the connection ended: {reason})", g.left_reason); + } if g.left_reason.is_empty() { g.left_reason = reason.to_owned(); } @@ -2031,10 +2307,11 @@ impl Hub { &mut self.attachment_snapshots, self.sync_attachments, ); - self.broadcast(&C::PlayerInfoRemove(play::PlayerInfoRemove { + // To the held players too: they were told of this one, and see its entity. + self.broadcast_watching(&C::PlayerInfoRemove(play::PlayerInfoRemove { uuids: vec![g.uuid], })); - self.broadcast(&C::RemoveEntities(play::RemoveEntities { + self.broadcast_watching(&C::RemoveEntities(play::RemoveEntities { ids: vec![g.entity_id], })); let left = C::SystemChat(play::SystemChat { @@ -2192,7 +2469,7 @@ impl Hub { } let mut batches = Vec::new(); for i in 0..self.guests.len() { - if self.here(&self.guests[i]) { + if self.watches(&self.guests[i]) { self.update_view(i); if let Some(batch) = self.next_batch(i, world, ctx.streaming) { batches.push((i, batch)); @@ -2410,7 +2687,7 @@ impl Hub { .iter() .filter_map(|key| Some((*key, self.encoder.biome_data(world.chunks.get(key)?)))) .collect(); - for g in self.guests.iter().filter(|g| self.here(g)) { + for g in self.guests.iter().filter(|g| self.watches(g)) { let chunks: Vec<_> = data .iter() .filter(|(key, _)| g.sent_chunks.contains(key)) @@ -2454,7 +2731,7 @@ impl Hub { let viewers: Vec = (0..self.guests.len()) .filter(|&i| { let g = &self.guests[i]; - self.here(g) + self.watches(g) && tracked(g, x, z) && (-1..=1).any(|dz| (-1..=1).any(|dx| !tracked(g, x + dx, z + dz))) }) @@ -2510,7 +2787,7 @@ impl Hub { } let viewers: Vec = (0..self.guests.len()) .filter(|&i| { - self.here(&self.guests[i]) && self.guests[i].sent_chunks.contains(&key) + self.watches(&self.guests[i]) && self.guests[i].sent_chunks.contains(&key) }) .collect(); if viewers.is_empty() { @@ -2575,7 +2852,7 @@ impl Hub { } let viewers: Vec = (0..self.guests.len()) .filter(|&i| { - self.here(&self.guests[i]) && self.guests[i].sent_chunks.contains(&(sx, sz)) + self.watches(&self.guests[i]) && self.guests[i].sent_chunks.contains(&(sx, sz)) }) .collect(); if viewers.is_empty() { @@ -2946,7 +3223,9 @@ impl Hub { level.synced.clone_from(&self.synced); let mut outs = std::mem::take(&mut self.entity_out); let dimension = &self.config.dimension; - let here = |g: &Guest| g.placed && g.dimension == *dimension; + // The viewers include the held players: they are shown the entities + // around them, but are not among the entities shown (`track_entities`). + let here = |g: &Guest| (g.placed || g.held) && g.dimension == *dimension; outs.resize_with(self.guests.iter().filter(|g| here(g)).count(), Vec::new); // The prediction's replay excuses a client that may still have an entity that left it. let recording = self.recording(); diff --git a/crates/horizon-client/src/lan/import.rs b/crates/horizon-client/src/lan/import.rs new file mode 100644 index 0000000000..0f4040bf94 --- /dev/null +++ b/crates/horizon-client/src/lan/import.rs @@ -0,0 +1,613 @@ +//! `player-import`: a player's record, as `player-export` made it on another +//! server (or the same one), applied to the held player that has just logged in. +//! +//! The record can be applied whole or in parts. A part left out keeps what the +//! held player has: the state this server loaded for it when it logged in (its own +//! file, or a new player's defaults). The parts are named as the operator of a proxy +//! names them, and each is a set of the guest's fields: +//! +//! | part | what it sets | +//! | --- | --- | +//! | `inventory` | the inventory, armour, off hand and the selected slot | +//! | `ender_chest` | the ender chest | +//! | `experience` | the level, the progress, the total and the seed | +//! | `health` | health and absorption, food, saturation, exhaustion and its timer, and the body's air, fire and freezing | +//! | `effects` | the active effects | +//! | `game_mode` | the game mode, the flight and its speed | +//! | `position` | where it stands, how it looks, whether it is on the ground, its fall and motion, the vehicle it rode | +//! | `spawn_point` | the respawn point | +//! | `recipe_book` | the recipe book | +//! | `advancements` | the advancements and the statistics (the caller applies them: they need the game's tree and counters) | +//! +//! What the record holds that no part names (the compound's keys Horizon does not +//! model) is taken when every part is. +use super::{ + guest::{Guest, SavedBody}, + hub::Hub, + playerdata, +}; +use crate::save::Level; +use horizon_world::nbt::{Limits, Tag, read_named}; +use serde_json::Value; + +/// The parts of a record, in the order the replies list them. +pub const PARTS: [&str; 10] = [ + "inventory", + "ender_chest", + "experience", + "health", + "effects", + "game_mode", + "position", + "spawn_point", + "recipe_book", + "advancements", +]; + +/// What an import did, for the caller to finish. +#[derive(Debug, PartialEq)] +pub struct Imported { + /// The parts that were applied, in [`PARTS`] order. + pub parts: Vec<&'static str>, + /// The advancements and the statistics to give the player, when the advancements part was + /// asked for: they need the game's tree and its counters. + pub advancements: Option, + pub stats: Option, + /// Whether the mods' attachments came (a whole import): the game's own copy of them is then + /// the record's. + pub attachments: bool, + /// Whether the other players are told the player has arrived (when it is placed). + pub announce: bool, + /// Whether the request asks the player to be let into the world now. The import never places + /// it itself: the game writes the player's file first and places it then, as for + /// `player-confirm`. Until then it stays held, and `player-drop` removes it. + pub place: bool, + /// The record's hand-over. + pub handover: u128, + /// Whether the import moved the player to another chunk than the one its view was built + /// around. The chunk centre was sent at once, but the chunks around the new place come with + /// the next ticks: whoever reads the player's world must wait for them. + pub recentered: bool, +} + +/// What a request of `player-import` asks: the record, the parts (all by default) +/// and whether the world is told (yes by default). +struct Request<'a> { + record: &'a Value, + parts: Vec<&'static str>, + announce: bool, + place: bool, + arrival: Arrival, +} + +/// Where the player stands when it arrives, when the request says so instead of the record: +/// what a proxy knows of its client, which the frozen source may not have seen yet. +#[derive(Default, Debug, PartialEq)] +struct Arrival { + position: Option<[f64; 3]>, + /// Yaw and pitch, in degrees. + rotation: Option<[f32; 2]>, + on_ground: Option, + /// Blocks a tick, as the body keeps it. + velocity: Option<[f64; 3]>, +} + +impl Arrival { + fn is_empty(&self) -> bool { + *self == Arrival::default() + } +} + +/// A position the server will put a player at: finite and inside the world's limit. Nothing +/// is asked of the border, the blocks or the chunks, as a vanilla server asks nothing of the +/// place a player's file names. +/// The same bounds as the record's own position ([`check_values`]). +fn check_position(p: [f64; 3]) -> Result<[f64; 3], String> { + if p.iter().all(|c| c.is_finite()) + && p[0].abs() <= MAX_XZ + && p[2].abs() <= MAX_XZ + && p[1].abs() <= MAX_Y + { + Ok(p) + } else { + Err(format!( + "{p:?} is not a position: x and z must be numbers within {MAX_XZ}, y within {MAX_Y}" + )) + } +} + +/// For the tests of the hub: whether a position is one the server will take. +#[cfg(test)] +pub fn check_position_for_tests(p: [f64; 3]) -> bool { + check_position(p).is_ok() +} + +/// `N` numbers out of a JSON list. +fn numbers(value: &Value, what: &str) -> Result<[f64; N], String> { + let list = value + .as_array() + .filter(|l| l.len() == N) + .ok_or_else(|| format!("{what} is a list of {N} numbers"))?; + let mut out = [0.0; N]; + for (slot, v) in out.iter_mut().zip(list) { + *slot = v + .as_f64() + .filter(|n| n.is_finite()) + .ok_or_else(|| format!("{what} holds only finite numbers"))?; + } + Ok(out) +} + +fn read_arrival(request: &Value) -> Result { + let given = |key: &str| request.get(key).filter(|v| !v.is_null()); + let position = match given("position") { + Some(v) => Some(check_position(numbers::<3>(v, "position")?)?), + None => None, + }; + let rotation = match given("rotation") { + Some(v) => { + let [yaw, pitch] = numbers::<2>(v, "rotation")?; + // Entity.setXRot: the pitch is taken modulo a turn (`frem`, the sign of the + // dividend, as Rust's `%`), then cut to a quarter turn each way; setYRot keeps the + // yaw as it is. + let (yaw, pitch) = (yaw as f32, pitch as f32); + // Finite as an f64 is not enough: past f32::MAX the cast gives an infinity (and + // the pitch's modulo a NaN), which Entity.load would refuse. + if !yaw.is_finite() || !pitch.is_finite() { + return Err("rotation holds only numbers a float can hold".into()); + } + let pitch = (pitch % 360.0).clamp(-90.0, 90.0); + Some([yaw, pitch]) + } + None => None, + }; + // Entity.load: a component over 10 blocks a tick reads as 0. + let velocity = match given("velocity") { + Some(v) => Some(numbers::<3>(v, "velocity")?.map(|c| if c.abs() > 10.0 { 0.0 } else { c })), + None => None, + }; + let on_ground = match given("on_ground") { + None => None, + Some(Value::Bool(b)) => Some(*b), + Some(_) => return Err("on_ground is true or false".into()), + }; + Ok(Arrival { + position, + rotation, + on_ground, + velocity, + }) +} + +fn read_request(request: &Value) -> Result, String> { + let record = request + .get("record") + .filter(|r| r.is_object()) + .ok_or("the request needs a record")?; + let parts = match request.get("parts") { + None | Some(Value::Null) => PARTS.to_vec(), + Some(Value::Array(names)) => { + let mut parts = Vec::new(); + for name in names { + let name = name.as_str().ok_or("a part is a name")?; + let part = PARTS + .iter() + .find(|p| **p == name) + .ok_or_else(|| format!("{name:?} is not a part of a record"))?; + if !parts.contains(part) { + parts.push(*part); + } + } + // Always in the order of PARTS, whatever order they were asked in. + PARTS.into_iter().filter(|p| parts.contains(p)).collect() + } + Some(_) => return Err("parts is a list of names".into()), + }; + let announce = match request.get("announce") { + None | Some(Value::Null) => true, + Some(Value::Bool(b)) => *b, + Some(_) => return Err("announce is true or false".into()), + }; + let place = match request.get("place") { + None | Some(Value::Null) => true, + Some(Value::Bool(b)) => *b, + Some(_) => return Err("place is true or false".into()), + }; + let arrival = read_arrival(request)?; + if !arrival.is_empty() && !parts.contains(&"position") { + return Err( + "position, rotation, on_ground and velocity belong to the position part, which is not imported" + .into(), + ); + } + Ok(Request { + record, + parts, + announce, + place, + arrival, + }) +} + +/// Reads the player compound and the hand-over id out of a record, after checking the record +/// is one this server can take for `uuid`. +fn read_compound(record: &Value, uuid: u128) -> Result<(Tag, u128), String> { + match record.get("version").and_then(Value::as_i64) { + Some(super::arrivals::RECORD_VERSION) => {} + Some(other) => return Err(format!("record version {other} is not understood")), + None => return Err("the record has no version".into()), + } + let theirs = record.get("data_version").and_then(Value::as_i64); + if theirs != Some(i64::from(horizon_world::DATA_VERSION)) { + return Err(format!( + "the record is of data version {theirs:?}, this server's is {}", + horizon_world::DATA_VERSION + )); + } + let named = record.get("uuid").and_then(Value::as_str); + if named.and_then(super::arrivals::parse_uuid) != Some(uuid) { + return Err("the record is another player's".into()); + } + let Some(handover) = record + .get("handover") + .and_then(Value::as_str) + .and_then(super::arrivals::parse_handover) + else { + return Err("the record has no hand-over id: export it with freeze".into()); + }; + let text = record + .get("player") + .and_then(Value::as_str) + .ok_or("the record has no player compound")?; + let bytes = crate::skins::base64_decode(text).ok_or("the player compound is not base64")?; + let (named, _) = read_named(&bytes, Limits::saved()) + .map_err(|e| format!("the player compound is not NBT: {e}"))?; + match named.tag { + tag @ Tag::Compound(_) => Ok((tag, handover)), + _ => Err("the player compound is not a compound".into()), + } +} + +/// The farthest a position may be from the origin on x and z, and on y: what vanilla's +/// `Entity.load` clamps a saved position to (`Mth.clamp(x, -3.0000512E7, 3.0000512E7)`, y +/// to +-2.0E7). +const MAX_XZ: f64 = 3.000_051_2e7; +const MAX_Y: f64 = 2.0e7; + +/// Refuses a compound whose values no player can have. `Entity.load` throws on a position or +/// rotation that is not finite; a record comes from another machine, so it is checked rather +/// than clamped, and a health of zero or less (a dead player) is refused too. +fn check_values(level: &Level) -> Result<(), String> { + let finite = |what: &str, values: &[f64]| { + if values.iter().all(|v| v.is_finite()) { + Ok(()) + } else { + Err(format!("the record's {what} is not a number")) + } + }; + if let Some(p) = level.position { + finite("position", &p)?; + if p[0].abs() > MAX_XZ || p[2].abs() > MAX_XZ || p[1].abs() > MAX_Y { + return Err("the record's position is out of the world".into()); + } + } + finite( + "rotation", + &[f64::from(level.rotation[0]), f64::from(level.rotation[1])], + )?; + finite("motion", &level.motion)?; + finite( + "health", + &[ + f64::from(level.health), + f64::from(level.absorption), + f64::from(level.saturation), + f64::from(level.xp_progress), + level.fall_distance, + ], + )?; + if level.health <= 0.0 { + return Err("the record's player is dead".into()); + } + // The bounds vanilla's own data keeps (26.3's bytecode): `max_health` is at most 1024 + // (`Attributes.MAX_HEALTH`) and absorption at most 2048 (`MAX_ABSORPTION`), an effect's + // amplifier is 0 to 255 (`MobEffectInstance` clamps it), and food exhaustion is at most 40 + // (`FoodData.addExhaustion`). A stack's count needs nothing here: `item_from_nbt` takes one out of + // `ItemStack`'s `intRange(1, 99)` as 1. + if level.health > 1024.0 || !(0.0..=2048.0).contains(&level.absorption) { + return Err("the record's health is past what a player can have".into()); + } + if !(0.0..=40.0).contains(&level.food_exhaustion) { + return Err("the record's food exhaustion is past what a player can have".into()); + } + if level + .active_effects + .iter() + .any(|(_, _, amplifier, _, _)| !(0..=255).contains(amplifier)) + { + return Err("the record holds an effect no player can have".into()); + } + Ok(()) +} + +/// What the held player has now, for the parts that are not imported. +struct Kept { + inventory: Option<( + Vec>, + [Option; 4], + Option, + usize, + )>, + ender: Option>>, + experience: Option<(i32, f32, i32, i32)>, + health: Option<(f32, Option, i32, f32)>, + effects: Option, + game_mode: Option<(i32, bool, f32)>, + position: Option<([f64; 3], [f64; 3], f32, f32, f32, bool, Option<[i32; 4]>)>, + spawn: Option<(Option<[f64; 3]>, Option<(String, [i32; 3], f32, bool)>)>, + recipes: Option, + /// The body's values: its fall goes with the position, its air and fire with the + /// health. + body: Option, + other: Option<( + Option, + Option<(String, [i32; 3])>, + bool, + Vec, + horizon_world::attachments::Attachments, + )>, + dimension: String, +} + +impl Kept { + fn of(g: &Guest, parts: &[&str]) -> Self { + let has = |p: &str| parts.contains(&p); + let all = parts.len() == PARTS.len(); + Kept { + inventory: (!has("inventory")).then(|| { + ( + g.inventory.clone(), + g.armor.clone(), + g.offhand.clone(), + g.selected, + ) + }), + ender: (!has("ender_chest")).then(|| g.ender_items.clone()), + experience: (!has("experience")).then_some(( + g.xp_level, + g.xp_progress, + g.xp_total, + g.xp_seed, + )), + health: (!has("health")).then_some(( + g.health, + g.saved_absorption, + g.food, + g.saturation, + )), + effects: (!has("effects")).then(|| g.effects.clone()), + game_mode: (!has("game_mode")).then_some((g.game_mode, g.flying, g.flying_speed)), + position: (!has("position")).then_some(( + g.position, + g.previous_position, + g.yaw, + g.pitch, + g.head_yaw, + g.on_ground, + g.saved_vehicle, + )), + spawn: (!has("spawn_point")).then(|| (g.spawn, g.respawn_elsewhere.clone())), + recipes: (!has("recipe_book")).then(|| g.recipe_book.clone()), + body: g.loaded_body, + other: (!all).then(|| { + ( + g.saved.clone(), + g.last_death.clone(), + g.seen_credits, + g.post_effects.clone(), + g.attachments.clone(), + ) + }), + dimension: g.dimension.clone(), + } + } + + /// Puts back what the import must not have changed. + fn restore(self, g: &mut Guest, parts: &[&str]) { + let has = |p: &str| parts.contains(&p); + if let Some((inventory, armor, offhand, selected)) = self.inventory { + (g.inventory, g.armor, g.offhand, g.selected) = (inventory, armor, offhand, selected); + } + if let Some(ender) = self.ender { + g.ender_items = ender; + } + if let Some((level, progress, total, seed)) = self.experience { + (g.xp_level, g.xp_progress, g.xp_total, g.xp_seed) = (level, progress, total, seed); + } + if let Some((health, absorption, food, saturation)) = self.health { + (g.health, g.saved_absorption, g.food, g.saturation) = + (health, absorption, food, saturation); + } + if let Some(effects) = self.effects { + g.effects = effects; + } + if let Some((mode, flying, speed)) = self.game_mode { + (g.game_mode, g.flying, g.flying_speed) = (mode, flying, speed); + } + if let Some((position, previous, yaw, pitch, head, ground, vehicle)) = self.position { + (g.position, g.previous_position) = (position, previous); + (g.yaw, g.pitch, g.head_yaw, g.on_ground) = (yaw, pitch, head, ground); + // The vehicle the player rode goes with where it stands. + g.saved_vehicle = vehicle; + } + if let Some((spawn, elsewhere)) = self.spawn { + (g.spawn, g.respawn_elsewhere) = (spawn, elsewhere); + } + if let Some(recipes) = self.recipes { + g.recipe_book = recipes; + } + // The body mixes the two: its fall and motion go with the position, its air, fire, + // freezing and food exhaustion and timer with the health. What a part does not take + // is put back value by value. + match (self.body, g.loaded_body.as_mut()) { + (Some(mine), Some(imported)) => { + if !has("position") { + imported.fall_distance = mine.fall_distance; + imported.velocity = mine.velocity; + } + if !has("health") { + imported.fire_ticks = mine.fire_ticks; + imported.air = mine.air; + imported.ticks_frozen = mine.ticks_frozen; + imported.food_exhaustion = mine.food_exhaustion; + imported.food_tick_timer = mine.food_tick_timer; + } + } + (Some(mine), None) => g.loaded_body = Some(mine), + (None, _) => {} + } + if let Some((saved, death, credits, post, attachments)) = self.other { + ( + g.saved, + g.last_death, + g.seen_credits, + g.post_effects, + g.attachments, + ) = (saved, death, credits, post, attachments); + } + // The client is in one dimension, and only a login can change it. + g.dimension = self.dimension; + } +} + +/// Applies the request to the held player `uuid` and tells its client, leaving it held +/// (`Hub::confirm_arrival` places it). The caller gives the player its advancements and +/// statistics (`Imported::advancements`, `Imported::stats`) and its attachments. +/// +/// Nothing changes when it refuses: the record is read and checked first, and the +/// guest is touched only once everything is known to be well. +pub fn import_player(hub: &mut Hub, uuid: u128, request: &Value) -> Result { + let request = read_request(request)?; + let (tag, handover) = read_compound(request.record, uuid)?; + let Some(i) = hub + .guests + .iter() + .position(|g| g.uuid == uuid && g.held && !g.placed) + else { + return Err("no player with that unique id is held".into()); + }; + if !hub.guests[i].left_reason.is_empty() { + return Err("that player is leaving".into()); + } + if hub.guests[i] + .arrival_handover + .is_some_and(|theirs| theirs != handover) + { + return Err("that player's record of another hand-over was imported".into()); + } + let mut level = Level::default(); + level.read_player(&tag); + check_values(&level)?; + // A record with a vehicle still to restore (`RootVehicle`) puts the player back on it, at + // the vehicle's own saved place: a position named by the request would be undone as soon + // as the vehicle is restored, so the two cannot be taken together. + if request.arrival.position.is_some() + && (level.root_vehicle.is_some() || tag.get("RootVehicle").is_some()) + { + return Err( + "the record has a vehicle to restore (RootVehicle): a position cannot be named with it" + .into(), + ); + } + // What `playerdata::load` gives `apply` too: the whole compound, for the keys Horizon + // does not model, the mods' attachments and the body's values. + level.player_extra = Some(tag); + // A position in another dimension than the client's cannot be taken: the client + // would have to change its world, and only a login does that. + if request.parts.contains(&"position") { + let theirs = playerdata_dimension(&level); + if theirs != hub.guests[i].dimension { + return Err(format!( + "the record is in {theirs}, the held player in {}: the position cannot be taken", + hub.guests[i].dimension + )); + } + } + let kept = Kept::of(&hub.guests[i], &request.parts); + // `apply` fills the slots a record holds and leaves the others as they are, which is right + // for a player loaded into an empty guest and wrong here: the held player holds what its + // own file gave it on this server, and an imported inventory REPLACES that, a slot the + // record leaves empty being empty afterwards. + { + let g = &mut hub.guests[i]; + if request.parts.contains(&"inventory") { + g.inventory.iter_mut().for_each(|slot| *slot = None); + g.armor = Default::default(); + g.offhand = None; + } + if request.parts.contains(&"ender_chest") { + g.ender_items.iter_mut().for_each(|slot| *slot = None); + } + } + playerdata::apply(&mut hub.guests[i], &level); + kept.restore(&mut hub.guests[i], &request.parts); + // What the request says of where the player stands wins over the record. + { + let g = &mut hub.guests[i]; + let a = &request.arrival; + if let Some(p) = a.position { + g.position = p; + g.previous_position = p; + } + if let Some([yaw, pitch]) = a.rotation { + g.yaw = yaw; + g.pitch = pitch; + g.head_yaw = yaw; + } + if let Some(ground) = a.on_ground { + g.on_ground = ground; + } + if let Some(velocity) = a.velocity + && let Some(body) = g.loaded_body.as_mut() + { + body.velocity = velocity; + } + } + // Everything the import changed reaches the client now, so that a proxy sees the + // player as it will be; the player stays held until it is placed or dropped. + let recentered = hub.send_arrival_state(i); + let g = &mut hub.guests[i]; + // A retry of this hand-over's import finds the view already moved: it still answers that + // the chunks around the new place are on their way, as the first reply did. + let recentered = recentered || (g.arrival_handover == Some(handover) && g.arrival_recentered); + g.arrival_recentered = recentered; + g.imported = true; + g.arrival_announce = request.announce; + g.arrival_handover = Some(handover); + let records = |key: &str| { + request + .parts + .contains(&"advancements") + .then(|| request.record.get(key).cloned()) + .flatten() + .filter(|a| !a.is_null()) + }; + Ok(Imported { + advancements: records("advancements"), + stats: records("stats"), + attachments: request.parts.len() == PARTS.len(), + parts: request.parts, + announce: request.announce, + place: request.place, + handover, + recentered, + }) +} + +/// The dimension a record names, the way `playerdata::apply` places it. +fn playerdata_dimension(level: &Level) -> String { + match level.dimension.as_str() { + d @ ("minecraft:the_nether" | "minecraft:the_end") => d.to_owned(), + _ => "minecraft:overworld".to_owned(), + } +} diff --git a/crates/horizon-client/src/lan/mod.rs b/crates/horizon-client/src/lan/mod.rs index bcb49c1b94..9aca58fc56 100644 --- a/crates/horizon-client/src/lan/mod.rs +++ b/crates/horizon-client/src/lan/mod.rs @@ -25,6 +25,7 @@ pub mod forwarding; pub mod guest; pub mod handoff; pub mod hub; +pub mod import; mod io_pool; pub mod menu; pub mod playerdata; diff --git a/crates/horizon-client/src/lan/playerdata.rs b/crates/horizon-client/src/lan/playerdata.rs index 8360170ef1..531b6b4329 100644 --- a/crates/horizon-client/src/lan/playerdata.rs +++ b/crates/horizon-client/src/lan/playerdata.rs @@ -225,14 +225,20 @@ pub fn player_tag(guest: &Guest) -> Tag { /// Save a leaving guest, including its saved vehicle tree. pub fn save(guest: &Guest, world: &Path) { - if let Err(e) = write_dat(&path(world, guest.uuid), player_tag(guest)) { - log::warn!( - "Horizon: {} is not saved: {e}", - path(world, guest.uuid).display() - ); + let file = path(world, guest.uuid); + if let Err(e) = write_dat(&file, player_tag(guest)) { + log::warn!("Horizon: {} is not saved: {e}", file.display()); } } +/// [`save`], synced to the disk and saying whether the file was written: a hand-over's commit +/// point (`player-confirm`) answers only once it is. +pub fn save_now(guest: &Guest, world: &Path) -> Result<(), String> { + let file = path(world, guest.uuid); + crate::save::write_dat_durable(&file, player_tag(guest)) + .map_err(|e| format!("{} is not saved: {e}", file.display())) +} + /// An immutable player snapshot. Compression and all filesystem work belong to /// the writer; native callbacks are delivered by the server after completion. struct Snapshot { diff --git a/crates/horizon-client/src/mod_content.rs b/crates/horizon-client/src/mod_content.rs index 14ddafbd21..b68f8cee62 100644 --- a/crates/horizon-client/src/mod_content.rs +++ b/crates/horizon-client/src/mod_content.rs @@ -2703,7 +2703,10 @@ pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result } /// The root to load: `base` itself with no content, else the mods' overlay -/// over it, built in `cache/mod-content`. +/// over it, built in `cache/mod-content/`: one folder per mod set, so +/// a running game keeps reading the root it was given while a switch builds +/// another (docs/design/mod-hotload.md). Roots of other sets unused for a day +/// are removed. /// /// The root is built once and kept: later starts with the same build, base /// and mod files reuse it. With ATM10's carried mods a build takes about @@ -2715,21 +2718,106 @@ pub fn resource_root(base: &Path, mods: &[ContentMod], cache: &Path) -> Result

fs::metadata(path.join(STAMP)) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.elapsed().ok()) + .is_none_or(|age| age > keep), + // The old single root's files and folders. + false => old_layout, + }; + if !stale { + continue; + } + let removed = match path.is_dir() { + true => fs::remove_dir_all(&path), + false => fs::remove_file(&path), + }; + if let Err(e) = removed { + log::warn!( + "Horizon: an old mod content root stays: {}: {e}", + path.display() + ); + } + } +} + +/// Whether turning a content mod on or off can apply in a running game +/// (docs/design/mod-hotload.md): its assets and data can, through the +/// resource reload and the server's data reload. What a world numbers or +/// generates from cannot yet: then the translation key of why. +pub fn live_switch(content: &Content) -> std::result::Result<(), &'static str> { + if !content.blocks.is_empty() + || !content.items.is_empty() + || !content.effects.is_empty() + || !content.entity_types.is_empty() + || !content.menu_types.is_empty() + || !content.creative_tabs.is_empty() + { + return Err("horizon.mods.switch.registries"); + } + let worldgen = |file: &&String| { + let mut parts = file.split('/'); + parts.next() == Some("data") + && parts.nth(1).is_some_and(|folder| { + matches!(folder, "worldgen" | "dimension" | "dimension_type") + || folder == "tags" && file.contains("/tags/worldgen/") + || matches!(folder, "neoforge" | "forge") && file.contains("/biome_modifier/") + }) + }; + if content.files.iter().any(|f| worldgen(&f)) { + return Err("horizon.mods.switch.worldgen"); + } + Ok(()) +} + /// In a root built by [`build`]: its warnings, one per line. const WARNINGS: &str = "horizon-mod-content-warnings.txt"; diff --git a/crates/horizon-client/src/mod_content_tests.rs b/crates/horizon-client/src/mod_content_tests.rs index dee9905b55..e168c540be 100644 --- a/crates/horizon-client/src/mod_content_tests.rs +++ b/crates/horizon-client/src/mod_content_tests.rs @@ -991,12 +991,19 @@ fn content_root_is_reused_until_a_mod_changes() { let file = fs::File::options().write(true).open(&lang).unwrap(); file.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(5)) .unwrap(); - resource_root(&base, std::slice::from_ref(&m), &cache).unwrap(); - assert!(!sentinel.exists(), "a changed mod kept the old root"); + let rebuilt = resource_root(&base, std::slice::from_ref(&m), &cache).unwrap(); + assert_ne!(rebuilt, root, "a changed mod reused the old root"); + assert!( + fs::read_to_string(rebuilt.join("assets/ruby/lang/en_us.json")) + .unwrap() + .contains("Ruby Block") + ); + // The root a running game was given is never changed under it. + assert!(sentinel.exists(), "a changed mod rebuilt the root in place"); assert!( fs::read_to_string(root.join("assets/ruby/lang/en_us.json")) .unwrap() - .contains("Ruby Block") + .contains("Block of Ruby") ); let _ = fs::remove_dir_all(&scratch); } @@ -3970,10 +3977,14 @@ fn blocks_and_items_declared_in_code_get_ids() { resource_root(&base, &mods, &scratch.join("cache")).unwrap(), root ); + // Changed declarations build a root of their own: the old one stays as + // it was, for a switch back to that set. let stamp = fs::read(root.join(STAMP)).unwrap(); mods[1].content.items[0].max_stack_size = Some(2); - resource_root(&base, &mods, &scratch.join("cache")).unwrap(); - assert_ne!(fs::read(root.join(STAMP)).unwrap(), stamp); + let changed = resource_root(&base, &mods, &scratch.join("cache")).unwrap(); + assert_ne!(changed, root); + assert_ne!(fs::read(changed.join(STAMP)).unwrap(), stamp); + assert_eq!(fs::read(root.join(STAMP)).unwrap(), stamp); let _ = fs::remove_dir_all(&scratch); } @@ -4374,3 +4385,192 @@ fn a_symlink_file_is_copied_through() { assert_eq!(fs::read(&at).unwrap(), b"real"); let _ = fs::remove_dir_all(&scratch); } + +/// A content mod of recipes, a tag and a name, for vanilla's items: what +/// switches live (docs/design/mod-hotload.md). +fn glint_mod(dir: &Path) -> ContentMod { + folder_mod( + dir, + &[ + ("horizon-mod.toml", &manifest("glint", "")), + ( + "data/glint/recipe/diamond_from_dirt.json", + r#"{"type": "minecraft:crafting_shapeless", "category": "misc", + "ingredients": ["minecraft:dirt", "minecraft:dirt"], + "result": {"id": "minecraft:diamond"}}"#, + ), + ( + "data/c/tags/item/gems/glint.json", + r#"{"values": ["minecraft:diamond"]}"#, + ), + ( + "assets/glint/lang/en_us.json", + r#"{"glint.hello": "Hello"}"#, + ), + ], + ) +} + +#[test] +fn only_assets_and_data_switch_live() { + let scratch = std::env::temp_dir().join(format!("horizon-mod-live-{}", std::process::id())); + let _ = fs::remove_dir_all(&scratch); + assert_eq!( + live_switch(&glint_mod(&scratch.join("glint")).content), + Ok(()) + ); + let ruby = ruby_mod(&scratch.join("ruby")); + assert_eq!( + live_switch(&ruby.content), + Err("horizon.mods.switch.registries") + ); + let effect = folder_mod( + &scratch.join("fx"), + &[ + ("horizon-mod.toml", &manifest("fx", "")), + ( + "data/fx/horizon/mob_effect/glow.json", + r#"{"category": "harmful", "color": 1}"#, + ), + ], + ); + assert_eq!( + live_switch(&effect.content), + Err("horizon.mods.switch.registries") + ); + for (id, file, text) in [ + ( + "ores", + "data/ores/neoforge/biome_modifier/add.json", + r#"{"type": "neoforge:none"}"#, + ), + ( + "village", + "data/minecraft/tags/worldgen/biome/has_structure/village_plains.json", + r#"{"values": []}"#, + ), + ( + "feat", + "data/feat/worldgen/placed_feature/rock.json", + r#"{"feature": "minecraft:ore_iron", "placement": []}"#, + ), + ] { + let m = folder_mod( + &scratch.join(id), + &[("horizon-mod.toml", &manifest(id, "")), (file, text)], + ); + assert_eq!( + live_switch(&m.content), + Err("horizon.mods.switch.worldgen"), + "{file}" + ); + } + fs::remove_dir_all(&scratch).unwrap(); +} + +/// On, off and on again: each root has what its set of mods has, a switch +/// never changes a root a game was given, and a set seen before reuses its +/// root without building it again. +#[test] +#[ignore = "full content root: CI and pr_ready run it"] +fn a_data_mod_loads_unloads_and_loads_again() { + let Some(base) = prepared() else { + return; + }; + let scratch = std::env::temp_dir().join(format!("horizon-mod-reload-{}", std::process::id())); + let _ = fs::remove_dir_all(&scratch); + let cache = scratch.join("cache"); + let glint = glint_mod(&scratch.join("glint")); + let dirt = || Some(crate::items::ItemStack::new("minecraft:dirt", 1)); + let grid = [dirt(), dirt(), None, None]; + // What a world reads from a root: the item tags and the recipes. + let tables = |root: &Path| { + let assets = Assets::load(root).unwrap(); + let facts = BlockFacts::load(root, assets.registry).unwrap(); + let items = Items::load(root, assets.registry, &facts, &PackOverlay::NONE).unwrap(); + let recipes = crate::crafting::Recipes::load(root, &items, &PackOverlay::NONE).unwrap(); + let crafted = recipes.find(&grid, 2).map(|(_, out)| out.item.to_string()); + let tagged = items.item_tag_order.get("c:gems/glint").cloned(); + ( + crafted, + tagged, + root.join("assets/glint/lang/en_us.json").is_file(), + ) + }; + let loaded = ( + Some("minecraft:diamond".to_owned()), + Some(vec!["minecraft:diamond".to_owned()]), + true, + ); + + let started = std::time::Instant::now(); + let on = resource_root(&base, std::slice::from_ref(&glint), &cache).unwrap(); + eprintln!("content root built in {} ms", started.elapsed().as_millis()); + assert_ne!(on, base); + assert_eq!(tables(&on), loaded); + + // Off: the base root, without any of it. + let off = resource_root(&base, &[], &cache).unwrap(); + assert_eq!(off, base); + assert_eq!(tables(&off), (None, None, false)); + assert!(on.join("assets/glint/lang/en_us.json").is_file()); + + // On again: the same root, not built again. + let sentinel = on.join("sentinel"); + fs::write(&sentinel, b"").unwrap(); + let started = std::time::Instant::now(); + let again = resource_root(&base, std::slice::from_ref(&glint), &cache).unwrap(); + eprintln!( + "content root reused in {} ms", + started.elapsed().as_millis() + ); + assert_eq!(again, on); + assert!(sentinel.exists()); + assert_eq!(tables(&again), loaded); + + // Another set of mods builds its own root beside it and leaves this one as it was. + let ruby = ruby_mod(&scratch.join("ruby")); + let package = horizon_mods::Package::read(&scratch.join("glint")).unwrap(); + let glint_again = ContentMod { + id: "glint".into(), + content: Content::read(&scratch.join("glint"), &package.manifest).unwrap(), + }; + let both = resource_root(&base, &[glint_again, ruby], &cache).unwrap(); + assert_ne!(both, on); + assert!(sentinel.exists()); + assert_eq!(tables(&both).0, loaded.0); + fs::remove_dir_all(&scratch).unwrap(); +} + +#[test] +fn roots_unused_for_a_day_are_removed() { + let scratch = std::env::temp_dir().join(format!("horizon-mod-prune-{}", std::process::id())); + let _ = fs::remove_dir_all(&scratch); + let roots = scratch.join("mod-content"); + let root = |name: &str, age_s: u64| { + let dir = roots.join(name); + fs::create_dir_all(&dir).unwrap(); + let stamp = dir.join(STAMP); + fs::write(&stamp, b"x").unwrap(); + let file = fs::File::options().write(true).open(&stamp).unwrap(); + file.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(age_s)) + .unwrap(); + dir + }; + let current = root("00000000000000aa", 3 * 86400); + let recent = root("00000000000000bb", 60); + let stale = root("00000000000000cc", 2 * 86400); + // Not a root's name: never touched while the folder has no root of the old layout. + let other = roots.join("notes"); + fs::create_dir_all(&other).unwrap(); + prune_roots(&roots, ¤t, KEEP_ROOTS); + assert!(current.exists() && recent.exists() && other.exists()); + assert!(!stale.exists()); + // An older game's single root, built straight in the folder, goes. + fs::write(roots.join(STAMP), b"old").unwrap(); + fs::create_dir_all(roots.join("assets/minecraft")).unwrap(); + prune_roots(&roots, ¤t, KEEP_ROOTS); + assert!(!roots.join(STAMP).exists() && !roots.join("assets").exists()); + assert!(current.exists() && recent.exists()); + fs::remove_dir_all(&scratch).unwrap(); +} diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index 59bb6ec057..61bbe61301 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -3125,6 +3125,50 @@ fn back_up_unreadable_player(path: &Path) { } /// Write a gzip NBT file the way vanilla does (`_new`, keep `_old`). pub fn write_dat(path: &Path, tag: Tag) -> Result<()> { + write_dat_as(path, tag, false) +} + +/// [`write_dat`], synced to the disk: the new file before it is renamed into place, and the +/// folder after, so a power loss leaves the old file or the new one. A hand-over's commit +/// point (`player-confirm`) replies only once its files are. +pub fn write_dat_durable(path: &Path, tag: Tag) -> Result<()> { + write_dat_as(path, tag, true) +} + +/// `bytes` written to `path` through `_new` and a rename, synced to the disk as +/// [`write_dat_durable`] syncs: never a half-written file in place. +pub fn write_durable(path: &Path, bytes: &[u8]) -> Result<()> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir)?; + } + let file = path.file_name().and_then(|n| n.to_str()).unwrap_or("data"); + let temporary = path.with_file_name(format!("{file}_new")); + write_synced(&temporary, bytes)?; + fs::rename(&temporary, path)?; + sync_folder(path)?; + Ok(()) +} + +fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + let mut file = fs::File::create(path)?; + file.write_all(bytes)?; + file.sync_all() +} + +/// The folder of `path` synced, so that a rename into it is on the disk (POSIX; Windows has +/// no folder handle to sync, and its rename is journaled by NTFS). +fn sync_folder(path: &Path) -> std::io::Result<()> { + #[cfg(unix)] + if let Some(dir) = path.parent() { + fs::File::open(dir)?.sync_all()?; + } + #[cfg(not(unix))] + let _ = path; + Ok(()) +} + +fn write_dat_as(path: &Path, tag: Tag, durable: bool) -> Result<()> { if let Some(dir) = path.parent() { fs::create_dir_all(dir)?; } @@ -3142,7 +3186,11 @@ pub fn write_dat(path: &Path, tag: Tag) -> Result<()> { .and_then(|n| n.to_str()) .unwrap_or("data.dat"); let temporary = path.with_file_name(format!("{file}_new")); - fs::write(&temporary, compressed)?; + if durable { + write_synced(&temporary, &compressed)?; + } else { + fs::write(&temporary, compressed)?; + } if path.exists() { let old = match file.strip_suffix(".dat") { Some(stem) => path.with_file_name(format!("{stem}.dat_old")), @@ -3152,6 +3200,9 @@ pub fn write_dat(path: &Path, tag: Tag) -> Result<()> { fs::rename(path, old).map_err(|e| format!("{}: {e}", path.display()))?; } fs::rename(temporary, path)?; + if durable { + sync_folder(path)?; + } Ok(()) } /// `SavedDataStorage`: `{data: ..., DataVersion}` under `data/minecraft/`. diff --git a/crates/horizon-client/tests/wide_states.rs b/crates/horizon-client/tests/wide_states.rs index f65fa52ceb..2e24daa717 100644 --- a/crates/horizon-client/tests/wide_states.rs +++ b/crates/horizon-client/tests/wide_states.rs @@ -3,7 +3,10 @@ //! since the palette width is set once for the whole process. extern crate horizon_client_lib as horizon_client; -use horizon_client::chunk::{Chunk, Section, block_strategy, set_block_states}; +use horizon_client::chunk::{ + Chunk, Section, block_strategy, known_block_state, known_blocks_only, set_block_states, +}; +use horizon_core::paletted::{PalettedContainer, Strategy, storage_len}; #[test] fn sections_hold_states_past_sixteen_bits() { @@ -38,4 +41,39 @@ fn sections_hold_states_past_sixteen_bits() { section.set(17, 99_998); assert_eq!(section.get(17), 99_998); assert_eq!(section.get(18), values[18]); + + // What a server with mods we lack sends: ids past the registry read as air, + // though they fit the width. 100,000 states: 99,999 is the last one. + assert!(known_block_state(99_999)); + assert!(!known_block_state(100_000)); + let mut sent = [1u32; 4096]; + sent[0] = 100_000; + sent[1] = 131_071; + let known = known_blocks_only(PalettedContainer::from_values(block_strategy(), &sent)); + assert_eq!((known.get(0), known.get(1), known.get(2)), (0, 0, 1)); + // The same in a global palette, with more ids than a section's own holds. + let mut wide: Vec = (0..4096).map(|i| 1000 + i).collect(); + wide[5] = 120_000; + let known = known_blocks_only(PalettedContainer::from_values(block_strategy(), &wide)); + assert_eq!((known.get(5), known.get(6)), (0, 1006)); + // A palette entry no position uses doesn't stay either. + let words = vec![0; storage_len(4, 4096)]; + let container = + PalettedContainer::from_wire(block_strategy(), 4, vec![1, 100_000], words).unwrap(); + assert!(container.maybe_has(|id| id == 100_000)); + let known = known_blocks_only(container); + assert!(!known.maybe_has(|id| id == 100_000)); + assert_eq!(known.get(0), 1); + // A host wider than us: narrowed, and what doesn't fit is air. + let wider = Strategy::blocks(20); + let mut sent = [7u32; 4096]; + sent[3] = 900_000; + let known = known_blocks_only(PalettedContainer::from_values(wider, &sent)); + assert_eq!(known.strategy().global_bits, block_strategy().global_bits); + assert_eq!((known.get(3), known.get(4)), (0, 7)); + // Within vanilla's width too: with vanilla's registry, 40,000 is unknown. + set_block_states(35_723); + assert!(!known_block_state(35_723)); + let known = known_blocks_only(PalettedContainer::new(block_strategy(), 40_000)); + assert_eq!(known.get(0), 0); } diff --git a/crates/horizon-items/src/blocks.rs b/crates/horizon-items/src/blocks.rs index 097b5d76c4..53b1e7d177 100644 --- a/crates/horizon-items/src/blocks.rs +++ b/crates/horizon-items/src/blocks.rs @@ -512,9 +512,13 @@ impl BlockFacts { extra, )) } + /// A state the registry doesn't have (a server with mods we lack sent it) + /// has air's facts. #[inline] pub fn get(&self, id: BlockStateId) -> &StateFacts { - &self.states[id.0 as usize] + self.states + .get(id.0 as usize) + .unwrap_or_else(|| &self.states[self.air.0 as usize]) } /// `SignBlock.getWoodType`'s name for a sign block. pub fn wood_type(&self, block: &str) -> Option<&str> { @@ -522,7 +526,7 @@ impl BlockFacts { } pub fn is_air(&self, id: BlockStateId) -> bool { matches!( - &*self.states[id.0 as usize].name, + &*self.get(id).name, "minecraft:air" | "minecraft:cave_air" | "minecraft:void_air" ) } diff --git a/crates/horizon-items/src/blocks/decode_tests.rs b/crates/horizon-items/src/blocks/decode_tests.rs index c222e26bd0..a30cd6dc53 100644 --- a/crates/horizon-items/src/blocks/decode_tests.rs +++ b/crates/horizon-items/src/blocks/decode_tests.rs @@ -164,3 +164,34 @@ fn the_validation_error_still_fails_the_load_first() { } std::fs::remove_dir_all(&root).unwrap(); } + +#[test] +fn a_state_past_the_registry_has_airs_facts() { + let registry = horizon_world::registry::BlockRegistry::from_facts_json( + &br#"{"states":[["minecraft:air",true,false,"","minecraft:air"],["minecraft:stone",true,false,"","minecraft:stone"]],"defaults":{"minecraft:air":0,"minecraft:stone":1}}"#[..], + ) + .unwrap(); + let root = std::env::temp_dir().join(format!("horizon-facts-unknown-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join("gameplay-facts.json"), + br#"{"states":[{"id":0},{"id":1}]}"#, + ) + .unwrap(); + let behaviours = serde_json::json!({ + "states": [[0, 0], [0, 0]], + "blocks": [ + {"name": "minecraft:air", "class": ["Block"]}, + {"name": "minecraft:stone", "class": ["Block"]}, + ], + }); + let facts = + super::super::BlockFacts::load_with_behaviour_json(&root, ®istry, &behaviours).unwrap(); + std::fs::remove_dir_all(&root).unwrap(); + + let id = horizon_world::registry::BlockStateId; + assert_eq!(&*facts.get(id(1)).name, "minecraft:stone"); + assert!(!facts.is_air(id(1))); + assert_eq!(&*facts.get(id(2)).name, "minecraft:air"); + assert!(facts.is_air(id(2))); +} diff --git a/crates/horizon-prepare/src/java.rs b/crates/horizon-prepare/src/java.rs index 1279cc6c4c..0b50245a6c 100644 --- a/crates/horizon-prepare/src/java.rs +++ b/crates/horizon-prepare/src/java.rs @@ -22,9 +22,18 @@ const TIMEOUT: Duration = Duration::from_secs(3600); /// 1.25 GB instead of 2 GB. pub(crate) const BATCH_OPTIONS: [&str; 2] = ["-XX:TieredStopAtLevel=1", "-XX:+UseSerialGC"]; -/// Exporters and JDK probes write to pipes or log files: on Windows they open no terminal. +/// The variables a JVM reads extra options from. A collector chosen there, such as a player's +/// global `-XX:+UseZGC`, stops a JVM started with [`BATCH_OPTIONS`]: "Multiple garbage collectors +/// selected" (#10067). +const JVM_OPTION_VARS: [&str; 3] = ["JAVA_TOOL_OPTIONS", "_JAVA_OPTIONS", "JDK_JAVA_OPTIONS"]; + +/// Exporters and JDK probes write to pipes or log files: on Windows they open no terminal. They +/// run with [`BATCH_OPTIONS`] alone, whatever options the player's environment sets for Java. pub(crate) fn command(program: impl AsRef) -> Command { - let command = Command::new(program); + let mut command = Command::new(program); + for var in JVM_OPTION_VARS { + command.env_remove(var); + } #[cfg(windows)] let command = { use std::os::windows::process::CommandExt; @@ -406,6 +415,19 @@ fn run(mut command: Command, log: &fs::File) -> Result<(), String> { mod tests { use super::*; + #[test] + fn jvms_ignore_the_players_global_options() { + let command = command("java"); + for var in JVM_OPTION_VARS { + assert!( + command + .get_envs() + .any(|(name, value)| name == var && value.is_none()), + "{var} reaches the JVM" + ); + } + } + #[test] fn verbatim_drive_paths_lose_their_prefix() { let jdk = r"C:\Program Files\Eclipse Adoptium\jdk-25\bin\javac.exe"; diff --git a/crates/horizon-server/src/config.rs b/crates/horizon-server/src/config.rs index 69c15e2220..0214c12f5c 100644 --- a/crates/horizon-server/src/config.rs +++ b/crates/horizon-server/src/config.rs @@ -71,6 +71,7 @@ expect_seconds = 10 # how long a player the proxy announced is waited f max_expect_seconds = 60 # the longest the proxy may ask for max_expected = 64 # players waited for at once max_freeze_seconds = 30 # the longest a player stays frozen while it is handed over +max_hold_seconds = 60 # the longest a player arriving from another server is held before it is dropped # Post the chat, joins, leaves and deaths to a Discord channel, drawn like the in-game chat (docs/hosting.md) [discord] @@ -313,6 +314,7 @@ pub struct Transfer { pub max_expect_seconds: u32, pub max_expected: u32, pub max_freeze_seconds: u32, + pub max_hold_seconds: u32, } impl Default for Transfer { @@ -323,6 +325,7 @@ impl Default for Transfer { max_expect_seconds: 60, max_expected: 64, max_freeze_seconds: 30, + max_hold_seconds: 60, } } } @@ -334,6 +337,7 @@ impl Transfer { ("max_expect_seconds", self.max_expect_seconds), ("max_expected", self.max_expected), ("max_freeze_seconds", self.max_freeze_seconds), + ("max_hold_seconds", self.max_hold_seconds), ] { if n == 0 { return Err(format!("transfer.{name} must be above zero")); @@ -349,11 +353,12 @@ impl Transfer { pub fn engine_arg(&self) -> Option { self.enabled.then(|| { format!( - "expect_seconds={},max_expect_seconds={},max_expected={},max_freeze_seconds={}", + "expect_seconds={},max_expect_seconds={},max_expected={},max_freeze_seconds={},max_hold_seconds={}", self.expect_seconds, self.max_expect_seconds, self.max_expected, - self.max_freeze_seconds + self.max_freeze_seconds, + self.max_hold_seconds ) }) } @@ -598,6 +603,7 @@ pub fn render(config: &Config) -> String { ("transfer", "max_expect_seconds"), ("transfer", "max_expected"), ("transfer", "max_freeze_seconds"), + ("transfer", "max_hold_seconds"), ] { set(&mut doc, section, field, &table[section][field]); } @@ -758,7 +764,9 @@ mod tests { let config = Config::parse(&on).unwrap(); assert_eq!( config.transfer.engine_arg().as_deref(), - Some("expect_seconds=10,max_expect_seconds=60,max_expected=64,max_freeze_seconds=30") + Some( + "expect_seconds=10,max_expect_seconds=60,max_expected=64,max_freeze_seconds=30,max_hold_seconds=60" + ) ); assert_eq!(Config::parse(&render(&config)).unwrap(), config); for (from, to) in [ diff --git a/crates/horizon-server/testdata/api-v1-config.json b/crates/horizon-server/testdata/api-v1-config.json index 8a9a035fad..b7fa9c3722 100644 --- a/crates/horizon-server/testdata/api-v1-config.json +++ b/crates/horizon-server/testdata/api-v1-config.json @@ -38,7 +38,8 @@ "expect_seconds": 10, "max_expect_seconds": 60, "max_expected": 64, - "max_freeze_seconds": 30 + "max_freeze_seconds": 30, + "max_hold_seconds": 60 }, "updates": {"keep": 1}, "world": { diff --git a/docs/api/server-reference.md b/docs/api/server-reference.md index 329b6f7f9b..186df16698 100644 --- a/docs/api/server-reference.md +++ b/docs/api/server-reference.md @@ -155,6 +155,7 @@ A proxy handing players over between servers (docs/server.md). Not vanilla's acc | `max_expect_seconds` | `60` | | the longest the proxy may ask for | | `max_expected` | `64` | | players waited for at once | | `max_freeze_seconds` | `30` | | the longest a player stays frozen while it is handed over | +| `max_hold_seconds` | `60` | | the longest a player arriving from another server is held before it is dropped | ### `[discord]` diff --git a/docs/design/mod-hotload.md b/docs/design/mod-hotload.md new file mode 100644 index 0000000000..819a688d64 --- /dev/null +++ b/docs/design/mod-hotload.md @@ -0,0 +1,247 @@ +# Mods from inside the game, loaded and unloaded in seconds + +Asked by Ludovic on 2026-11-05: players add mods from inside the game, and turn them on and off in a few seconds, +without a restart wherever one can be avoided. This note says what can change live, what needs a world reload or a +restart, how the in-game browser installs, and which native-mod path gets a code change from "minutes, then +restart" to seconds. It builds on the registry v2 ([registry-v2.md](registry-v2.md)), the content mods of +[mods.md](../mods.md), the native mods of [native-mods.md](../native-mods.md) and the plan in +[#7386](https://github.com/horizon-rust/horizon/issues/7386). + +## Where things stand + +| | Install | Turn on or off | Change | +|---|---|---|---| +| Content mod (`.hmod`) | launcher, or by hand in `horizon-mods/` | Options > Mods, then **restart** | rebuild the `.hmod`, restart | +| Native mod (`.hnmod`) | launcher only | off: at once, if the mod checks `native_on`; on: **rebuild** | **rebuild** (minutes), restart | + +Three facts from the code shape the plan: + +- A pack reload already runs `mods::content_root` with the mods that are on (`stage_pack`), so a switch followed by + a resource reload already swaps the mods' **assets** (models, textures, sounds, lang). Nothing else follows. +- The server's data tables (recipes, tags, loot tables, advancements, functions, data maps) are rebuilt by `/reload` + (`reload_world_tables`), from `options.resources`, and sent to the players (`lan::reload_sync_data`). A mod switch + never reaches them. +- The content root is one folder, `.horizon-cache/mod-content`, rebuilt **in place** when the mod set changes. A + running game whose `Resources` were built from the old root then reads tables of the new one. Today that happens + only between a switch and the restart the page asks for; with live switching it would happen every time. + +## Content mods: what can change live + +A content mod brings four sorts of thing. They differ in who holds them and whether a save names them by number. + +| What | Held by | Saves name them | Live? | +|---|---|---|---| +| Assets: block and item models, textures, sounds, lang, fonts, particles, entity models | the client's `PackResources`, atlas, audio, `lang` | no | **live**: the pack reload (1 to 3 s) | +| Data: recipes, tags, loot tables, advancements, functions, predicates, item modifiers, data maps | the server's `WorldTables` | no | **live**: `/reload`'s path, players resynced | +| Worldgen: biome modifiers, structure set, pool and processor additions, features | the world's generator, built when it opens | no, but chunks already generated keep what was there | **world reload**; only new chunks change | +| Registries: blocks and their states, items, mob effects, entity types, menu types, component types | `Resources` (`BlockFacts`, `Items`), process-wide tables (`MOD_EFFECTS`, `THROWN_TYPES`, mod menu types, `item::custom`) | **yes**: chunk palettes, inventories, effects, entities | **world reload**, later; **restart** today | + +Vanilla draws the same line. `/reload` reloads a server's data packs (`ReloadableServerResources`), and F3+T reloads +the client's resources. A change to the registries a world was created with (a new feature flag, a data pack with +worldgen) goes through the world's **Data Packs** screen and reopens the world. + +### Live: assets and data + +A switch of a mod whose content is only assets and data applies at once, in the world or on the title screen: + +1. **A root per mod set.** `mod_content::resource_root` builds into `mod-content//` instead of one + folder. A running game keeps reading the root it was given, and a switch never changes files under it. Roots + that are not the current one and are older than a day are removed when the game starts, so the cache holds one + root, or two after a switch. +2. **Classify the switch** before applying it: build the new mod list (`accept_mod`, as `content_root` does) on a + worker, and compare what each side **registers** (the ids of blocks and their declared state properties, items, + mob effects, entity types, menu types, and the native component types). Equal registries mean the switch is live. + Otherwise it needs a world reload (below), and the page says so on the mod's row before anything changes. +3. **Client.** The pack reload already builds the new root and swaps the assets. It runs under its progress overlay; + the world keeps drawing. +4. **Server.** The server thread gets the new root (`options.resources`) and runs `reload_world_tables`, as `/reload` + does: tables rebuilt, recipes and tags sent to the players, advancements reloaded. A LAN host's guests and the + window's own client get the same packets they get after `/reload`. +5. **Multiplayer.** On someone else's server, the client never changes data: only assets switch live, and a mod that + adds registries can't be turned on while connected ("Leave the server to turn this mod on"). On a LAN host, a + live switch resyncs the guests; a registry change is refused while guests are connected, as it would disconnect + them. + +What a player keeps: their world, position, inventory and open screen. What they see: the overlay of a resource +reload, then the new textures and recipes. A mod turned off leaves its recipes out of the recipe book at once; its +items stay where they are only if a mod still registers them (otherwise the switch was not live). + +### World reload: registries and worldgen + +Blocks and items have numbers. Chunks store block states as palette entries, inventories store item ids, and the +client meshes sections against `BlockFacts`. Adding a block shifts nothing (mod ids come after vanilla's), but a +running world's tables, caches and connections all hold the old numbering, so a registry change reopens the world: + +1. Save and leave the world (as **Save and Quit**). +2. Build the new `Resources` on a worker (`load_resources` from the new root), as the game does at start. +3. Swap `Game.res` and reload the client's resources. +4. Reopen the same world, at the same place. + +The cost is a world's load, about what the player waits for when opening it from the title screen. Two things keep +this step out of the first PRs: + +- **Process-wide tables are set once.** Mob effects (`potions::MOD_EFFECTS`), thrown entity types + (`projectiles::THROWN_TYPES`), mod menu types, living entity types and native component types + (`horizon_protocol::item::custom::install`) are `OnceLock`s, filled at start and read without a lock on hot + paths. They become `ArcSwap`-style tables replaced between worlds, never while one is open. +- **A mod turned off whose blocks are in the world.** Opening a world without a mod already drops its effects and + keeps its entities frozen ([mods.md](../mods.md)), and its blocks get what opening the world without it does today. The world reload asks + first, with the count of the mod's blocks and items it found in the loaded chunks, and makes a backup (the world's + `backups/` copy) before it reopens without them. + +Until then, a registry change keeps today's rule: the row says "Restart the game to apply", and the game asks +whether to restart now. A restart from the game is the launcher starting it again with `--world `, so the +player lands back in the world. + +### What "a few seconds" means here, measured + +Measured in game on the ThinkPad (i7-7500U, Intel HD 620, Vulkan, `profile.fast`; the desktop can't run the game +over ssh) with the fixture mod of #10217: a recipe, an item tag, two functions and a language file. Four runs, on +2026-11-05: + +| Step | Time | +|---|---| +| Turn on the first time: build its content root, then reload the client's resources | 3.2 to 5.6 s | +| Turn off, or on again (the root is reused) | 1.1 to 1.7 s | +| Then the server's data reload (`reload_datapacks`, as `/reload`), on its own thread | 0.9 to 1.7 s | + +So a switch lands in about 2 to 3 s, and the first time a mod set is used in about 4 to 7 s. The root alone, on +the desktop (i7-13700KF, NVMe, under a load of 30 to 40), in #10217's test: built in 5.1 to 5.4 s, reused in 0 to +19 ms, over three runs. Most of a first switch is building the root; a set used before costs only the reloads. + +## The in-game mod browser + +Options > Mods gets a **Browse mods** button beside **Open mod folder**. It opens Horizon's own menu pages, drawn +with the options screen's widgets (`options_ui`), every string through `Text::tr` keys in +`assets/horizon/lang/*.json`. + +- **The index.** The same file the launcher reads (`horizon-mods` `index.json`, `HORIZON_MOD_INDEX` for a test + registry), fetched on a worker with the game's `ureq` when the page opens (a Pages file, not the API: no rate + limit), kept in `mod-browser/horizon-index.json` beside `options.txt`. The launcher and the game share that copy, + so an offline game still lists what the launcher saw last. Parsed with `horizon_mods::index::Index::parse`, which + refuses an index with a bad entry as the launcher does. +- **The list.** A search box and the projects this game can install, sorted by downloads: name, author, summary, + its categories, and **Installed**, **Update** or nothing. A search matches the name, id, summary, tags and + categories. +- **A project's page.** Name, authors, licence, version and date, categories and tags, the summary and description + (the index's markdown read as plain text: headings, lists and links shown as text), its changelog, and buttons: + **Install** or **Update**, the mod's on and off switch once it is installed, and **Open page** (the site's page in + the browser). **Remove** stays in the launcher for now. Screenshots come in a second step: the registry mirrors + them as PNG and JPEG under its own Pages ([registry-v2.md](registry-v2.md)); the page will draw them through the + same dynamic-texture path as player skins (`skin_slots`), one at a time, loaded when the page opens. +- **Install.** As the launcher's `install_version`: the newest version for this game that was not taken down, into + `mod-browser/downloads/`, then `horizon_mods::index::check_file` (size, SHA-256, the author's signature, the + file's manifest against the index), then moved to `horizon-mods/.hmod`, turned on in `horizon-mods.txt`, and + recorded in `mod-browser.json` with the launcher's own fields, so the launcher lists it, updates it and removes + it. A file the index took down (`revoked.toml`) is refused. A `.hmod` with code (`needs_port`) is not offered. +- **Kinds.** Content mods first. Resource packs, shader packs and data packs follow with the same page; each installs + where the launcher puts it. +- **Native mods.** Listed with the launcher's warning, word for word: they are native code with the player's rights, + not sandboxed, and the red **Install** button asks first, as the launcher's trust notice does. The game can't + compile, so it installs the `.hnmod` into `horizon-native-mods/`, and the launcher builds it before the next play + (#9901). The registry review stays the gate for every kind. + +## Native mods: the fast path + +A native mod's code is in the game's binary. What can be made fast: + +| Option | A mod change takes | On and off | Unload | Risk | +|---|---|---|---|---| +| **Today**: the launcher rebuilds the game (`profile.fast`, incremental, warm `native/target`) | see measurements | off at once (if the mod checks `native_on`), on: rebuild | none | none | +| **A. Every installed mod built in, on and off at run time** | rebuild only to install, update or remove | **at once**, for trait mods by dispatch | the mod's hooks stop; its state stays until restart | low: one check per dispatched mod | +| **B. Background build while playing, then a quick swap** | build while playing, then a restart into the same world | as A | restart | low; a second process and a hand-over | +| **C. Rust `dylib`s** (#7386 stages 3 and 4, arismoko's #797) | that mod only, under 30 s (the spike's pass mark) | restart | **never**: a Rust `dylib` can't be unloaded soundly | medium: exact-build pinning, Windows export limit | +| **D. A C ABI or `abi_stable` plugin layer over `native-hooks.json`** | that mod only, seconds | at run time | possible, with care | high: a second API to keep beside `mod_api`, and still not sandboxed | +| **E. wasm mods** (arismoko's fork: `horizon-mod-wasm`, wasmtime, one API with native) | that mod only, seconds | at run time | **yes**: drop the instance | medium: performance of the hooks it covers, API coverage | + +### What each costs, and the choice + +- **Unloading native code.** Rust code loaded with `dlopen` can't be unloaded soundly: a `'static` reference, a + thread it spawned, a thread-local destructor or a `Box` it handed the game outlives `dlclose`, and the + next call jumps into unmapped memory. A sound "unload" of native code is "stop calling it, keep it mapped, free it + at exit". Only a sandbox frees a mod's code and state at once: wasm's instance is dropped with its memory, and the + game never holds a pointer into it because every call goes through the host's table. +- **D is not worth a second API.** A stable C ABI over the hook catalog duplicates what arismoko's fork already + generates for wasm from the same catalog (the `mod_api` crate builds for `wasm32-unknown-unknown`, and its + interface is generated at build time). It would give native speed and run-time loading, but no sandbox and no + sound unload. We don't build it. +- **C and E are arismoko's work, and we build on it.** The fork has the `dylib` loader (#797), its acceptance harness + (#798) and the wasm runtime. They merge upstream through their own PRs; Horizon's side is to keep the hook catalog + and `Hooks` dispatch shaped so both can sit behind it. +- **A is the biggest win for the least risk, now.** Turning a native mod on or off is the common case (trying a mod, + finding a conflict, a modpack switch), and it needs no compiler at all while the mod is in the build. Step one + (#10218): the game skips a turned-off trait mod in `Hooks` dispatch (`mod_api`), so a mod that was on at launch + stops at once without its author doing anything, and starts again at once when turned back on. `RenderHooks` + run every frame over a plain list, so they are not switched yet, and mixin mods keep honouring `native_on` (they + paste code into the game's own functions, so only they can check). Step two: the launcher builds every installed + mod, whatever its `mod.` line says, so a mod that was off at launch also turns on without a build; it waits + for step one to settle, because a build with every installed mod costs players who keep many mods turned off. +- **B comes next.** With A in, a build is needed only to install, update or remove. The launcher already rebuilds + before play (#9901); B lets the player keep playing during it, and then restarts into the same world. + +### Measured + +On the desktop (i7-13700KF, `-j 8`, Linux, `rustc` 1.99, the launcher's `profile.fast`, incremental), building +`horizon-client` as the launcher does, on 2026-11-05 while other builds kept the machine at a load of about 30 to 40 +(an idle machine is faster; the ratios are the point): + +| Build | Time | +|---|---| +| Cold, empty target | 812 s | +| No change | 55 s | +| One line changed in gameplay (`game/mods.rs`), as a mixin into gameplay does | 570 s (the revert: 668 s) | +| One line changed in `horizon-world`, as a mixin into a lower crate does | 427 s (the revert: 469 s) | +| The gameplay change, linked with mold | 612 s | + +Mold changes nothing: the time is spent compiling the gameplay crate again, not linking it. A native mod's change +costs most of a cold build, so the run-time switch (A) is what makes on and off fast, and only a smaller gameplay +crate or code outside the game's binary (C, E) makes a change fast. + +## Security + +Nothing in this note widens what a mod can do. + +- Content mods have no code. The game checks every downloaded file against the index exactly as the launcher does, + and keeps the content fence ([mods.md](../mods.md#namespaces-and-limits)). +- Native mods stay full-trust native code. The in-game browser shows the launcher's trust notice and red warning + before installing one, and installing never builds or runs it: the launcher does, behind its own warning. +- The registry review stays the gate for listing a project, `revoked.toml` turns off taken-down files in the game + as in the launcher, and SHA-256 pinning and signatures are checked before a file reaches `horizon-mods/`. +- The browser fetches only the index, the registry's release assets (`horizon_mods::index::FILE_HOST`) and, later, + the registry's own mirrored images. It never follows a project's own links. + +## Plan, small PRs + +1. This note. +2. The in-game browser and install, for content mods (#10104). +3. Live switching of content mods (#10217): a root per mod set, classification, the client and server reloads, + tests that load, unload and load again a fixture mod and check the recipes, tags and lang it adds. Measured in + game on the ThinkPad, the root alone on the desktop. +4. Native step A, first half (#10218): a trait mod built in turns off and on at run time through `Hooks` dispatch. Then + the launcher builds every installed mod, and `RenderHooks` get the same switch. + +Later: the world reload for registry changes (replaceable process-wide tables), screenshots on the project page, +the other kinds in the browser, and B. + +## From the contributors' plans + +What this note takes from them: + +- From arismoko's fork ("deslopping the mod API"): the `dylib` loader (#797, #798) and the wasm runtime are the + run-time loading paths; mod-defined data pack registries reloaded on `/reload` (their #811) fit the live data + reload above as they are; one dependency model for native and wasm. +- From #7386: no unload for Rust `dylib`s, the spike's pass mark (under 30 s for a one-line mod change), and the + static build kept for a settled mod set. +- From levzzz5154's horizon-mixins: mixins stay for what has no hook; option A keeps every mixin mod working, + turned off only through `native_on`. +- From Vaspyyy's #9866: the browser says before install whether a native mod builds on the player's Horizon; the + in-game list shows the same verdict. + +What we'd ask them: + +- **arismoko:** Should option A's dispatch gate (skip a turned-off mod's `Hooks`) go in the generated dispatch + upstream now, or does your fork's dispatch already have a per-mod switch we should take instead? When the wasm + runtime lands upstream, can a wasm mod's instance be dropped while a world is open, so its switch is live too? +- **levzzz5154:** Could horizon-mixins wrap each mixin's injected code in a generated `native_on()` check, + so a mixin mod turns off at run time without its author writing the check? +- **Both:** Is a content root per mod set (`mod-content//`) in the way of anything you load from it? diff --git a/docs/mods.md b/docs/mods.md index 48d7a4bc4b..056f653e1a 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -14,14 +14,19 @@ Mods are **off by default**. A fresh install has no `horizon-mods` folder and pl ## Playing with mods -1. Install a mod from the launcher's Mods page ([mod-browser.md](mod-browser.md)), or put its files by hand in the +1. Install a mod from the launcher's Mods page ([mod-browser.md](mod-browser.md)) or from the game (Options > Mods > + **Browse Mods**: Horizon's registry, checked as the launcher checks it), or put its files by hand in the game folder (the one holding `options.txt`): a content mod's `.hmod` (or a folder holding `horizon-mod.toml`) in `horizon-mods/`. Native mods go in `horizon-native-mods/`, and the launcher builds them ([native-mods.md](native-mods.md)). 2. Options > **Mods** lists them with what each may do ("May add blocks, items, mob effects and entity types to your worlds") and a switch. They start off. The choice is kept in `horizon-mods.txt` (`mod.:true`). -3. A content mod's blocks, items, mob effects and entity types load once, when the game starts: restart the game after - a switch. +3. A switch applies at once when the mod has only assets and data (textures, models, sounds, languages, recipes, tags, + loot tables, advancements, functions): the game reloads its resources, then the world's data as `/reload` does, + and the players of a world open to LAN get the new data. A mod that adds blocks, items, mob effects, entity or menu + types, or changes world generation, still loads only when the game starts: the Mods page says to restart, and its + switch waits outside a world. On another server's world every content mod's switch waits: the server owns its + data ([design/mod-hotload.md](design/mod-hotload.md)). 4. A mod that can't load shows why, in red, on its row. Two turned-on content mods never own the same namespace: the first by file name loads, and the other stays out with diff --git a/docs/pvp.md b/docs/pvp.md index 486a763008..8e0ed51ad5 100644 --- a/docs/pvp.md +++ b/docs/pvp.md @@ -153,6 +153,22 @@ Overworld. They change how `chat::Chat` lays its lines out (`ChatQol`, `set_search`): the 100 messages it keeps are the same, so turning an option off shows them as before. Code: `chat.rs`, `game/chat.rs`. +## Combat feel (`combatFeel`, default Modern) + +**Legacy (1.8.9)** draws fights as 1.8.9 did, from its own client (`ItemRenderer`, read for the numbers only): +the item held in first person sits, swings, eats, drinks and draws a bow along 1.8.9's poses, a sword is held up +to block while you hold use (with nothing in the off hand drawn), and the hand doesn't dip after each hit. The +attack indicator is hidden unless **Attack indicator in Legacy** (`combatIndicator`) keeps it. Items whose +resource pack moves them in hand, and the ones 1.8.9 didn't have (crossbow, trident, spear, brush...), keep +26.3's poses. 1.8.9 tinted no armour red on a hit, so the hurt flash is the same in both. +Legacy also leaves out what 1.9 added around a hit: the sweep arc and the damage hearts a server sends, and +with **1.8 hit sounds** (`combatSounds`) the `entity.player.attack.*` sounds (strong, weak, crit, sweep, +knockback, no damage); hurt sounds and crit particles stay. `pvp::Combat`. + +It is only the look: the client sends a server exactly what Modern sends (same attacks, same timing, no +block-hit packets), so vanilla servers and their anticheats see nothing new. Code: `first_person.rs` +(`legacy_item_pose`), the hand in `game.rs`. + ## PvP preset The **PvP preset** button (first on the PvP & HUD page and on Horizon Extras) turns on, in one click: the custom diff --git a/docs/server.md b/docs/server.md index e95fabaf7d..e4586a0d82 100644 --- a/docs/server.md +++ b/docs/server.md @@ -155,6 +155,7 @@ expect_seconds = 10 # how long a player the proxy announced is waited f max_expect_seconds = 60 # the longest the proxy may ask for max_expected = 64 # players waited for at once max_freeze_seconds = 30 # the longest a player stays frozen while it is handed over +max_hold_seconds = 60 # the longest a player arriving from another server is held before it is dropped ``` The supervisor passes the section to the engine as `--transfers expect_seconds=10,...`, beside @@ -186,8 +187,22 @@ table. The player's next login uses the expectation up as it starts, once the ba limit let it in, so a login still in configuration when the time runs out (a resource pack prompt) keeps it. A login that then fails before it reaches play (its connection drops in configuration, or the client refuses a required pack) gives the expectation back with its own deadline, so the proxy's next attempt within that time is -still expected. An expectation no login used is reported in the log when its time runs out. For now that is all an expectation does: -the player still logs in as any other. +still expected. An expectation no login used is reported in the log when its time runs out. + +A login that used up an expectation is a *held* player. It gets the whole join sequence and the level around it +(chunks, block and light changes, entities, the level's broadcasts), and its acknowledgements are handled (teleport, +chunk batch, loaded, keep-alive, ping, tick end, client information), but it is not placed: no join message, no +player-list entry for the others, not among the entities they are shown, not simulated, and nothing else it sends is +acted on. The world is kept loaded, and generated if need be, around a held player as around a placed one, so a +server with nobody else on it sends a held player its chunks. The log says ` is held: it arrives from another +server` instead of the join line. A held player nobody takes over is dropped after `max_hold_seconds` (1,200 ticks +by default), with a warning, and its departure is logged as ` was dropped while held: `, with no "left +the game" line. `player-export` refuses a held player. + +`player-drop ` forgets the player's expectation, and disconnects the held player without a save or a word to +anyone. The reply is `{"ok": true, "held": true, "expected": false}`: whether a held player was disconnected, and +whether an expectation was forgotten. It is refused for an id that is neither held nor expected, and leaves a placed +player alone. `player-export [freeze]` reads a connected player out of the source server. It first closes the player's open menu as vanilla does when a menu closes, in the player's own level (the cursor stack, a crafting table's grid and @@ -234,7 +249,7 @@ kicked for floating. Every later export of that player, with or without `freeze` byte for byte, and does not restart the freeze: a proxy that lost the reply asks again. The hand-over ends after `max_freeze_seconds` (counted in ticks, 600 by default) with a warning in the log, and the id is then forgotten. Without `freeze` the export is a read: `handover` is null and the player goes on. The request is refused for a -player who is not connected, has not finished joining, is leaving, is riding, is asleep or is dead. +player who is not connected, is held, has not finished joining, is leaving, is riding, is asleep or is dead. While frozen, the player takes no damage (as vanilla's `Abilities.invulnerable`: the void and `/kill` still kill) and picks nothing up (items, arrows, experience orbs), so its record stays what it is until the release. A player @@ -253,6 +268,157 @@ than the player's current one, and for a player who died while frozen. Once the over, and a late `moved` is refused: the player has played on since, and the record the other server has is older. The proxy then fails the hand-over on the target (`player-drop`). +`player-import ` applies a record to the held player `uuid` on the target server, whole or in parts, +and lets it into the world: + +```json +{"record": { ...the record of player-export... }, "parts": ["inventory", "experience"], "announce": true, "place": true} +``` + +`parts` is optional (all of them by default), `announce` is optional (true by default) and `place` is optional (true +by default; see below). The parts, in the order the reply lists them, and what each sets: + +| part | sets | +| --- | --- | +| `inventory` | the inventory, armour, off hand and selected slot | +| `ender_chest` | the ender chest | +| `experience` | the level, progress, total and seed | +| `health` | health and absorption, food, saturation, exhaustion and its timer, and the body's air, fire and freezing | +| `effects` | the active effects | +| `game_mode` | the game mode, flight and flight speed | +| `position` | where the player stands, how it looks, whether it is on the ground, its fall and motion, and the vehicle it rode | +| `spawn_point` | the respawn point | +| `recipe_book` | the recipe book | +| `advancements` | the advancements and the statistics | + +A part that is not asked for keeps what the held player has, which is what this server loaded for it when it logged +in: its own file, or a new player's defaults. An imported inventory or ender chest replaces the held player's whole: a +slot the record leaves empty is empty afterwards. The keys of the compound that Horizon does not model, the values +mods attached to the player, and the last death, the post effects and the credits, are taken only when every part +is. Only the compound of the record is read: the fields above it (`position`, `rotation`, `velocity`, `dimension`) +are for whoever reads the record. + +A record is refused, with `{"error": "..."}` and nothing changed, when its `version` is not 1, its `data_version` is +not this server's, its `uuid` is not the request's, it has no `handover` id (an export without `freeze`), its +compound is not valid base64 NBT, a part's name is unknown, or the player is not held or is leaving. It is refused +too when its values are ones no player can have: a position, rotation, motion, health, absorption, saturation, +experience progress or fall that is not a number, a position farther than 30,000,512 blocks from the origin (20,000,000 +up or down), which vanilla's `Entity.load` would clamp, a health of zero or less, a health over 1,024 or an absorption +over 2,048 (the bounds of `max_health` and `max_absorption`), a food exhaustion over 40 (`FoodData.addExhaustion`'s) +or an effect amplifier outside 0 to 255. A stack's count out of vanilla's 1 to 99 is read as 1, as for any item. A record whose position is in +another dimension than the held player's world is refused when `position` is among the parts: the client is in one +dimension and only a login changes it. + +The record's hand-over is bound to the held player at the import: a second import of the same record is taken (a +retry), one of another hand-over is refused, and `player-confirm` and `player-drop` name it. + +**The commit point.** On success the player is sent what the import changed the way a join sends it (game mode, +abilities, held slot, a new position to confirm, the effects, with those it had from this server's own file removed, +inventory, recipe book and the player list as it now is). Then, unless `"place": false`, it is placed as +`player-confirm` places it: its player file, advancements and statistics are written to disk first, each to a new +file that is synced and then renamed into place (on Linux and macOS the folder is synced too), then it is placed: simulated, shown to the others and taking part from the next tick, when its first tick sends its +health, experience and advancements as a new player's does. Everyone gets its player-list entry, since everyone will +be shown its entity; with `announce` true they also get the join line. The log says ` placed: it has arrived +from another server`, and the join line when it is announced. The reply is `{"ok": true, "handover": "...", "parts": +[...], "placed": true, "announced": true, "recentered": false}` only once the file is written, so a proxy sends `player-release moved` to +the source after this reply: from then on a crash of the target, or a power loss, leaves the player's three files there +as it arrived. When one cannot be written, the reply is an error and the player stays held and imported, to be +confirmed again or dropped. + +**A lost reply after the commit.** A `player-import` or `player-confirm` that names the hand-over of a player already +placed from it is answered `{"ok": true, "placed": true, ..., "retried": true}`, with nothing done again: a proxy that +lost the reply asks again and learns the commit happened. + +**A refused `moved` after the commit.** The target then has the player placed and on disk, and nothing undoes that: +`player-drop` takes only a held player. It happens when the freeze ran out between the confirm and the release (the +proxy was late), or when an operator's `/kill` killed the frozen player (nothing else hurts it, and it does not +move). The proxy keeps its client on the target and disconnects the source's connection; the source then saves its +copy of the player as it is, so the two servers' files differ from that moment. To keep clear of the first case, the +proxy confirms only while the export's `freeze_ticks_left` leaves it time to release, and otherwise drops the arrival +and aborts. + +**Where the player arrives.** Only the compound of the record is read, so to put the player somewhere other than the +record says (a proxy that knows where its client stands, which the frozen source may not have seen yet), the request +takes four optional fields, which win over the compound: + +```json +{"record": {...}, "position": [300.5, 64.0, 300.5], "rotation": [90.0, 20.0], "on_ground": false, "velocity": [0.0, -0.08, 0.0]} +``` + +`position` is `[x, y, z]`, `rotation` is `[yaw, pitch]` in degrees, `on_ground` is a boolean and `velocity` is +`[x, y, z]` in blocks a tick. The pitch is taken as vanilla's `Entity.setXRot` takes it: modulo 360, then cut to ±90; +the yaw is kept as it is. A velocity component over 10 blocks a tick is taken as 0, as `Entity.load` takes a saved +`Motion`. The velocity is the server's: the arrival's teleport carries no motion, as a vanilla login's does not, so +the client starts still, and since a player's client moves itself, its first move replaces it. They belong to the `position` part: a +request that gives one and does not import that part is refused. A position that is not finite, or farther than the +record's own may be (30,000,512 blocks from the origin, 20,000,000 up or down), is refused, and so is a rotation or a +velocity that is not finite numbers (a rotation past what a float holds too). A record with a vehicle still to restore +(`RootVehicle`) puts the player back on it at the vehicle's own place, so a request that names a position for such a +record is refused. The border, the blocks and the chunks are not checked, +as a vanilla server does not check the place its player file names. The player is teleported to the position (the +client confirms a new teleport id), so an anticheat never sees the arrival as a move, however far it is from the +export. + +**The view follows the imported position.** `recentered` says whether the import moved the player to another chunk +than the one its view was built around (only possible with the `position` part, and only once the player has been sent +chunks). When it did, the server moves the view at once, before the position the client confirms: it sends +`SetChunkCacheCenter` for the new chunk and forgets the chunks that are out of reach, as the next tick would have. The +chunks around the new place are **not** sent yet: they come one batch at a time with the next ticks, and the client +acknowledges each batch. Whoever reads the player's world (a proxy building a view of it) must therefore wait for new +chunk batches after a reply with `"recentered": true`, as it waited for the first ones; with `false` the chunks it +already has are the ones around the player. + +**Importing in two steps, so that a hand-over can be cancelled.** With `"place": false` the record is applied and the +player's client is sent everything the import changed, but the player stays held: not placed, not simulated, not +shown, not announced, and nothing is written. The reply is `{"ok": true, "handover": "...", "parts": [...], "placed": +false, "announced": }`. Then either: + +- `player-confirm ` writes the player's files and places it as `place: true` would have, and + announces it if the import's `announce` said so. What the client did since the import was not acted on, so it is + sent its position to confirm again and its inventory. Reply `{"ok": true, "placed": true, "announced": true}`. It is + refused, with nothing changed, for a player who is not held, is leaving, whose record was not imported, or whose + record is of another hand-over. +- `player-drop [hand-over]` cancels the arrival: the connection is closed and nothing is saved or said to + anyone (the player was never placed, so the world never knew it). Reply `{"ok": true, "held": true, "expected": + false}`. With a hand-over id, it must be the imported record's, so a late drop of an older hand-over leaves a newer + one alone; before any import there is no id to name, and the drop goes by the unique id alone (a late drop of + that kind can still cut a newer held player that was not imported yet, which loses nothing: the source still has + the player frozen, and the proxy aborts there). Once dropped, the player can be neither imported nor confirmed. + +A proxy that fails after a successful `player-import` with `place: false`, and before it has sent its client the first +byte of the hand-over, calls `player-drop` and the target is as it was. A proxy that dies in between leaves a held +player that is dropped by itself after `max_hold_seconds`, with a warning in the log. + +**A hand-over, in order.** A proxy moving a player from server A to server B, without a loading screen. Both run +with `[transfer]` on and behind the proxy's Velocity forwarding: + +1. `player-expect ` on B: B holds the player's next login. +2. `player-export freeze` on A: the record and its `handover` id, and A stops acting on the player's inputs. + A lost reply is asked again: the same record comes back. +3. The proxy logs the player in to B on its own connection: B sends the held player the world around the place B + loaded for it. +4. `player-import ` on B with `"place": false` and, if the proxy knows where its client stands, the + request's `position`, `rotation`, `on_ground` and `velocity`. With `"recentered": true` the proxy waits for B's + next chunk batches. +5. The proxy switches its client over to B's connection. If anything fails before the client got the first byte of + it, `player-drop ` on B and `player-release abort` on A, and the player goes on + on A as before. +6. `player-confirm ` on B: B writes the player's files, then places it. Only after its `ok`, + `player-release moved` on A: the player is out of A's world, and A saves nothing of it. + +Every step that is not taken in time is undone by itself: an expectation after `expect_seconds`, a held player after +`max_hold_seconds`, a freeze after `max_freeze_seconds`, each with a warning in the log. A `moved` that comes after +the freeze ran out is refused, so the proxy must confirm and release within `max_freeze_seconds` of the export. + +**In the log.** Each `player-*` request writes one line with what became of it: `INFO` and what it did +(`player-import Alex (): ok, parts inventory,experience, placed false, announced true, recentered false, +hand-over `), or `WARN` and the reason it was refused (`player-export Alex (): refused: that player is +riding`). The hand-over's id is in the lines of the export, the import, the confirm, the release and a named drop, so +the lines of one hand-over can be found on both servers. Only the ids, the name when the server knows it, and a few +words of the outcome are written: never the record or the request's body, which are large and are the player's. A +held player has its own lines instead of the join and leave ones: ` is held: it arrives from another server`, +` placed: it has arrived from another server` and ` was dropped while held: `. + **The headless engine** comes after perf-ticks-2's work lands: Resources split into shared and per-game, the game built in `Game::new`, the integrated server on its own thread. On that base: diff --git a/launcher/src/native_mods/diagnostics.rs b/launcher/src/native_mods/diagnostics.rs index 47875e985f..407dac2bd1 100644 --- a/launcher/src/native_mods/diagnostics.rs +++ b/launcher/src/native_mods/diagnostics.rs @@ -80,11 +80,13 @@ pub(super) fn capture(compiler: &Compiler<'_>, input: impl BufRead, mut log: imp output } -/// A file's functions (where each is, its name and its tokens) and inline modules (where, name). +/// A file's functions (where each is, its name and its tokens), inline modules (where, name), +/// and the blocks horizon-mixins wraps a helper in (where, the helper's name and tokens). #[derive(Default)] struct Items { functions: Vec<(Range, String, String)>, modules: Vec<(Range, String)>, + calls: Vec<(Range, String, String)>, } impl<'ast> Visit<'ast> for Items { @@ -104,13 +106,31 @@ impl<'ast> Visit<'ast> for Items { } syn::visit::visit_item_mod(self, module); } + + /// `{ fn helper(…) { … } helper(…) }` and nothing else: an error in the generated call (an + /// argument the helper's signature no longer takes) is the helper's too. + fn visit_block(&mut self, block: &'ast syn::Block) { + if let [syn::Stmt::Item(syn::Item::Fn(function)), syn::Stmt::Expr(syn::Expr::Call(call), _)] = + block.stmts.as_slice() + { + if matches!(&*call.func, syn::Expr::Path(path) if path.path.is_ident(&function.sig.ident)) + { + self.calls.push(( + block.span().byte_range(), + function.sig.ident.to_string(), + function.to_token_stream().to_string(), + )); + } + } + syn::visit::visit_block(self, block); + } } /// A file being touched is insufficient: the error must be inside what a mod brought, and its /// owner be unique. That is a helper (the function with its tokens, or with its name when the /// pristine source has no function of that name: the staged file may be formatted, a trailing -/// comma added, which changes its tokens and not its name), or a support module (the inline -/// module of its name, when the pristine source has none). +/// comma added, which changes its tokens and not its name, with the call horizon-mixins generates +/// for it), or a support module (the inline module of its name, when the pristine source has none). fn owner(compiler: &Compiler<'_>, plan: &Value, manifest: &Value, span: &Value) -> Option { let stage = compiler.stage.canonicalize().ok()?; let file = Path::new(span["file_name"].as_str()?); @@ -184,6 +204,7 @@ fn owner(compiler: &Compiler<'_>, plan: &Value, manifest: &Value, span: &Value) if staged .functions .iter() + .chain(&staged.calls) .any(|f| inside(&f.0) && is_helper(f)) { owners.insert(owner(entry)); @@ -429,4 +450,52 @@ mod tests { assert!(!error.contains("fast_mesh"), "{error}"); fs::remove_dir_all(dir).unwrap(); } + + /// flair 1.0.0 on Horizon 66f3981: Horizon widened `extra` to five-element tuples, so the + /// call horizon-mixins generates for the mod's helper, outside the helper itself, fails. + #[test] + fn an_error_in_the_generated_call_to_a_helper_names_its_mod() { + const FLAIR: &str = "fn flair_armor(extra: &[(u8, u8)]) -> usize { extra.len() }"; + let check = |dir: &Path, main: &str| { + fs::write(dir.join("src/main.rs"), main).unwrap(); + let output = Command::new(env!("CARGO")) + .args(["check", "--offline", "--message-format=json"]) + .arg("--manifest-path") + .arg(dir.join("Cargo.toml")) + .env("CARGO_TARGET_DIR", dir.join("target")) + .output() + .unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("E0308"), "{stdout}"); + stdout + }; + let dir = fixture("compiler-generated-call"); + fs::write( + dir.join("original/src/main.rs"), + "fn render(_extra: &[(u8, u8, bool)]) {}\nfn main() {}\n", + ) + .unwrap(); + plan(&dir, json!([entry("flair", "1.0.0", FLAIR)])); + let mods = vec![("flair".to_owned(), "1.0.0".to_owned())]; + let compiler = Compiler { + stage: &dir, + source: &dir.join("original"), + mods: &mods, + }; + let stdout = check( + &dir, + &format!("fn render(extra: &[(u8, u8, bool)]) {{\n {{\n {FLAIR}\n flair_armor(extra)\n }};\n}}\nfn main() {{ render(&[]); }}\n"), + ); + let output = capture(&compiler, Cursor::new(&stdout), Vec::new()); + assert_eq!(output.owners(), ["flair 1.0.0"]); + + // The same call in a block that also holds the game's own code is not proven the mod's. + let stdout = check( + &dir, + &format!("fn render(extra: &[(u8, u8, bool)]) {{\n {{\n {FLAIR}\n let _ = 1;\n flair_armor(extra)\n }};\n}}\nfn main() {{ render(&[]); }}\n"), + ); + let output = capture(&compiler, Cursor::new(&stdout), Vec::new()); + assert!(output.owners().is_empty(), "{:?}", output.owners()); + fs::remove_dir_all(dir).unwrap(); + } } diff --git a/tools/server_transfer_e2e.py b/tools/server_transfer_e2e.py new file mode 100644 index 0000000000..86bd1a9b62 --- /dev/null +++ b/tools/server_transfer_e2e.py @@ -0,0 +1,615 @@ +#!/usr/bin/env python3 +"""A whole hand-over between two Horizon dedicated servers, over their real control sockets (#7484). + +Two engines (`horizon-client --dedicated`) run with `[transfer]` on (`--transfers`) and Velocity forwarding +(`forwarding.secret`), as docs/server.md requires. This harness is the proxy: a small relay per engine answers +the engine's `velocity:player_info` request with a signed payload (docs/velocity-forwarding.md), then copies +bytes both ways, and the control requests are sent on each engine's control socket. The player is a +horizon-bots client; the hand-over's two connections are two bot processes with the same name, as a proxy +holds two connections for one client. + + python3 tools/server_transfer_e2e.py --bin-dir target/fast + +Steps, in docs/server.md's order ("A hand-over, in order"), each checked: + +1. A player joins server A through the relay (and one joining A directly is refused: forwarding is required). + A's console gives it items and experience. +2. `player-expect` on B. +3. `player-export freeze` on A: a record with a hand-over id. Asked again, the same bytes come back. +4. The second connection logs in to B: B holds it ("is held"), and announces nothing. +5. `player-import` on B with `"place": false`: the record is taken, the player stays held. +6. `player-confirm ` on B: B's player file is on disk with the record's items when the reply comes, + then the player is placed. +7. `player-release moved` on A: the player is out of A's world, without "left the game", and A + writes no file for it, before or after it stops. + +Then the cancels: a hand-over from B back to A is aborted (`player-drop` on A, `player-release ... abort` on B) +and the player plays on on B; a late `moved` for it is refused. + +The engines run in a fresh folder with fresh HORIZON_HOME and XDG folders (lab.game_env), on free ports, with no +window. Every request, reply (records shortened) and check goes to --out/steps.json, with both servers' logs. +Build first: + cargo build --profile fast -p horizon-client --bin horizon-client + cargo build --profile fast -p horizon-bots +""" +import argparse +import base64 +import gzip +import hashlib +import hmac +import json +import os +from pathlib import Path +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import threading +import time +import uuid as uuidlib + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import lab # noqa: E402 +from server_bench import DONE, Server # noqa: E402 + +EXE = '.exe' if os.name == 'nt' else '' +SECRET = b'server-transfer-e2e-secret' +NAME = 'Mover' +PLAYER = NAME + '0' # horizon-bots names its bots PREFIX0, PREFIX1... +PROPERTIES = { + 'online-mode': 'false', + 'white-list': 'false', + 'level-type': 'minecraft\\:flat', + 'generate-structures': 'false', + 'gamemode': 'survival', + 'spawn-protection': '0', + 'view-distance': '3', + 'simulation-distance': '3', +} +CONTAINER_CLICK = 18 # ServerboundContainerClickPacket's id (horizon-protocol ids.rs) + + +def click_body(slot): + """A pickup click on `slot` of the inventory menu (window 0, state 0, button 0), with no + predicted slots and no predicted cursor: the server works the click out itself.""" + return '00' + '00' + f'{slot:04x}' + '00' + '00' + '00' + '00' + + +TRANSFERS = 'expect_seconds=20,max_expect_seconds=60,max_expected=8,max_freeze_seconds=30,max_hold_seconds=60' + + +class Failed(Exception): + pass + + +# ---- the wire: what the relay reads of a login --------------------------------------------------- + +def varint(n): + out = b'' + n &= 0xFFFFFFFF + while True: + b, n = n & 0x7F, n >> 7 + out += bytes([b | 0x80]) if n else bytes([b]) + if not n: + return out + + +def parse_varint(buf, i=0): + n = shift = 0 + while True: + b = buf[i] + i += 1 + n |= (b & 0x7F) << shift + if not b & 0x80: + return n, i + shift += 7 + + +def string(s): + b = s.encode() + return varint(len(b)) + b + + +def parse_string(buf, i): + n, i = parse_varint(buf, i) + return buf[i:i + n].decode(), i + n + + +def recv_exact(sock, n): + data = b'' + while len(data) < n: + chunk = sock.recv(n - len(data)) + if not chunk: + raise EOFError + data += chunk + return data + + +def recv_frame(sock): + length = 0 + shift = 0 + while True: + b = recv_exact(sock, 1)[0] + length |= (b & 0x7F) << shift + if not b & 0x80: + break + shift += 7 + return recv_exact(sock, length) + + +def send_frame(sock, body): + sock.sendall(varint(len(body)) + body) + + +def offline_uuid(name): + """UUID.nameUUIDFromBytes("OfflinePlayer:" + name), as an offline server and horizon-bots derive it.""" + md5 = bytearray(hashlib.md5(('OfflinePlayer:' + name).encode()).digest()) + md5[6] = (md5[6] & 0x0F) | 0x30 + md5[8] = (md5[8] & 0x3F) | 0x80 + return uuidlib.UUID(bytes=bytes(md5)) + + +def forwarded(name, address='127.0.0.1'): + """The `velocity:player_info` answer a proxy signs: version 1, the address, the profile, no properties.""" + data = varint(1) + string(address) + offline_uuid(name).bytes + string(name) + varint(0) + return hmac.new(SECRET, data, hashlib.sha256).digest() + data + + +class Relay: + """A stand-in for the proxy in front of one engine: it answers the engine's forwarding request for the + client, then copies bytes both ways. Every connection is logged.""" + + def __init__(self, engine_port, log): + self.engine_port = engine_port + self.log = log + self.listener = socket.socket() + self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.listener.bind(('127.0.0.1', 0)) + self.listener.listen(8) + self.port = self.listener.getsockname()[1] + self.sockets = [] + threading.Thread(target=self._accept, daemon=True).start() + + def _accept(self): + while True: + try: + client, _ = self.listener.accept() + except OSError: + return + threading.Thread(target=self._serve, args=(client,), daemon=True).start() + + def _serve(self, client): + try: + engine = socket.create_connection(('127.0.0.1', self.engine_port), timeout=30) + except OSError as e: + self.log(f'relay: no engine on {self.engine_port}: {e}') + client.close() + return + self.sockets += [client, engine] + try: + handshake = recv_frame(client) + send_frame(engine, handshake) + _, i = parse_varint(handshake) # packet id 0 + _, i = parse_varint(handshake, i) # protocol + _, i = parse_string(handshake, i) # host + next_state, _ = parse_varint(handshake, i + 2) + if next_state != 2: + self._pipe(client, engine) + return + login_start = recv_frame(client) + send_frame(engine, login_start) + _, i = parse_varint(login_start) + name, _ = parse_string(login_start, i) + request = recv_frame(engine) + pid, i = parse_varint(request) + transaction, i = parse_varint(request, i) + channel, _ = parse_string(request, i) if pid == 4 else ('', i) + if pid != 4 or channel != 'velocity:player_info': + self.log(f'relay: the engine did not ask for forwarding (packet {pid})') + send_frame(client, request) + else: + send_frame(engine, varint(2) + varint(transaction) + b'\x01' + forwarded(name)) + self.log(f'relay {self.port}: forwarded {name} to {self.engine_port}') + self._pipe(client, engine) + except (EOFError, OSError) as e: + self.log(f'relay {self.port}: {type(e).__name__} {e}') + client.close() + engine.close() + + def _pipe(self, a, b): + a.settimeout(None) + b.settimeout(None) + + def copy(src, dst): + try: + while True: + chunk = src.recv(65536) + if not chunk: + break + dst.sendall(chunk) + except OSError: + pass + for s in (src, dst): + try: + s.shutdown(socket.SHUT_RDWR) + except OSError: + pass + threading.Thread(target=copy, args=(a, b), daemon=True).start() + threading.Thread(target=copy, args=(b, a), daemon=True).start() + + def close(self): + self.listener.close() + for s in self.sockets: + try: + s.close() + except OSError: + pass + + +# ---- an engine and its control socket ------------------------------------------------------------ + +class Engine: + def __init__(self, bins, resources, folder, out, label): + self.label = label + self.folder = folder + folder.mkdir(parents=True) + self.port = lab.free_port() + props = dict(PROPERTIES, **{'server-port': str(self.port), 'motd': f'transfer e2e {label}'}) + (folder / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items())) + (folder / 'eula.txt').write_text('eula=true\n') + (folder / 'forwarding.secret').write_bytes(SECRET + b'\n') + # horizon-server makes the folder of the game's control endpoint; the game does not. + self.endpoint = folder / '.horizon' / 'engine.json' + self.endpoint.parent.mkdir() + command = [bins / f'horizon-client{EXE}', '--resources', resources, '--control-socket', self.endpoint, + '--transfers', TRANSFERS, '--dedicated', '--nogui'] + self.server = Server(command, folder, out / f'server-{label}.log') + self.control = None + + def ready(self): + self.server.wait_for(DONE, 300) + self.server.wait_for(r'Transfers on \(server\.toml \[transfer\]\)', 5) + self.server.wait_for(r'Velocity player information forwarding is on', 5) + info = json.loads(self.endpoint.read_text()) + sock = socket.create_connection(('127.0.0.1', info['port']), timeout=30) + reader = sock.makefile('r', encoding='utf-8') + sock.sendall(f'auth {info["token"]}\n'.encode()) + reply = reader.readline() + if '"ok"' not in reply: + raise Failed(f'{self.label}: the control socket refused the token: {reply.strip()}') + self.control = (sock, reader) + + def ask(self, line): + sock, reader = self.control + sock.sendall((line + '\n').encode()) + return json.loads(reader.readline()) + + def world_file(self, kind, player_uuid): + """players//. of the world, as Horizon writes it.""" + ext = 'dat' if kind == 'data' else 'json' + return self.folder / 'world' / 'players' / kind / f'{player_uuid}.{ext}' + + def stop(self): + if self.control: + try: + self.control[0].close() + except OSError: + pass + self.server.stop() + + +# ---- a player file ------------------------------------------------------------------------------- + +def read_nbt(data): + """A named NBT root out of uncompressed bytes, as plain Python values.""" + pos = [0] + + def take(n): + b = data[pos[0]:pos[0] + n] + pos[0] += n + return b + + def payload(tag): + if tag == 1: + return struct.unpack('>b', take(1))[0] + if tag == 2: + return struct.unpack('>h', take(2))[0] + if tag == 3: + return struct.unpack('>i', take(4))[0] + if tag == 4: + return struct.unpack('>q', take(8))[0] + if tag == 5: + return struct.unpack('>f', take(4))[0] + if tag == 6: + return struct.unpack('>d', take(8))[0] + if tag == 7: + n = struct.unpack('>i', take(4))[0] + return take(n) + if tag == 8: + n = struct.unpack('>H', take(2))[0] + return take(n).decode('utf-8', 'replace') + if tag == 9: + inner = take(1)[0] + n = struct.unpack('>i', take(4))[0] + return [payload(inner) for _ in range(n)] + if tag == 10: + out = {} + while True: + t = take(1)[0] + if t == 0: + return out + key = payload(8) + out[key] = payload(t) + if tag == 11: + n = struct.unpack('>i', take(4))[0] + return list(struct.unpack(f'>{n}i', take(4 * n))) + if tag == 12: + n = struct.unpack('>i', take(4))[0] + return list(struct.unpack(f'>{n}q', take(8 * n))) + raise ValueError(f'tag {tag}') + + root = take(1)[0] + payload(8) + return payload(root) + + +def items_in(player, item): + return sum(s.get('count', 1) for s in player.get('Inventory', []) if s.get('id') == item) + + +# ---- the run ------------------------------------------------------------------------------------- + +class Steps: + def __init__(self, out): + self.out = out + self.steps = [] + self.failed = [] + + def note(self, step, ok, **what): + entry = {'step': step, 'ok': ok, **what} + self.steps.append(entry) + print(('ok ' if ok else 'FAIL ') + step + ('' if ok else f': {what}')) + if not ok: + self.failed.append(step) + (self.out / 'steps.json').write_text(json.dumps(self.steps, indent=1)) + + def check(self, step, condition, **what): + self.note(step, bool(condition), **what) + return bool(condition) + + +def short(reply): + """A reply for the log, its record shortened.""" + r = json.loads(json.dumps(reply)) + if isinstance(r.get('record'), dict) and isinstance(r['record'].get('player'), str): + r['record']['player'] = f'<{len(r["record"]["player"])} base64 characters>' + for key in ('advancements', 'stats'): + if r['record'].get(key) is not None: + r['record'][key] = '<...>' + return r + + +class Bot: + """One horizon-bots client standing still, through a relay.""" + + def __init__(self, bins, port, out, label, seconds, *extra): + self.report = out / f'bot-{label}.json' + self.log = (out / f'bot-{label}.log').open('w') + self.process = subprocess.Popen( + [str(bins / f'horizon-bots{EXE}'), '--server', f'127.0.0.1:{port}', '--bots', '1', + '--behaviour', 'stand', '--seconds', str(seconds), '--name', NAME, '--report', str(self.report), + *extra], + stdout=self.log, stderr=subprocess.STDOUT, env=lab.game_env()) + + def stop(self): + if self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(10) + except subprocess.TimeoutExpired: + self.process.kill() + self.log.close() + + +def run(bins, resources, scratch, out): + steps = Steps(out) + player_uuid = str(offline_uuid(PLAYER)) + relay_log = (out / 'relay.log').open('w') + + def log(line): + relay_log.write(line + '\n') + relay_log.flush() + + a = Engine(bins, resources, scratch / 'a', out, 'a') + b = Engine(bins, resources, scratch / 'b', out, 'b') + relays, bots = [], [] + try: + a.ready() + b.ready() + relay_a, relay_b = Relay(a.port, log), Relay(b.port, log) + relays += [relay_a, relay_b] + steps.note('both engines up, transfers on, forwarding on', True, ports={'a': a.port, 'b': b.port}) + + # 1. The player on A, through the proxy; a direct login is refused. + direct = Bot(bins, a.port, out, 'direct', 20) + bots.append(direct) + direct.process.wait(60) + steps.check('a login that skips the proxy is refused', + any('behind a proxy' in l or 'Refused' in l for l in a.server.since(0))) + mark_a = a.server.count() + # Three seconds after it joins, the client picks up its first hotbar slot (inventory menu + # slot 36, where the diamonds go) and puts it in the 2x2 crafting grid (slot 1): two + # ServerboundContainerClickPackets (window 0, a pickup each, no predicted slots). + clicks = ','.join(f'{tick}:{CONTAINER_CLICK}:{click_body(slot)}' for tick, slot in ((60, 36), (64, 1))) + first = Bot(bins, relay_a.port, out, 'first', 240, '--raw', clicks) + bots.append(first) + a.server.wait_for(rf'{PLAYER} joined the game', 60) + a.server.console(f'give {PLAYER} minecraft:diamond 5') + a.server.console(f'give {PLAYER} minecraft:oak_log 12') + a.server.console(f'xp add {PLAYER} 7 levels') + # The client's clicks put the diamonds in its 2x2 grid; the export must put them back in + # the inventory, or they are in no record. + time.sleep(5) + steps.note('1. the player is on A through the proxy, with 5 diamonds, 12 logs and 7 levels', True) + # The player compound has no crafting grid: diamonds that left `Inventory` are in it. + mark = a.server.count() + a.server.console(f'data get entity {PLAYER} Inventory') + time.sleep(1) + inventory = [l for l in a.server.since(mark) if 'entity data' in l or 'Inventory' in l] + steps.check("1. its client moved the diamonds into its 2x2 crafting grid (they left its Inventory)", + inventory and not any('diamond' in l for l in inventory) + and any('oak_log' in l for l in inventory), console=inventory) + a_file = a.world_file('data', player_uuid) + a_before = a_file.stat().st_mtime_ns if a_file.exists() else None + + # 2. B expects it. + reply = b.ask(f'player-expect {player_uuid}') + steps.check('2. player-expect on B', reply.get('ok') is True, reply=reply) + + # 3. A exports it, frozen; a repeat answers the same bytes. + reply = a.ask(f'player-export {player_uuid} freeze') + record = reply.get('record') or {} + handover = record.get('handover') + steps.check('3. player-export freeze on A gives a record with a hand-over id', + reply.get('ok') is True and isinstance(handover, str) and len(handover) == 32, + reply=short(reply)) + again = a.ask(f'player-export {player_uuid} freeze') + steps.check('3. a repeated export answers the same record, byte for byte', + json.dumps(again.get('record'), sort_keys=True) == json.dumps(record, sort_keys=True)) + steps.check('3. the export says how long the freeze still lasts', + 0 < again.get('freeze_ticks_left', 0) <= 600, freeze_ticks_left=again.get('freeze_ticks_left')) + + # While frozen: no damage, no pickup. + mark = a.server.count() + a.server.console(f'damage {PLAYER} 5') + a.server.console('summon minecraft:item 0.5 -60 0.5 {Item:{id:"minecraft:emerald",count:3},PickupDelay:0}') + time.sleep(2) + a.server.console('kill @e[type=minecraft:item]') + time.sleep(1) + console = a.server.since(mark) + steps.check('3. a frozen player picks nothing up: the emeralds at its feet are still in the world', + any('Killed' in l for l in console), console=console) + + # 4. The second connection logs in to B, held. + mark_b = b.server.count() + second = Bot(bins, relay_b.port, out, 'second', 240) + bots.append(second) + b.server.wait_for(rf'{PLAYER} is held: it arrives from another server', 60) + steps.check('4. the login on B is held, and announced to no one', + not any('joined the game' in l for l in b.server.since(mark_b))) + + # 5. Import without placing. + request = json.dumps({'record': record, 'place': False}) + reply = b.ask(f'player-import {player_uuid} {request}') + steps.check('5. player-import on B, place false: taken, still held', + reply.get('ok') is True and reply.get('placed') is False and reply.get('handover') == handover, + reply=reply) + b_file = b.world_file('data', player_uuid) + steps.check('5. nothing is written before the confirmation', not b_file.exists()) + + # 6. Confirm: the commit point. + reply = b.ask(f'player-confirm {player_uuid} {handover}') + written = b_file.exists() + player = read_nbt(gzip.decompress(b_file.read_bytes())) if written else {} + steps.check('6. player-confirm on B: placed, and its file is on disk when the reply comes', + reply.get('ok') is True and reply.get('placed') is True and written, reply=reply) + steps.check("6. B's file holds the record's items and experience", + items_in(player, 'minecraft:diamond') == 5 and items_in(player, 'minecraft:oak_log') == 12 + and player.get('XpLevel') == 7, + diamonds=items_in(player, 'minecraft:diamond'), logs=items_in(player, 'minecraft:oak_log'), + level=player.get('XpLevel')) + exported = read_nbt(base64.b64decode(record['player'])) if record.get('player') else {} + steps.check('3. the diamonds of the 2x2 grid are back in the exported inventory', + items_in(exported, 'minecraft:diamond') == 5, diamonds=items_in(exported, 'minecraft:diamond')) + steps.check('6. the statistics went with it', b.world_file('stats', player_uuid).exists()) + steps.check('6. no emerald came: the record is the player at the export', + items_in(player, 'minecraft:emerald') == 0) + # A proxy that lost the reply asks again: the same answer, nothing done again. + retry = b.ask(f'player-confirm {player_uuid} {handover}') + steps.check('6. a confirm retried after the commit is answered ok, placed', + retry.get('ok') is True and retry.get('placed') is True and retry.get('retried') is True, + reply=retry) + retry = b.ask(f'player-import {player_uuid} {request}') + steps.check('6. an import retried after the commit is answered ok, placed', + retry.get('ok') is True and retry.get('placed') is True and retry.get('retried') is True, + reply=retry) + + # 7. Release on A. + reply = a.ask(f'player-release {player_uuid} {handover} moved') + steps.check('7. player-release moved on A', reply.get('ok') is True and reply.get('released') == 'moved', + reply=reply) + first.process.wait(30) + time.sleep(2) + report = json.loads(first.report.read_text())['per_bot'][0] if first.report.exists() else {} + steps.check('3. the frozen player took no damage (its client was never sent a lower health)', + report.get('health') in (None, 20.0, 20), health=report.get('health')) + lines_a = a.server.since(mark_a) + steps.check('7. A logs no "left the game" for a player moved away', + not any(f'{PLAYER} left the game' in l for l in lines_a)) + steps.check("7. A's connection of the player is closed", first.process.poll() is not None) + late = a.ask(f'player-release {player_uuid} {handover} moved') + steps.check('7. a repeated release is refused', 'error' in late, reply=late) + + # The cancel: from B back to A, aborted. + reply = a.ask(f'player-expect {player_uuid}') + steps.check('cancel: player-expect on A', reply.get('ok') is True, reply=reply) + reply = b.ask(f'player-export {player_uuid} freeze') + back = (reply.get('record') or {}).get('handover') + steps.check('cancel: player-export freeze on B', reply.get('ok') is True and back, reply=short(reply)) + third = Bot(bins, relay_a.port, out, 'third', 60) + bots.append(third) + a.server.wait_for(rf'{PLAYER} is held: it arrives from another server', 60) + reply = a.ask(f'player-drop {player_uuid}') + steps.check('cancel: player-drop on A lets the held login go', reply.get('held') is True, reply=reply) + a.server.wait_for(rf'{PLAYER} was dropped while held', 30) + reply = b.ask(f'player-release {player_uuid} {back} abort') + steps.check('cancel: player-release abort on B', reply.get('released') == 'abort', reply=reply) + reply = b.ask(f'player-release {player_uuid} {back} moved') + steps.check('cancel: a moved after the abort is refused', 'error' in reply, reply=reply) + status = b.ask('server-status') + names = [p.get('name') for p in status.get('players', [])] + steps.check('cancel: the player plays on on B', PLAYER in names and second.process.poll() is None, + players=names) + + # A wrote nothing for the player it let go, before or after it stops. + a.stop() + a_after = a_file.stat().st_mtime_ns if a_file.exists() else None + steps.check('A wrote no file for the player after it moved away, even when it stopped', + a_after == a_before, before=a_before, after=a_after) + finally: + for bot in bots: + bot.stop() + for relay in relays: + relay.close() + for engine in (a, b): + try: + engine.stop() + except Exception: # noqa: BLE001 - stopping what is left + pass + relay_log.close() + if steps.failed: + raise Failed('failed: ' + '; '.join(steps.failed)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--bin-dir', default=str(lab.ROOT / 'target/fast')) + parser.add_argument('--resources', default=str(lab.RESOURCES), help='the prepared game files') + parser.add_argument('--out', default=str(lab.LOCAL / 'server-transfer-e2e')) + args = parser.parse_args() + bins, out = Path(args.bin_dir).resolve(), Path(args.out) + shutil.rmtree(out, ignore_errors=True) + out.mkdir(parents=True) + os.environ.update(lab.game_env()) + with tempfile.TemporaryDirectory(prefix='server-transfer-e2e-') as scratch: + try: + run(bins, Path(args.resources), Path(scratch), out) + except (Failed, RuntimeError) as e: + sys.exit(f'server_transfer_e2e failed (steps and logs in {out}):\n{e}') + print(f'server_transfer_e2e: the whole hand-over and its cancel went as docs/server.md says (steps in {out})') + + +if __name__ == '__main__': + main()