mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
The name tag loop keeps #10312's frustum-culled collect_name_tags and wraps it in #10328's layout cache frame; both test sets kept. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
109c355b33
47 files changed
+5731
-220
No files matched your search
@@ -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<T: Send + 'static>(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();
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ pub struct HudAtlas {
|
||||
pub sprites: HashMap<String, Rect>,
|
||||
/// Resolved player-profile names and UUIDs for inline head components.
|
||||
pub object_skins: HashMap<String, String>,
|
||||
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<String>,
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<Row> {
|
||||
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::NativePage>| 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
|
||||
|
||||
@@ -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<Result<Opt, &'static str>> {
|
||||
@@ -1218,6 +1267,8 @@ pub fn rows() -> Vec<Result<Opt, &'static str>> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Request>) {
|
||||
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 =
|
||||
|
||||
@@ -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<glam::Mat4> {
|
||||
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)]
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! (including their positions) and the loaded chunks; `console <line>` runs a console command and answers with the lines it logged;
|
||||
//! `server-events <since>` answers the typed events after that number (events.rs); `management <connection>
|
||||
//! <json>` edits the lists as vanilla's management protocol does (management.rs).
|
||||
//! `player-expect <uuid> [seconds]`, `player-export <uuid> [freeze]` and `player-release <uuid> ...` are a
|
||||
//! proxy's, for a player it moves between two servers (transfer.rs).
|
||||
//! `player-expect <uuid> [seconds]`, `player-drop <uuid>`, `player-export <uuid> [freeze]`,
|
||||
//! `player-release <uuid> ...`, `player-import <uuid> <json>` and `player-confirm <uuid> ...` 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};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! A proxy's requests on the control endpoint, for a player it moves between two servers:
|
||||
//! `player-expect <uuid> [seconds]` (the target is told who is coming),
|
||||
//! `player-expect <uuid> [seconds]` (the target is told who is coming; its login is held),
|
||||
//! `player-drop <uuid>` (the target forgets it, and lets a held player go),
|
||||
//! `player-export <uuid> [freeze]` (the source answers with the player's record) and
|
||||
//! `player-release <uuid> <hand-over> moved [announce] | abort` (the source lets the player
|
||||
//! go). They run
|
||||
//! `player-release <uuid> <hand-over> moved [announce] | abort` (the source lets the player go),
|
||||
//! `player-import <uuid> <json>` and `player-confirm <uuid> <hand-over>` (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 <uuid> <json>`: 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 <uuid> <hand-over>`: 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<bool, String> {
|
||||
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,
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// 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<std::collections::BTreeSet<String>>,
|
||||
/// 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<std::time::Instant>,
|
||||
/// 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<String>,
|
||||
live_ids: std::collections::BTreeSet<String>,
|
||||
/// 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<String> {
|
||||
(!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<String>,
|
||||
live_ids: &mut std::collections::BTreeSet<String>,
|
||||
) {
|
||||
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` (`<id>.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<String> {
|
||||
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<String> {
|
||||
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.<id>` 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.
|
||||
|
||||
@@ -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<NameTag> {
|
||||
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<NameTag> {
|
||||
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<u32, usize> {
|
||||
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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<u128, String>,
|
||||
skins_revision: u64,
|
||||
published_skins: Option<(usize, u128, u64, u64)>,
|
||||
listed: std::collections::HashSet<u128>,
|
||||
/// `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<horizon_client::tab_list::TabPlayer>, bool, Vec<u128>)> {
|
||||
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::<Vec<_>>(),
|
||||
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<String>, bool)> = Vec::new();
|
||||
let mut resolved: Vec<(usize, Option<String>, 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<String, String>, 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() {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<TabPlayer> {
|
||||
let mut players = vec![me];
|
||||
|
||||
@@ -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::<serde_json::Value>(&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::<serde_json::Value>(&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 {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<u128>,
|
||||
/// 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(),
|
||||
|
||||
@@ -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<HostEvent>) {
|
||||
// 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<u32> = 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<usize> {
|
||||
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<usize, &'static str> {
|
||||
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<bool, &'static str> {
|
||||
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<HostEvent> {
|
||||
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<usize> = (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<usize> = (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<usize> = (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();
|
||||
|
||||
@@ -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<Value>,
|
||||
pub stats: Option<Value>,
|
||||
/// 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<bool>,
|
||||
/// 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<const N: usize>(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<Arrival, String> {
|
||||
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<Request<'_>, 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<crate::items::ItemStack>>,
|
||||
[Option<crate::items::ItemStack>; 4],
|
||||
Option<crate::items::ItemStack>,
|
||||
usize,
|
||||
)>,
|
||||
ender: Option<Vec<Option<crate::items::ItemStack>>>,
|
||||
experience: Option<(i32, f32, i32, i32)>,
|
||||
health: Option<(f32, Option<f32>, i32, f32)>,
|
||||
effects: Option<crate::effects::PlayerEffects>,
|
||||
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<crate::recipe_book::Settings>,
|
||||
/// The body's values: its fall goes with the position, its air and fire with the
|
||||
/// health.
|
||||
body: Option<SavedBody>,
|
||||
other: Option<(
|
||||
Option<Tag>,
|
||||
Option<(String, [i32; 3])>,
|
||||
bool,
|
||||
Vec<String>,
|
||||
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<Imported, String> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2703,7 +2703,10 @@ pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result<Vec<String>
|
||||
}
|
||||
|
||||
/// 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/<stamp>`: 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<P
|
||||
{
|
||||
return Ok(base.to_owned());
|
||||
}
|
||||
let out = cache.join("mod-content");
|
||||
let stamp = stamp(base, mods)?;
|
||||
let roots = cache.join("mod-content");
|
||||
let out = roots.join(&stamp[..ROOT_NAME]);
|
||||
if fs::read(out.join(STAMP)).is_ok_and(|s| s == stamp.as_bytes()) {
|
||||
// A reused root warns as the build did.
|
||||
let warnings = fs::read_to_string(out.join(WARNINGS)).unwrap_or_default();
|
||||
warnings.lines().for_each(|w| log::warn!("Horizon: {w}"));
|
||||
// Used again: kept another day.
|
||||
let _ = fs::write(out.join(STAMP), &stamp);
|
||||
return Ok(out);
|
||||
}
|
||||
for warning in build(base, mods, &out)? {
|
||||
log::warn!("Horizon: {warning}");
|
||||
}
|
||||
fs::write(out.join(STAMP), stamp)?;
|
||||
prune_roots(&roots, &out, KEEP_ROOTS);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The length of a root's folder name: the start of its stamp.
|
||||
const ROOT_NAME: usize = 16;
|
||||
/// How long a root no start or switch used is kept.
|
||||
const KEEP_ROOTS: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
|
||||
|
||||
/// Removes from `roots` the roots other than `current` whose stamp is older
|
||||
/// than `keep`, and what an older game left there (one root built straight in
|
||||
/// the folder). A game still running on a removed root was started more than
|
||||
/// `keep` ago and reads its tables already; only the folders' own names are
|
||||
/// touched.
|
||||
fn prune_roots(roots: &Path, current: &Path, keep: std::time::Duration) {
|
||||
let Ok(entries) = fs::read_dir(roots) else {
|
||||
return;
|
||||
};
|
||||
let old_layout = roots.join(STAMP).exists();
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path == current {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name();
|
||||
let is_root = name.len() == ROOT_NAME
|
||||
&& name
|
||||
.to_string_lossy()
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_hexdigit())
|
||||
&& path.is_dir();
|
||||
let stale = match is_root {
|
||||
true => 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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -3125,6 +3125,50 @@ fn back_up_unreadable_player(path: &Path) {
|
||||
}
|
||||
/// Write a gzip NBT file the way vanilla does (`<name>_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 `<name>_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/`.
|
||||
|
||||
@@ -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<u32> = (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);
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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<OsStr>) -> 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";
|
||||
|
||||
@@ -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<String> {
|
||||
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 [
|
||||
|
||||
+2
-1
@@ -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": {
|
||||
|
||||
@@ -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]`
|
||||
|
||||
|
||||
@@ -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/<stamp hash>/` 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 <name>`, 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/<id>.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<dyn Fn>` 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.<id>` 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(<mod id>)` 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/<hash>/`) in the way of anything you load from it?
|
||||
+8
-3
@@ -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.<id>: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
|
||||
|
||||
+16
@@ -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
|
||||
|
||||
+169
-3
@@ -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 `<name> 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 `<name> was dropped while held: <reason>`, with no "left
|
||||
the game" line. `player-export` refuses a held player.
|
||||
|
||||
`player-drop <uuid>` 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 <uuid> [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 <uuid> <json>` 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 `<name> 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": <what announce asked>}`. Then either:
|
||||
|
||||
- `player-confirm <uuid> <hand-over>` 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 <uuid> [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 <uuid>` on B: B holds the player's next login.
|
||||
2. `player-export <uuid> 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 <uuid> <json>` 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 <uuid> <hand-over>` on B and `player-release <uuid> <hand-over> abort` on A, and the player goes on
|
||||
on A as before.
|
||||
6. `player-confirm <uuid> <hand-over>` on B: B writes the player's files, then places it. Only after its `ok`,
|
||||
`player-release <uuid> <hand-over> 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 (<uuid>): ok, parts inventory,experience, placed false, announced true, recentered false,
|
||||
hand-over <id>`), or `WARN` and the reason it was refused (`player-export Alex (<uuid>): 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: `<name> is held: it arrives from another server`,
|
||||
`<name> placed: it has arrived from another server` and `<name> was dropped while held: <reason>`.
|
||||
|
||||
**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:
|
||||
|
||||
|
||||
@@ -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<usize>, String, String)>,
|
||||
modules: Vec<(Range<usize>, String)>,
|
||||
calls: Vec<(Range<usize>, 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<String> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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 <uuid> 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 <uuid> <id>` 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 <uuid> <id> 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/<kind>/<uuid>.<dat|json> 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()
|
||||
Reference in new issue
Block a user