Merge remote-tracking branch 'origin/main' into fix/6152-submerged-mount

This commit is contained in:
Akirabane committed 2026-11-01 19:52:02 +01:00
commit fd43c46207
41 files changed
+2393 -135

No files matched your search

@@ -483,6 +483,21 @@ impl KeyBinds {
.find(|mapping| self.get(mapping) == name)
.and_then(|mapping| key_code(Self::default_of(mapping)))
}
/// `AbstractContainerScreen.checkHotbarKeyPressed`: the off hand and the
/// hotbar slots, as the controls bind them now. Answers the key the menu
/// acts on (`KeyF` for the off hand, `DigitN` for a slot), so a rebound key
/// swaps instead of reaching the screen.
pub fn container_hotbar_key(&self, key: KeyCode) -> Option<KeyCode> {
let name = key_name(key)?;
if self.get("key.swapOffhand") == name {
return Some(KeyCode::KeyF);
}
MAPPINGS
.iter()
.filter(|m| m.0.starts_with("key.hotbar."))
.find(|m| self.get(m.0) == name)
.and_then(|m| key_code(Self::default_of(m.0)))
}
}
#[cfg(test)]
@@ -519,6 +534,29 @@ mod tests {
assert_eq!(keys.container_mouse_key(MouseButton::Forward, false), None);
}
#[test]
fn container_hotkeys_follow_the_bound_keys() {
let mut keys = KeyBinds::default();
assert_eq!(
keys.container_hotbar_key(KeyCode::KeyF),
Some(KeyCode::KeyF)
);
assert_eq!(
keys.container_hotbar_key(KeyCode::Digit3),
Some(KeyCode::Digit3)
);
assert_eq!(keys.container_hotbar_key(KeyCode::Tab), None);
// The report's key: bound to the off hand, the menu takes it first.
keys.set("key.swapOffhand", "key.keyboard.tab");
assert_eq!(keys.container_hotbar_key(KeyCode::Tab), Some(KeyCode::KeyF));
assert_eq!(keys.container_hotbar_key(KeyCode::KeyF), None);
keys.set("key.hotbar.3", "key.keyboard.t");
assert_eq!(
keys.container_hotbar_key(KeyCode::KeyT),
Some(KeyCode::Digit3)
);
}
#[test]
fn rebinding_translates_to_defaults() {
let mut k = KeyBinds::default();
@@ -209,5 +209,102 @@
"horizon.pages.split_profiles.tooltip": "谁在玩:保存在此游戏文件夹中的一份档案。",
"horizon.shader_options.open": "光影选项…",
"horizon.shader_options.profile": "档案",
"horizon.shader_options.loading": "正在读取光影包的选项…"
"horizon.shader_options.loading": "正在读取光影包的选项…",
"horizon.pages.split_settings": "分屏设置",
"horizon.pages.split_setup": "分屏",
"horizon.pages.split_setup.tooltip": "在本机游玩的玩家:手柄可以加入你的世界。",
"horizon.pages.open.split_setup": "分屏…",
"horizon.title.not_affiliated": "与 Mojang 或 Microsoft 无关",
"horizon.extras.header.lighting": "光照",
"horizon.extras.header.third_person": "第三人称",
"horizon.extras.header.particles": "粒子",
"horizon.extras.header.inventory": "物品栏",
"horizon.extras.header.chat": "聊天",
"horizon.extras.header.multiplayer": "多人游戏",
"horizon.extras.header.sound": "声音",
"horizon.extras.fullbright": "全亮",
"horizon.extras.dynamic_lights": "动态光源",
"horizon.extras.own_name_tag": "自己的名字标签",
"horizon.extras.hide_block_particles": "隐藏方块破坏粒子",
"horizon.extras.auto_reconnect": "自动重连",
"horizon.extras.ui_sound_reserve": "为界面音效保留声道",
"horizon.extras.mouse_tweaks": "鼠标调整控制",
"horizon.extras.mouse_tweaks_wheel": "滚轮移动物品",
"horizon.extras.sort": "排序按钮",
"horizon.extras.sort_key": "排序快捷键",
"horizon.extras.no_ai": "关闭 AI",
"horizon.extras.agent_control": "代理控制",
"horizon.extras.discord_presence": "Discord 动态状态",
"horizon.extras.splitscreen": "分屏",
"horizon.extras.chat_animation": "平滑聊天",
"horizon.extras.chat_indicator": "消息指示条",
"horizon.extras.fullbright.tooltip": "即使在黑暗区域也以全亮显示方块和实体。",
"horizon.extras.dynamic_lights.tooltip": "手持、掉落或燃烧中的火把等光源会照亮周围方块。",
"horizon.extras.own_name_tag.tooltip": "在第三人称视角中把名字显示在头顶,与其它玩家看到的一致。当服务器隐藏团队名字或你处于隐身状态时不显示。",
"horizon.extras.hide_block_particles.tooltip": "隐藏方块被破坏时迸出的碎屑粒子,以及挖掘时掉落的碎屑。其它粒子仍会显示。",
"horizon.extras.auto_reconnect.tooltip": "连接中断或服务器重启后自动重新加入,被踢出或封禁后不会。",
"horizon.extras.ui_sound_reserve.tooltip": "世界中的声音会为界面音效留出 247 个声道中的 16 个,这样大量声音(比如一次大爆炸)不会把菜单点击音挤掉。",
"horizon.extras.mouse_tweaks.tooltip": "右键拖动每次放一个物品,左键拖动合并手持物品,Shift + 左键拖动移动所有槽位。",
"horizon.extras.mouse_tweaks_wheel.tooltip": "向下把槽位中的一个物品送到另一侧,向上取回一个。",
"horizon.extras.sort.tooltip": "按创造模式标签页顺序排序物品栏、箱子、木桶、末影箱和潜影盒,并合并堆叠。",
"horizon.extras.sort_key.tooltip": "排序光标下的部分。点击后按一个键;按 Escape 取消绑定。",
"horizon.extras.no_ai.tooltip": "隐藏并关闭所有 AI 功能:代理控制、horizon-ctl 和 MCP 服务器。启动器中的开关也一样。",
"horizon.extras.agent_control.tooltip": "你自己的 AI 代理可以通过 horizon-ctl 或 MCP 客户端玩这个游戏,仅限本机。",
"horizon.extras.discord_presence.tooltip": "Discord 运行时在你的 Discord 资料上显示“正在玩 Horizon”:菜单、单人、多人、维度以及游玩时长。绝不显示世界名称或服务器地址。",
"Calibrate...": "校准…",
"Calibrate Anyway...": "仍要校准…",
"Use Automatic Setup": "使用自动设置",
"Shortcut: %s": "快捷键:%s",
"Next": "下一步",
"HDR Calibration %s/%s: %s": "HDR 校准 %s/%s:%s",
"Sets the three below full screen, one test picture at a time.": "以全屏方式逐张显示测试图,来设置下面三项。",
"A key that opens this screen from the game. Not bound at first.": "在游戏中打开此界面的按键。初始未绑定。",
"Your display didn't say how bright it gets: calibrate to set it up.": "显示器没有报告自身亮度:需要通过校准来设置。",
"Already set up for your display. Only calibrate if HDR looks wrong.": "已按你的显示器设置好。只有在 HDR 显示异常时才需要校准。",
"Puts Peak Brightness and Paper White back to your display's setup.": "将峰值亮度和纸白亮度恢复为你的显示器设置。",
"Set up for your display. Leave on Auto unless it looks wrong.": "已按你的显示器设置。除非显示异常,否则保持“自动”。",
"Lower it until the logo just disappears into the black.": "调低,直到标志刚好没入黑色背景。",
"Raise it until the logo just disappears into the bright square.": "调高,直到标志刚好没入亮色方块。",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "在你游玩时的光照环境下,把卡片调成长时间观看也舒适的白色。",
"horizon.splitscreen.profiles.forget": "忘记",
"horizon.splitscreen.profiles.create": "创建",
"horizon.splitscreen.profiles.you": " - 你",
"horizon.splitscreen.profiles.forget_named": "忘记 %s",
"horizon.splitscreen.profiles.previous": "上一个",
"horizon.splitscreen.profiles.next": "下一个",
"horizon.splitscreen.profiles.new_button": "新建档案…",
"horizon.splitscreen.profiles.forget_title": "忘记档案?",
"horizon.splitscreen.profiles.forget_leaves": "该档案将从此实例的列表中移除。",
"horizon.splitscreen.profiles.forget_kept": "世界进度和设置会保留。",
"horizon.splitscreen.profiles.new_title": "新建档案",
"horizon.splitscreen.profiles.name_hint": "名称(3 到 16 个字母、数字或 _)",
"horizon.splitscreen.profiles.switch_title": "切换档案",
"horizon.splitscreen.profiles.offline_note": "离线档案属于此实例",
"horizon.splitscreen.profiles.move": "移动游玩位置",
"horizon.splitscreen.profiles.move_place": "把游玩位置移到:%s",
"horizon.friends.title": "与好友一起玩",
"horizon.friends.hint": "好友可以在标题画面的“好友”按钮后、直连界面或启动器的“开始游戏”页粘贴该代码。",
"horizon.friends.copied": "已复制",
"horizon.friends.copy_code": "复制代码",
"horizon.friends.stop_sharing": "停止共享",
"horizon.friends.none_yet": "还没有好友加入。",
"horizon.friends.here_now": "当前在线:%s",
"horizon.friends.share_intro": "通过邀请码共享这个世界:它会开放到局域网,互联网上的好友可以用代码加入。不经过我们的任何服务器。",
"horizon.friends.start_sharing": "开始共享",
"horizon.friends.troubleshooting": "疑难解答…",
"horizon.friends.stopped": "已停止共享邀请码。",
"horizon.friends.short_code_changed": "有人反复尝试错误的短代码,因此已更换。",
"horizon.friends.wrong_code": "有人尝试了错误的代码。",
"horizon.friends.code_ready": "你的邀请码已就绪:游戏菜单 → 与好友一起玩。复制后发给好友。",
"horizon.friends.share_failed": "无法共享世界:%s",
"horizon.friends.finding_route": "正在寻找好友连接你的方式…",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "空格",
"horizon.osk.delete": "删除",
"horizon.osk.type": "输入",
"horizon.osk.symbols": "符号",
"horizon.osk.close": "关闭",
"horizon.extras.chat_animation.tooltip": "新聊天行从左侧滑入并淡入,而不是整行直接出现。",
"horizon.extras.chat_indicator.tooltip": "聊天行左侧的彩色竖条。",
"horizon.extras.splitscreen.tooltip": "在世界中,另一个手柄可以以新玩家身份加入:提示时按 A,然后选择档案和游玩位置。"
}
+1
View File
@@ -1398,6 +1398,7 @@ pub fn mob_from_nbt(
"minecraft:max_health" => m.attrs.max_health = v as f32,
"minecraft:movement_speed" => m.attrs.speed = v,
"minecraft:attack_damage" => m.attrs.attack_damage = v as f32,
"minecraft:attack_knockback" => m.attrs.attack_knockback = v,
_ => {}
}
}
+36 -11
View File
@@ -1679,7 +1679,8 @@ impl LevelAccess for GameLevel<'_> {
/// FallingBlockEntity carrying a block state.
#[derive(Clone, Debug)]
struct FallingBlock {
/// The entity id a server tracks it by (0 until it is tracked).
/// Its entity id: from the entity counter on the server, the server's on
/// a client.
id: u32,
state: BlockStateId,
/// `DATA_START_POS`: spawn position used to select weighted block models.
@@ -4871,8 +4872,9 @@ impl Game<'_> {
f64::from(pos.y),
f64::from(pos.z) + 0.5,
);
self.next_entity += 1;
self.falling.push(FallingBlock {
id: 0,
id: self.next_entity,
state,
start_pos: pos,
position: p,
@@ -7769,9 +7771,16 @@ impl Game<'_> {
// macOS warps by re-associating the mouse with the pointer,
// which undoes a Locked grab and leaves the pointer free: warp
// first there. Wayland moves a pointer only while it is locked,
// so everywhere else warp after grabbing.
// so everywhere else warp after grabbing (Windows does both).
#[cfg(target_os = "macos")]
warp(window);
// Windows clips a Locked pointer to a 1x1 rectangle where it is, and `SetCursorPos`
// stays inside that clip: warp first, or the pointer stays frozen wherever it was
// (over another window, on another monitor) instead of the window's centre (#6076).
#[cfg(windows)]
if self.focused {
warp(window);
}
self.captured = window
.set_cursor_grab(CursorGrabMode::Locked)
.or_else(|_| window.set_cursor_grab(CursorGrabMode::Confined))
@@ -11199,7 +11208,7 @@ impl Game<'_> {
if landed {
if knockback > 0.0 {
let (xd, zd) = self.extra_knockback_direction();
self.mobs[index].knockback(f64::from(knockback), xd, zd, &source);
self.mobs[index].knockback_effect(f64::from(knockback), xd, zd, &source, amount);
self.stop_sprint_after_extra_knockback();
}
let mob = &self.mobs[index];
@@ -13198,18 +13207,24 @@ impl Game<'_> {
});
}
self.orbs = orbs;
for o in taken {
self.add_pickup(pickups::Taken::Orb(Box::new(o)), pickups::Collector::Player);
}
if gained > 0 {
gained = self.mending(gained);
}
if gained > 0 {
let at = self.eye_position();
for o in taken {
// ClientPacketListener.handleTakeItemEntity: every orb taken sounds
// at the orb, whether mending took its points or not.
let pitch =
(self.world.random.next_float() - self.world.random.next_float()) * 0.35 + 0.9;
self.audio
.play("minecraft:entity.experience_orb.pickup", at, 0.1, pitch);
let at = [o.position.x, o.position.y, o.position.z];
self.audio.play(
"minecraft:entity.experience_orb.pickup",
Some(at),
0.1,
pitch,
);
self.add_pickup(pickups::Taken::Orb(Box::new(o)), pickups::Collector::Player);
}
if gained > 0 {
self.give_orb_points(gained);
}
}
@@ -15611,6 +15626,16 @@ impl Game<'_> {
Screen::DebugOptions => self.close_debug_options(),
Screen::Mod => self.close_mod_screen(),
},
// `checkHotbarKeyPressed`: the keys the controls bind to the off
// hand and the hotbar slots, so a rebound key swaps instead of
// reaching the screen. The list below keeps Q and the defaults.
_ if (self.screen == Screen::Inventory
|| (self.screen == Screen::Creative && !self.creative_screen.tab().search))
&& self
.settings
.keys
.container_hotbar_key(key)
.is_some_and(|key| self.inventory_hotkey(key)) => {}
KeyCode::Digit1
| KeyCode::Digit2
| KeyCode::Digit3
@@ -344,7 +344,7 @@ impl Game<'_> {
.map_or([1, 1], |r| [r.config.width, r.config.height]);
let b = HudBatch::new(size);
let cursor = self.cursor;
let shift = self.keys.contains(&KeyCode::ShiftLeft);
let shift = self.has_shift_down();
let alt = self.keys.contains(&KeyCode::AltLeft);
let atlas_click = |game: &Game<'_>, edit: &mut EditBox| {
edit.click(&game.gui.atlas, cursor.0, false, false);
@@ -437,8 +437,8 @@ impl Game<'_> {
shortcut: Option<horizon_client::clipboard::EditShortcut>,
text: Option<&str>,
) {
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
let shift = self.keys.contains(&KeyCode::ShiftLeft);
let ctrl = self.has_control_down_with_quirk();
let shift = self.has_shift_down();
let types_text = self.types_text();
let size = self
.renderer
@@ -803,8 +803,8 @@ impl Game<'_> {
shortcut: Option<horizon_client::clipboard::EditShortcut>,
text: Option<&str>,
) {
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
let shift = self.keys.contains(&KeyCode::ShiftLeft);
let ctrl = self.has_control_down_with_quirk();
let shift = self.has_shift_down();
// Windows' AltGr is Ctrl+Alt, and its characters still type.
let types_text = self.types_text();
let Some(mut s) = self.command_block_screen.take() else {
+34 -1
View File
@@ -65,6 +65,8 @@ pub(super) enum Target {
Dragon(u32),
/// A primed TNT, by its entity id (`minecraft:tnt`).
Tnt(u32),
/// A falling block, by its entity id (`minecraft:falling_block`).
Falling(u32),
}
/// An entity of a type Horizon does not simulate: its compound lives in the
/// entity memory (`persist.raw`), its position and rotation here.
@@ -138,7 +140,8 @@ fn non_player_entity_id(t: Target) -> Option<i32> {
| Target::Other(id)
| Target::Crystal(id)
| Target::Dragon(id)
| Target::Tnt(id)) = t
| Target::Tnt(id)
| Target::Falling(id)) = t
else {
return None;
};
@@ -517,6 +520,11 @@ impl Game<'_> {
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.position.y, t.position.z]),
Target::Falling(id) => self
.falling
.iter()
.find(|f| f.id == id)
.map(|f| [f.position.x, f.position.y, f.position.z]),
}
}
pub(super) fn target_type(&self, t: Target) -> String {
@@ -564,6 +572,7 @@ impl Game<'_> {
Target::Crystal(_) => "minecraft:end_crystal".into(),
Target::Dragon(_) => "minecraft:ender_dragon".into(),
Target::Tnt(_) => "minecraft:tnt".into(),
Target::Falling(_) => "minecraft:falling_block".into(),
}
}
/// The name of the player the game acts for: a guest's own while their
@@ -598,6 +607,18 @@ impl Game<'_> {
.unwrap_or_default();
Text::literal(self.pack.names.display_item(&item))
}
// FallingBlockEntity.getTypeName: `entity.minecraft.falling_block_type`
// with the block's name.
Target::Falling(id) => {
let block = self
.falling
.iter()
.find(|f| f.id == id)
.map(|f| self.res.facts().get(f.state).name.replace(':', "."))
.unwrap_or_default();
let block = Text::translate(&format!("block.{block}"), &[]);
Text::translate("entity.minecraft.falling_block_type", &[block])
}
Target::Mob(id)
if self
.mobs
@@ -818,6 +839,7 @@ impl Game<'_> {
// The kinds /data can write (entity_nbt).
.filter(|p| {
p.is_arrow()
|| horizon_client::entity_nbt::saved_with_chunk(p)
|| matches!(
p.kind,
K::LargeFireball { .. }
@@ -841,6 +863,7 @@ impl Game<'_> {
candidates.push(Target::Dragon(d.id));
}
candidates.extend(self.tnts.iter().map(|t| Target::Tnt(t.id)));
candidates.extend(self.falling.iter().map(|f| Target::Falling(f.id)));
candidates
}
/// The loaded levels, in the order the server holds them: the
@@ -873,6 +896,7 @@ impl Game<'_> {
Target::Crystal(id),
Target::Dragon(id),
Target::Tnt(id),
Target::Falling(id),
]
.into_iter()
.find(|t| self.level_holds(*t))
@@ -895,6 +919,7 @@ impl Game<'_> {
Target::Crystal(id) => self.crystals.iter().any(|c| c.id == id),
Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id),
Target::Tnt(id) => self.tnts.iter().any(|t| t.id == id),
Target::Falling(id) => self.falling.iter().any(|f| f.id == id),
}
}
/// Run `f` with the level that holds the entity a target names in place
@@ -1511,6 +1536,12 @@ impl Game<'_> {
t.previous = v;
}
}
Target::Falling(id) => {
if let Some(f) = self.falling.iter_mut().find(|f| f.id == id) {
f.position = v;
f.previous = v;
}
}
// Wall-mounted entities do not move.
Target::Frame(_) | Target::Painting(_) => {}
}
@@ -2009,6 +2040,7 @@ impl Game<'_> {
Target::Crystal(id) => self.kill_crystal(id),
Target::Dragon(id) => self.kill_dragon(id),
Target::Tnt(id) => self.tnts.retain(|t| t.id != id),
Target::Falling(id) => self.falling.retain(|f| f.id != id),
}
// The orbs marked for removal are gone (Entity.kill removes them).
let before = self.orb_ids();
@@ -3681,6 +3713,7 @@ mod look_at_tests {
Target::Crystal(7),
Target::Dragon(7),
Target::Tnt(7),
Target::Falling(7),
] {
assert_eq!(non_player_entity_id(t), Some(7), "{t:?}");
}
@@ -164,7 +164,8 @@ impl Game<'_> {
.and_then(|a| a.last_state)
.map(|s| self.state_to_nbt(s));
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))?
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))
.or_else(|| entity_nbt::throwable_to_nbt(p, random_uuid(), owner, None))?
}
Target::Crystal(id) => {
let c = self.crystals.iter().find(|c| c.id == id)?;
@@ -188,6 +189,24 @@ impl Game<'_> {
self.persist.raw.get(&id),
)
}
Target::Falling(id) => {
let f = self.falling.iter().find(|f| f.id == id)?.clone();
let (uuid, raw) = match &f.saved {
Some(saved) => (saved.0, Some(&saved.1)),
None => (self.uuid_for(id), None),
};
let tag = entity_nbt::falling_block_to_nbt(
self.state_to_nbt(f.state),
f.position,
f.velocity,
f.age,
f.drop_item,
f.damage,
f.fall_distance,
uuid,
);
crate::game::persist::with_raw(tag, raw)
}
})
}
/// `Entity.load` of new data (`EntityDataAccessor.setData`): the entity is
@@ -788,6 +807,10 @@ impl Game<'_> {
Some(Target::Cart(id))
} else if self.others.iter().any(|o| o.id == id) {
Some(Target::Other(id))
} else if self.tnts.iter().any(|t| t.id == id) {
Some(Target::Tnt(id))
} else if self.falling.iter().any(|f| f.id == id) {
Some(Target::Falling(id))
} else {
None
}
+115 -12
View File
@@ -4,6 +4,7 @@
use super::{Game, creatures::seen};
use horizon_client::{
Result,
chunk_map::{ChunkMap, ChunkMembershipDelta},
containers::{BlockEntity, CreakingHeart},
mobs::{
Mob, MobType,
@@ -302,24 +303,30 @@ impl Game<'_> {
&& self.ui.rules.bool("spawn_monsters")
}
/// Hearts that came with generated or loaded chunks get their block
/// entity (`newBlockEntity`) the first time their chunk is seen.
/// entity (`newBlockEntity`) the first time their chunk is seen lit.
/// Only the chunks that arrived since are looked at: a server holds tens
/// of thousands, and reading each one every tick cost milliseconds.
pub(super) fn scan_for_hearts(&mut self) {
let Some(heart) = self
if self
.pack
.assets
.registry
.default_state("minecraft:creaking_heart")
else {
.is_none()
{
return;
};
let keys: Vec<(i32, i32)> = self
.world
.chunks
}
self.creaking.follow_chunks(&self.world.chunks);
let mut keys: Vec<(i32, i32)> = self
.creaking
.unscanned
.iter()
.filter(|(k, c)| c.lit && !self.creaking.scanned.contains(k))
.map(|(k, _)| *k)
.copied()
.filter(|k| self.world.chunks.get(k).is_some_and(|c| c.lit))
.collect();
keys.sort_unstable();
for key in keys {
self.creaking.unscanned.remove(&key);
self.creaking.scanned.insert(key);
let Some(chunk) = self.world.chunks.get(&key).cloned() else {
continue;
@@ -334,7 +341,6 @@ impl Game<'_> {
.collect();
}
let hearts = &self.creaking.hearts;
let _ = heart;
let is_heart = |id: u32| hearts.get(id as usize).copied().unwrap_or(false);
for (si, section) in chunk.sections.iter().enumerate() {
// Only a section whose palette holds a heart state can have
@@ -359,8 +365,6 @@ impl Game<'_> {
}
}
}
let chunks = &self.world.chunks;
self.creaking.scanned.retain(|k| chunks.contains_key(k));
}
}
@@ -369,6 +373,10 @@ impl Game<'_> {
pub(super) struct CreakingState {
/// Chunks already scanned for heart blocks.
scanned: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
/// Resident chunks not scanned yet (not lit yet, or just arrived).
unscanned: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
/// The chunk map's membership revision the two sets follow.
revision: Option<u64>,
/// Which block states are creaking hearts, by state id.
hearts: Vec<bool>,
}
@@ -378,5 +386,100 @@ impl CreakingState {
/// (game/levels.rs).
pub(super) fn swap_level(&mut self, other: &mut Self) {
std::mem::swap(&mut self.scanned, &mut other.scanned);
std::mem::swap(&mut self.unscanned, &mut other.unscanned);
std::mem::swap(&mut self.revision, &mut other.revision);
}
/// Bring `scanned` and `unscanned` up to the chunks resident now: a
/// chunk that left is forgotten (it is scanned again if it comes back
/// later), one that arrived waits to be scanned. From the map's
/// membership changes, or from all its keys when they can't be replayed.
fn follow_chunks(&mut self, chunks: &ChunkMap) {
let revision = chunks.membership_revision();
if self.revision == Some(revision) {
return;
}
let deltas = self.revision.and_then(|r| chunks.deltas_since(r));
match deltas {
Some(deltas) => {
for (_, delta) in deltas {
match delta {
ChunkMembershipDelta::Added(key) => {
if chunks.contains_key(&key) && !self.scanned.contains(&key) {
self.unscanned.insert(key);
}
}
ChunkMembershipDelta::Removed(key) => {
if !chunks.contains_key(&key) {
self.scanned.remove(&key);
self.unscanned.remove(&key);
}
}
ChunkMembershipDelta::Reset => {}
}
}
}
None => {
self.scanned.retain(|k| chunks.contains_key(k));
self.unscanned = chunks
.keys()
.filter(|k| !self.scanned.contains(k))
.copied()
.collect();
}
}
self.revision = Some(revision);
}
}
#[cfg(test)]
mod tests {
use super::CreakingState;
use horizon_client::{chunk::Chunk, chunk_map::ChunkMap};
use std::sync::Arc;
fn add(chunks: &mut ChunkMap, key: (i32, i32)) {
chunks.insert(key, Arc::new(Chunk::empty(key.0, key.1)));
}
fn sorted(set: &horizon_client::fast_hash::FxHashSet<(i32, i32)>) -> Vec<(i32, i32)> {
let mut keys: Vec<_> = set.iter().copied().collect();
keys.sort_unstable();
keys
}
/// The heart scan follows the chunks that come and go, as the scan of
/// every resident chunk each tick did: an arrival waits, a scanned chunk
/// that stayed is not scanned again, and one that left is forgotten.
#[test]
fn the_heart_scan_follows_arrivals_and_departures() {
let mut chunks = ChunkMap::default();
let mut state = CreakingState::default();
add(&mut chunks, (0, 0));
add(&mut chunks, (1, 0));
state.follow_chunks(&chunks);
assert_eq!(sorted(&state.unscanned), [(0, 0), (1, 0)]);
// Scanned, as scan_for_hearts does once a chunk is lit.
state.unscanned.remove(&(0, 0));
state.scanned.insert((0, 0));
add(&mut chunks, (2, 0));
chunks.remove(&(1, 0));
// Gone and back between two ticks: still scanned, as before.
chunks.remove(&(0, 0));
add(&mut chunks, (0, 0));
state.follow_chunks(&chunks);
assert_eq!(sorted(&state.scanned), [(0, 0)]);
assert_eq!(sorted(&state.unscanned), [(2, 0)]);
// Gone over a tick: forgotten, and scanned again when it returns.
chunks.remove(&(0, 0));
state.follow_chunks(&chunks);
assert!(state.scanned.is_empty());
add(&mut chunks, (0, 0));
state.follow_chunks(&chunks);
assert_eq!(sorted(&state.unscanned), [(0, 0), (2, 0)]);
// A cleared map can't be replayed: rebuilt from its keys.
chunks.clear();
add(&mut chunks, (5, 5));
state.follow_chunks(&chunks);
assert_eq!(sorted(&state.unscanned), [(5, 5)]);
assert!(state.scanned.is_empty());
}
}
@@ -330,8 +330,7 @@ impl Game<'_> {
self.debug_options = Some(s);
return;
}
let shift =
self.keys.contains(&KeyCode::ShiftLeft) || self.keys.contains(&KeyCode::ShiftRight);
let shift = self.has_shift_down();
s.search.focused = ui::hovered(
self.cursor,
s.search.x,
@@ -431,8 +430,8 @@ impl Game<'_> {
return;
}
let shortcut = self.edit_shortcut(event);
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
let shift = self.keys.contains(&KeyCode::ShiftLeft);
let ctrl = self.has_control_down_with_quirk();
let shift = self.has_shift_down();
let types_text = self.types_text();
let Some(s) = &mut self.debug_options else {
return;
+29 -3
View File
@@ -278,6 +278,7 @@ pub(super) struct Dragon {
/// `lastHurtByPlayer` with its 100 tick memory: the player credited with
/// the kill, and the dragon tick of their last hit.
hurt_by_player: Option<(FightWho, i32)>,
pub last_source: Option<(&'static str, Option<i32>, Option<i32>)>,
pub death_time: i32,
pub flap: f32,
pub old_flap: f32,
@@ -406,6 +407,7 @@ impl Dragon {
invulnerable: 0,
last_hurt: 0.0,
hurt_by_player: None,
last_source: None,
death_time: 0,
flap: 0.0,
old_flap: 0.0,
@@ -1182,6 +1184,15 @@ impl Game<'_> {
let pitch = 0.8 + self.world.random.next_float() * 0.3;
self.audio
.play("minecraft:entity.ender_dragon.hurt", Some(at), 5.0, pitch);
if let Some(s) = source {
d.last_source = Some((
s.damage_type,
(s.causing != 0).then_some(s.causing),
(s.direct != 0).then_some(s.direct),
));
} else {
d.last_source = Some(("minecraft:generic", None, None));
}
}
hurt
}
@@ -3587,9 +3598,14 @@ impl Game<'_> {
}
}
}
/// The remote dragon, if `id` is it.
/// The remote dragon, if `id` is it or one of its parts.
fn remote_dragon_mut(&mut self, id: i32) -> Option<&mut Dragon> {
self.dragon.as_mut().filter(|d| d.id as i32 == id)
let Ok(uid) = u32::try_from(id) else {
return None;
};
self.dragon
.as_mut()
.filter(|d| d.id == uid || (d.id != 0 && (d.id + 1..=d.id + 8).contains(&uid)))
}
/// `handleSetEntityData`: LivingEntity.DATA_HEALTH_ID and
/// EnderDragon.DATA_PHASE (`onSyncedDataUpdated` sets the phase).
@@ -3614,13 +3630,15 @@ impl Game<'_> {
/// `handleHurtAnimation` and `handleDamageEvent`: the red flash.
pub(super) fn remote_dragon_hurt(&mut self, id: i32) {
if let Some(d) = self.remote_dragon_mut(id) {
// `LivingEntity.handleDamageEvent`: the red flash. Its sound is the
// server's (`playHurtSound`); a client plays one only for its own player.
d.hurt_time = 10;
}
}
/// `handleRemoveEntities`, or a crystal removed: the healing beam
/// keeps pointing at the same crystal.
pub(super) fn remove_remote_dragon(&mut self, id: i32) {
if self.remote_dragon_mut(id).is_some() {
if self.dragon.as_ref().is_some_and(|d| d.id as i32 == id) {
self.dragon = None;
return;
}
@@ -4377,4 +4395,12 @@ mod tests {
let t = nearest_enemy(&players, false, Vec3::new(9.0, 64.0, 0.0), None, |_| true);
assert_eq!(t.map(|p| p.who), Some(FightWho::Host));
}
#[test]
fn a_landed_hurt_sets_the_dragon_hurt_flash_time() {
let mut d = dragon_as(42);
assert_eq!(d.hurt_time, 0);
let hurt = d.hurt(0, 5.0, HIT, false);
assert_eq!(hurt, Hurt::Landed { full: true });
assert_eq!(d.hurt_time, 10);
}
}
+16
View File
@@ -2725,6 +2725,22 @@ impl Game<'_> {
}
seen.insert(m.id, (m.hurt_time, dead, swing));
}
if let Some(d) = self.dragon.as_ref().filter(|d| d.id != 0) {
let id = d.id;
let dead = !d.alive();
let old = host
.mob_state
.get(&self.dim)
.and_then(|s| s.get(&id))
.copied();
let at = [d.position.x, d.position.y, d.position.z];
if d.hurt_time > old.map_or(0, |o| o.0) && !dead {
let (kind, cause, direct) =
d.last_source.unwrap_or(("minecraft:generic", None, None));
host.hub.damage_event(id as i32, kind, cause, direct, at);
}
seen.insert(id, (d.hurt_time, dead, None));
}
// LivingEntity.hurtServer for the in-frame LAN host's player (guests
// broadcast directly from HealthEvent::DamageBroadcast): a hit that
// restarted the hurt time and was not blocked by a shield broadcasts
+1 -1
View File
@@ -1188,7 +1188,7 @@ impl Game<'_> {
let PhysicalKey::Code(key) = event.physical_key else {
return;
};
let ctrl = self.keys.contains(&winit::keyboard::KeyCode::ControlLeft);
let ctrl = self.has_control_down_with_quirk();
let shortcut = self.edit_shortcut(event);
let types_text = self.types_text();
let shift = self.has_shift_down();
+19 -1
View File
@@ -80,6 +80,9 @@ pub(super) struct Parked {
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
lan_critical_hits: Vec<(Option<u32>, i32, bool)>,
/// The hub's tracked entities of this level (`LanHost::states`), as its last tick described
/// them: what its players' moves are checked against before this tick describes them again.
lan_states: horizon_client::lan::entity::States,
/// Only the per-level parts of these are used (`swap_level`).
anims: BlockEntityAnims,
raiding: Raiding,
@@ -213,6 +216,7 @@ impl Game<'_> {
lan_block_events: Vec::new(),
lan_entity_events: Vec::new(),
lan_critical_hits: Vec::new(),
lan_states: Default::default(),
anims: Default::default(),
raiding: Default::default(),
sculk: Default::default(),
@@ -291,7 +295,11 @@ impl Game<'_> {
/// The level that was in place is kept with the others; the shared
/// clock and weather go with the swap. `host` is the hub when the caller
/// holds it (`self.lan` is taken during the network tick).
pub(super) fn enter_level(&mut self, host: Option<&mut LanHost>, target: Dim) -> Result<()> {
pub(super) fn enter_level(
&mut self,
mut host: Option<&mut LanHost>,
target: Dim,
) -> Result<()> {
// The in-frame game keeps its one level (`enter_dimension`).
if self.options.execution_role.is_client() {
return Ok(());
@@ -310,6 +318,16 @@ impl Game<'_> {
// The sculk tables' watched blocks are built once per game.
p.world.watched = std::mem::take(&mut self.world.watched);
self.swap_level(&mut p);
// The tracked entities go with their level: a level's moves, read before its tick
// describes its entities again, would otherwise meet the last level's (the anticheat
// missed the boat under a player while anyone was in another dimension).
let states = match host.as_deref_mut() {
Some(h) => Some(&mut h.states),
None => self.lan.as_mut().map(|h| &mut h.states),
};
if let Some(states) = states {
std::mem::swap(states, &mut p.lan_states);
}
p.idle = 0;
let left = std::mem::replace(&mut self.dim, target);
self.levels.parked.insert(left, p);
@@ -114,7 +114,8 @@ fn target_id(t: Target) -> Option<u32> {
| Target::Other(id)
| Target::Crystal(id)
| Target::Dragon(id)
| Target::Tnt(id) => Some(id),
| Target::Tnt(id)
| Target::Falling(id) => Some(id),
Target::Player | Target::Guest(_) => None,
}
}
@@ -91,8 +91,9 @@ impl Game<'_> {
damage.max = 40;
}
let at = Vec3::new(pos[0], pos[1], pos[2]);
self.next_entity += 1;
self.falling.push(FallingBlock {
id: 0,
id: self.next_entity,
state,
start_pos: cell,
position: at,
@@ -5429,6 +5429,27 @@ mod tests {
}
}
#[test]
fn native_loot_kinds_stay_hookable() {
let source = include_str!("../../../horizon-world/src/loot.rs");
for hook in [
"pub fn native_loot_function(",
"pub fn native_loot_condition(",
] {
let at = source
.find(hook)
.unwrap_or_else(|| panic!("{hook} not found"));
let start = source[..at].rfind("///").unwrap();
let body = &source[start..start + source[start..].find("\n}\n").unwrap()];
for line in body.lines() {
assert!(!line.contains("cfg"), "a cfg in a loot hook point: {line}");
for mac in ["!(", "![", "! {", "!{"] {
assert!(!line.contains(mac), "a macro in a loot hook point: {line}");
}
}
}
}
#[test]
fn loot_table_load_stays_hookable() {
let source = include_str!("../../../horizon-world/src/loot.rs");
+5 -2
View File
@@ -402,8 +402,11 @@ impl Game<'_> {
&& let Some(state) = self.state_from_nbt(&state)
{
let (no_gravity, drop_item) = entity_nbt::falling_block_flags(&tag);
self.next_entity += 1;
// A summoned one has no UUID yet: `Entity` makes a random one.
let uuid = uuid.unwrap_or_else(|| self.uuid_for(self.next_entity));
self.falling.push(FallingBlock {
id: 0,
id: self.next_entity,
state,
// DATA_START_POS is not persisted by vanilla.
start_pos: horizon_core::position::BlockPos::default(),
@@ -427,7 +430,7 @@ impl Game<'_> {
&tag,
self.res.facts().get(state).name.ends_with("anvil"),
),
saved: uuid.map(|u| Box::new((u, tag.clone()))),
saved: Some(Box::new((uuid, tag.clone()))),
});
}
}
+115 -7
View File
@@ -3602,10 +3602,14 @@ impl Game<'_> {
}
C::TakeItemEntity(p) => {
// ClientPacketListener.handleTakeItemEntity: the pickup sound at
// the item or arrow, whoever collects it (the server sends none).
// An orb's sound comes from the server, at the orb; an entity
// this client doesn't track sounds at this player.
let orb = self.orbs.iter().any(|o| o.id == p.item as u32);
// the orb, item or arrow, whoever collects it (the server sends
// none); an entity this client doesn't track sounds at this player.
let orb_at = self
.orbs
.iter()
.find(|o| o.id == p.item as u32)
.map(|o| o.position);
let orb = orb_at.is_some();
let item = self.dropped.iter().find(|d| d.id == p.item as u32);
let shot = self
.creatures
@@ -3631,7 +3635,17 @@ impl Game<'_> {
}
(None, None) => None,
};
if !orb && let Some(at) = at {
if let Some(o) = orb_at {
let pitch = (self.world.random.next_float() - self.world.random.next_float())
* 0.35
+ 0.9;
self.audio.play(
"minecraft:entity.experience_orb.pickup",
Some([o.x, o.y, o.z]),
0.1,
pitch,
);
} else if let Some(at) = at {
let pitch = ((self.world.random.next_float() - self.world.random.next_float())
* 0.7
+ 1.0)
@@ -7329,6 +7343,13 @@ impl Game<'_> {
if self.keys.contains(&KeyCode::ShiftLeft) && hands_full {
return (InteractionResult::Pass, DryUse::default());
}
// AbstractCauldronBlock.useItemOn (CauldronInteractions): a bucket,
// bottle or dye the cauldron takes fills or lowers it, and the click
// ends there instead of falling through to Item.use (which would
// empty a water bucket into the face of the cauldron).
if let Some(reacts) = self.remote_cauldron_reacts(hit, held) {
return reacts;
}
if self.jukebox_takes(hit.pos, held) {
return (InteractionResult::Success, DryUse::default());
}
@@ -7379,6 +7400,76 @@ impl Game<'_> {
})
}
/// `AbstractCauldronBlock.useItemOn` (`CauldronInteractions`) on this
/// client's level: the cauldron's new state and the sound it made, or
/// None when the item passes and `Item.use` still has its turn. The
/// interaction player is a copy: the hand and the inventory change only
/// when the server confirms them.
fn remote_cauldron_reacts(
&self,
hit: &super::Hit,
held: Option<&ItemStack>,
) -> Option<(InteractionResult, DryUse)> {
use horizon_client::cauldron::{self, Outcome};
struct DryHand<'a> {
creative: bool,
hand: Option<ItemStack>,
inventory: &'a [Option<ItemStack>],
}
impl cauldron::Player for DryHand<'_> {
fn creative(&self) -> bool {
self.creative
}
fn hand(&mut self) -> &mut Option<ItemStack> {
&mut self.hand
}
fn add(&mut self, _: &mut ItemStack) -> bool {
true
}
fn contains(&self, stack: &ItemStack) -> bool {
self.inventory
.iter()
.flatten()
.any(|have| have.same_item_same_components(stack))
}
fn drop_stack(&mut self, _: ItemStack) {}
}
let registry = self.pack.assets.registry;
let state = registry
.state(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
.filter(|s| s.name.ends_with("cauldron"))?
.clone();
let held = held?;
let above = self.world.block(hit.pos[0], hit.pos[1] + 1, hit.pos[2]);
let under_water = registry
.state(above)
.is_some_and(|s| s.name == "minecraft:water");
let removes_dye = cauldron::removes_dye(self.items(), &held.item);
let mut hand = DryHand {
creative: self.creative,
hand: Some(held.clone()),
inventory: &self.inventory,
};
let result = cauldron::interact(&state, under_water, removes_dye, &mut hand);
let reaction = match result.outcome {
Outcome::Pass => return None,
Outcome::Success => InteractionResult::Success,
Outcome::Consume => InteractionResult::Consume,
};
let pos = horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]);
let mut changes = Vec::new();
if let Some(block) = result.block
&& let Ok(new) = registry.resolve(&block)
{
changes.push((pos, new));
}
let sounds = match result.sound {
Some(sound) => vec![(pos, std::sync::Arc::from(sound), 1.0, 1.0)],
None => Vec::new(),
};
Some((reaction, (changes, sounds)))
}
/// MultiPlayerGameMode.useItemOn inside startPrediction, for a block item:
/// the blocks `BlockItem.place` set at `pos` are placed on this client's
/// level at once, so the next click of a fast bridge aims at them rather
@@ -9370,14 +9461,15 @@ type DryUse = (DryChanges, Vec<DrySound>);
type DryPlace = (horizon_core::position::BlockPos, DryChanges);
/// The blocks a guest's hand flips that its use predicts (and hears at once): a wooden or
/// copper door, trapdoor or fence gate, a lever or a button. An iron door or trapdoor has
/// no hand use.
/// copper door, trapdoor or fence gate, a lever, a button or a cauldron, whose fill level
/// changes as the bucket, bottle or dye lands. An iron door or trapdoor has no hand use.
pub(super) fn hand_operated(name: &str) -> bool {
let name = name.trim_start_matches("minecraft:");
((name.ends_with("_door") || name.ends_with("_trapdoor")) && !name.starts_with("iron_"))
|| name.ends_with("_fence_gate")
|| name.ends_with("_button")
|| name == "lever"
|| name.ends_with("cauldron")
}
impl<'w> DryLevel<'w> {
@@ -9888,6 +9980,22 @@ mod tests {
);
}
/// A cauldron's fill level is a use this client predicts, and a guest's
/// host forwards its sound: every cauldron state counts, not only the
/// doors and levers.
#[test]
fn every_cauldron_counts_as_hand_operated() {
for name in [
"minecraft:cauldron",
"minecraft:water_cauldron",
"minecraft:lava_cauldron",
"minecraft:powder_snow_cauldron",
] {
assert!(super::hand_operated(name), "{name}");
}
assert!(!super::hand_operated("minecraft:water"));
}
/// An NPC plugin lists the NPC, spawns it, and takes the entry off the list:
/// the entity keeps the skin it found; a player spawned before its entry
/// arrives takes the entry once it comes.
@@ -180,6 +180,7 @@ impl Game<'_> {
| Target::Crystal(id)
| Target::Dragon(id)
| Target::Tnt(id)
| Target::Falling(id)
| Target::Other(id) => Some(uuid_text(self.uuid_for(id))),
// Orbs and projectiles have no UUID here.
Target::Orb(_) | Target::Projectile(_) => None,
@@ -215,6 +216,11 @@ impl Game<'_> {
| Target::Tnt(id)
| Target::Other(id) => self.persist.raw.get(&id),
Target::Player => self.level.player_extra.as_ref(),
Target::Falling(id) => self
.falling
.iter()
.find(|f| f.id == id)
.and_then(|f| f.saved.as_ref().map(|s| &s.1)),
Target::Guest(_)
| Target::Orb(_)
| Target::Projectile(_)
+23 -1
View File
@@ -782,6 +782,8 @@ impl Damage {
pub struct Attrs {
pub speed: f64,
pub attack_damage: f32,
/// `Attributes.ATTACK_KNOCKBACK`: what a melee hit adds to its push (half of it).
pub attack_knockback: f64,
pub max_health: f32,
pub follow_range: f64,
pub knockback_resistance: f64,
@@ -2508,6 +2510,7 @@ impl Mob {
attrs: Attrs {
speed: kind.speed,
attack_damage: kind.attack_damage,
attack_knockback: kind.attack_knockback,
max_health: kind.max_health,
follow_range: kind.follow_range,
knockback_resistance: kind.knockback_resistance,
@@ -2663,7 +2666,9 @@ impl Mob {
if self.species() == Species::SulfurCube
&& !sulfur_cube::accepts_damage(self, &source, amount)
{
return false;
// `SulfurCube.hurtServer` returns true for the blows it shrugs off and only knocks
// about, so `Player.attack` goes on with its extra knockback and its aftermath.
return sulfur_cube::immune_to(&source);
}
// LivingEntity.isInvulnerableTo: only #bypasses_invulnerability gets past it.
if !self.alive()
@@ -2964,6 +2969,23 @@ impl Mob {
/// took its share, before the motion changes and the draws of a random
/// direction.
pub fn knockback(&mut self, power: f64, xd: f64, zd: f64, source: &Damage) {
self.knockback_by(power, xd, zd, source, None);
}
/// `LivingEntity.knockback(power, xd, zd, source, damage, comesFromEffect = true)`: an extra
/// push of a blow (sprint, the Knockback enchantment). A sulfur cube with a block turns it
/// into its own launch (`SulfurCube.knockback`), scaled by `power * 0.25` and `sqrt(damage)`.
pub fn knockback_effect(&mut self, power: f64, xd: f64, zd: f64, source: &Damage, damage: f32) {
self.knockback_by(power, xd, zd, source, Some(damage));
}
fn knockback_by(&mut self, power: f64, xd: f64, zd: f64, source: &Damage, effect: Option<f32>) {
if let Some(damage) = effect
&& self.species() == Species::SulfurCube
&& sulfur_cube::has_body(self)
&& source.attacker.is_some()
&& sulfur_cube::knockback(self, (xd, zd), source.from, damage, Some(power))
{
return;
}
if creaking::is_creaking(self) && !creaking::can_move(self) {
return;
}
+14 -14
View File
@@ -2328,22 +2328,22 @@ pub fn do_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
seconds: burn,
});
}
let knock = super::gear::knockback(m);
if knock > 0 {
let (dx, dz) = match who {
Who::Player => cx.w.player.as_ref().map_or((0.0, 0.0), |p| {
(p.position.x - m.position.x, p.position.z - m.position.z)
}),
Who::Mob(id) => cx.w.seen(id).map_or((0.0, 0.0), |s| {
(s.position.x - m.position.x, s.position.z - m.position.z)
}),
};
let len = (dx * dx + dz * dz).sqrt().max(1e-5);
let k = f64::from(knock) * 0.5;
cx.events.push(MobEvent::Push {
// `Mob.doHurtTarget`: `causeExtraKnockback(target, getKnockback(...))`. `getKnockback` is the
// `attack_knockback` attribute plus the Knockback enchantment, halved; the push goes along the
// attacker's facing through `LivingEntity.knockback` (so the target's resistance applies), and
// slows the attacker.
let power = (m.attrs.attack_knockback as f32 + super::gear::knockback(m) as f32) / 2.0;
if power > 0.0 {
use horizon_core::geometry::trig::Mth;
let to_rad = std::f32::consts::PI / 180.0;
cx.events.push(MobEvent::Knockback {
target: who,
velocity: [dx / len * k, 0.1, dz / len * k],
power: f64::from(power),
x: f64::from((m.yaw * to_rad).msin()),
z: f64::from(-(m.yaw * to_rad).mcos()),
source,
});
m.velocity = Vec3::new(m.velocity.x * 0.6, m.velocity.y, m.velocity.z * 0.6);
}
// Zombies set their target on fire while burning (Zombie.doHurtTarget).
if m.fire_ticks > 0 && matches!(m.species(), Species::Zombie) && m.random.next_float() < 0.3 {
+116 -25
View File
@@ -32,7 +32,7 @@ pub struct Archetype {
pub explosion: Option<(i32, f32, bool)>,
/// `contact_damage` amount (`sulfur_cube_hot`).
pub contact_damage: Option<f32>,
/// The knockback resistance modifier (the attribute clamps to 0..1).
/// The knockback resistance modifier (the attribute range is -2..1; negative values add knockback).
pub knockback_resistance: f64,
/// `Attributes.BOUNCINESS` (the modifier added to a base of 0).
pub bounciness: f32,
@@ -389,7 +389,7 @@ mod immune_tests {
}
/// `#sulfur_cube_with_block_immune_to` has these too: a cube with a block
/// takes none of them, a cube without one does.
/// loses no health to any of them (and `hurtServer` still answers true), a cube without one does.
#[test]
fn a_cube_with_a_block_shrugs_off_the_whole_tag() {
for kind in [
@@ -404,7 +404,8 @@ mod immune_tests {
] {
let mut m = cube(Some("minecraft:dirt"));
let health = m.health;
assert!(!m.damage(1.0, Damage::of(kind)), "{kind:?}");
// `SulfurCube.hurtServer` returns true for these, though no health is lost.
assert!(m.damage(1.0, Damage::of(kind)), "{kind:?}");
assert_eq!(m.health, health, "{kind:?}");
let mut m = cube(None);
assert!(m.damage(1.0, Damage::of(kind)), "{kind:?}");
@@ -412,6 +413,37 @@ mod immune_tests {
}
}
/// `SulfurCube.knockback` with `comesFromEffect`: a sprint hit's extra push (power 0.5) is
/// `0.5 * 0.25` of the plain push, a Knockback II hit's (power 1.0) a quarter of it.
#[test]
fn an_effect_push_is_a_fraction_of_the_blows_own() {
let push = |scale: f32| {
launch(
(1.0, 0.5),
0.0,
4.0,
(1.0, 0.0),
[-2.0, 0.0, 0.0],
[0.0, 0.0, 0.0],
[0.0, 0.5, 0.0],
1.0,
None,
scale,
)
};
let plain = push(1.0);
assert!(plain[0].abs() > 0.01, "the plain push moves it: {plain:?}");
for (power, scale) in [(0.5_f32, 0.125_f64), (1.0, 0.25)] {
let extra = push(power * 0.25);
for axis in 0..3 {
assert!(
(extra[axis] - plain[axis] * scale).abs() < 1.0e-6,
"{power}: {extra:?} against {plain:?}"
);
}
}
}
/// outside the tag (`magic`) a cube with a block is hurt.
#[test]
fn a_cube_with_a_block_takes_magic() {
@@ -518,21 +550,10 @@ pub fn prime(m: &mut Mob, imminent: bool) -> bool {
m.extra.scratch[1] |= 1;
true
}
/// `hurtServer`: fire or `#is_explosion` (a firework rocket's burst too)
/// light an explosive cube; with a block it shrugs off blows, falls and
/// explosions and is only knocked about.
pub fn accepts_damage(m: &mut Mob, source: &Damage, amount: f32) -> bool {
if !has_body(m) {
return true;
}
if can_explode(m) && !primed(m) {
if source.is_fire() {
prime(m, false);
} else if matches!(source.kind, DamageKind::Explosion | DamageKind::Fireworks) {
prime(m, true);
}
}
let immune = matches!(
/// `#sulfur_cube_with_block_immune_to`: the damage a cube with a block shrugs off and is only
/// knocked by.
pub fn immune_to(source: &Damage) -> bool {
matches!(
source.kind,
DamageKind::PlayerAttack
| DamageKind::MaceSmash
@@ -556,8 +577,23 @@ pub fn accepts_damage(m: &mut Mob, source: &Damage, amount: f32) -> bool {
| DamageKind::Stalagmite
| DamageKind::SulfurCubeHot
| DamageKind::WindCharge
);
if immune {
)
}
/// `hurtServer`: fire or `#is_explosion` (a firework rocket's burst too)
/// light an explosive cube; with a block it shrugs off blows, falls and
/// explosions and is only knocked about.
pub fn accepts_damage(m: &mut Mob, source: &Damage, amount: f32) -> bool {
if !has_body(m) {
return true;
}
if can_explode(m) && !primed(m) {
if source.is_fire() {
prime(m, false);
} else if matches!(source.kind, DamageKind::Explosion | DamageKind::Fireworks) {
prime(m, true);
}
}
if immune_to(source) {
// `dealDefaultKnockback`, unless the damage type is tagged no_knockback.
if !source.no_knockback() {
deal_default_knockback(m, source, amount);
@@ -634,6 +670,15 @@ fn vertical_hit_angle_power_transfer(
}
(powers.0 * (1.0 - ratio), powers.1 * (1.0 + ratio))
}
/// The `knockback_resistance` attribute's value: vanilla clamps it to -2..1, and a negative one
/// (an explosive or bouncy block) adds knockback.
fn resistance(attribute: f64) -> f32 {
attribute.clamp(-2.0, 1.0) as f32
}
/// `playerPush`'s scale: `Math.max(0, 1 - knockback_resistance)`, in double like vanilla.
fn push_factor(attribute: f64) -> f64 {
(1.0 - attribute.clamp(-2.0, 1.0)).max(0.0)
}
/// What `SulfurCube.knockback` adds to the cube's movement for a blow of `damage`: the
/// archetype's `powers` (horizontal, vertical) turned by where the blow came from (`from`, the
/// direct entity: the direction of the knock) and, with the causing entity's `aim` (eye, view
@@ -649,6 +694,7 @@ fn launch(
center: [f64; 3],
height: f32,
aim: Option<([f64; 3], [f64; 3])>,
effect_scale: f32,
) -> [f64; 3] {
let (h0, v0) = powers;
let (mut xd, mut zd) = (xd as f32, zd as f32);
@@ -673,7 +719,8 @@ fn launch(
h *= 1.0 / max;
v *= 1.0 / max;
}
let k = damage.sqrt();
// `Mth.sqrt(damage) * (comesFromEffect ? power * 0.25 : 1)`.
let k = damage.sqrt() * effect_scale;
h *= k;
v *= k;
h *= 1.0 - resistance;
@@ -702,14 +749,20 @@ fn deal_default_knockback(m: &mut Mob, source: &Damage, damage: f32) {
.map_or((0.0, 0.0), |f| (f[0] - m.position.x, f[2] - m.position.z)),
};
if source.attacker.is_some() {
knockback(m, (xd, zd), source.from, damage);
knockback(m, (xd, zd), source.from, damage, None);
} else {
m.knockback(f64::from(0.4_f32), xd, zd, source);
}
}
/// `SulfurCube.knockback` with a block and a causing entity: the archetype's powers, turned by
/// where the blow came from and by the entity's aim (`Mob::attacker_aim`, from the game).
pub fn knockback(m: &mut Mob, direction: (f64, f64), from: Option<[f64; 3]>, damage: f32) -> bool {
pub fn knockback(
m: &mut Mob,
direction: (f64, f64),
from: Option<[f64; 3]>,
damage: f32,
effect_power: Option<f64>,
) -> bool {
let Some(a) = archetype(m) else {
return false;
};
@@ -717,7 +770,7 @@ pub fn knockback(m: &mut Mob, direction: (f64, f64), from: Option<[f64; 3]>, dam
let from = from.unwrap_or([m.position.x, m.position.y, m.position.z]);
let delta = launch(
(a.horizontal_power, a.vertical_power),
a.knockback_resistance.clamp(0.0, 1.0) as f32,
resistance(a.knockback_resistance),
damage,
direction,
aim.map_or(from, |a| a.feet),
@@ -725,6 +778,7 @@ pub fn knockback(m: &mut Mob, direction: (f64, f64), from: Option<[f64; 3]>, dam
m.center(),
m.height(),
aim.map(|a| (a.eye, a.look)),
effect_power.map_or(1.0, |p| p as f32 * 0.25),
);
m.velocity = Vec3::new(
m.velocity.x + delta[0],
@@ -842,7 +896,7 @@ fn player_push(m: &mut Mob, p: &super::PlayerView, events: &mut Vec<MobEvent>) {
return;
}
let a = archetype(m);
let kb = (1.0 - a.map_or(0.0, |a| a.knockback_resistance.clamp(0.0, 1.0))).max(0.0);
let kb = push_factor(a.map_or(0.0, |a| a.knockback_resistance));
let len = horizontal.max(1.0e-4);
let speed = (p.velocity.length() * 2.0 * f64::from(0.3f32)).clamp(0.0, 0.5);
let push = Vec3::new(
@@ -1286,6 +1340,7 @@ mod launch_tests {
center,
height,
c.aim,
1.0,
);
for axis in 0..3 {
assert!(
@@ -1297,3 +1352,39 @@ mod launch_tests {
}
}
}
#[cfg(test)]
mod resistance_tests {
use super::{launch, push_factor, resistance};
#[test]
fn negative_resistance_adds_knockback() {
assert_eq!(resistance(-1.0), -1.0);
assert_eq!(resistance(-2.0), -2.0);
assert_eq!(resistance(-5.0), -2.0);
assert_eq!(resistance(3.0), 1.0);
assert_eq!(push_factor(-1.0), 2.0);
assert_eq!(push_factor(-2.0), 3.0);
assert_eq!(push_factor(1.0), 0.0);
assert_eq!(push_factor(0.3), 1.0 - 0.3);
let push = |r: f64, damage: f32| {
launch(
(1.0, 0.6),
resistance(r),
damage,
(1.0, 0.0),
[0.0, 64.0, 0.0],
[1.0, 64.0, 0.0],
[1.0, 64.5, 0.0],
1.0,
None,
1.0,
)
};
let base = push(0.0, 4.0);
let explosive = push(-1.0, 4.0);
let bouncy = push(-2.0, 4.0);
assert!((explosive[0] / base[0] - 2.0).abs() < 1.0e-6);
assert!((bouncy[0] / base[0] - 3.0).abs() < 1.0e-6);
}
}
+47
View File
@@ -1551,6 +1551,31 @@ impl LootContext<'_> {
// does not have: held true (a double plant's other half is taken
// as there). Unknown types fail, as in loot.rs.
"minecraft:location_check" => true,
// A mod's own kind: the condition hook answers. This engine
// (block drops, entity drops, loot_table, interact_loot) has
// no LootStack, so the function hook isn't here yet.
k if !c.is_null() && k.contains(':') && !k.starts_with("minecraft:") => {
let ctx = horizon_world::loot::NativeLootContext {
entity: None,
damage: None,
player: None,
block: (!self.block.is_empty()).then_some(self.block),
tool: self.held.map(|h| horizon_world::loot::NativeLootTool {
item: h.item.to_string(),
enchantments: h
.enchantments()
.iter()
.map(|(id, lvl)| (id.to_string(), *lvl as i32))
.collect(),
}),
luck: 0.0,
biome: None,
seed: random.next_long(),
};
let mut holds = false;
horizon_world::loot::native_loot_condition(k, c, &ctx, &mut holds);
holds
}
_ => false,
}
}
@@ -2111,6 +2136,28 @@ mod tests {
}
}
/// A mod's kind nobody claims fails; a reference by name isn't a kind,
/// and fails too.
#[test]
fn an_unclaimed_condition_of_a_mods_kind_fails() {
let items = Items::for_tests(HashMap::new());
let props = horizon_world::chunk::BlockProperties::new();
let table = |condition: Value| {
json!({"pools":[{"rolls":1,"entries":[
{"type":"minecraft:item","name":"minecraft:stone","condition":condition}]}]})
};
let mut random = LegacyRandom::new(7);
let mut drops = |condition: Value, block: &str| {
ctx(&items, None, &props, block)
.evaluate(&table(condition), &mut random)
.len()
};
assert_eq!(drops(json!({"type":"mymod:always"}), "minecraft:stone"), 0);
assert_eq!(drops(json!({"type":"mymod:always"}), ""), 0);
assert_eq!(drops(json!("mymod:always"), "minecraft:stone"), 0);
assert_eq!(drops(Value::Null, "minecraft:stone"), 1);
}
#[test]
fn fortune_silk_touch_and_looting() {
let items = Items::for_tests(HashMap::new());
+1
View File
@@ -20,6 +20,7 @@ mod management_tls;
mod mcp;
mod panel;
mod process;
mod query;
mod rcon;
#[cfg(test)]
mod reference;
+742
View File
@@ -0,0 +1,742 @@
//! Vanilla's GS4 query (docs/server.md, section 8): `enable-query` and `query.port` in server.properties
//! (`[properties]` in server.toml) turn on a UDP listener that answers as vanilla 26.3's
//! `net.minecraft.server.rcon.thread.QueryThreadGs4` does: a handshake (a request of type 9 answers with a
//! challenge token), then the basic stat (MOTD, game type, world, players, port, address) or the full stat
//! (key/value pairs and the player names) to a request that carries the token. The supervisor serves it, and
//! asks the game for who is online as the control API does.
//!
//! Where it differs from vanilla, on purpose: while the game isn't running nothing answers (vanilla's query
//! thread dies with its server), who is online is read at most once a second, and a session id with bytes
//! outside ASCII is echoed as UTF-8 text does (Java's `new String(bytes, UTF_8)` replaces each bad byte, Rust's
//! lossy decoder each bad run; real clients mask the id to ASCII).
use crate::{
api::{Phase, Shared},
engine,
supervisor::note,
};
use horizon_server_files::properties::{ServerProperties, SystemEntropy};
use std::{
collections::HashMap,
io,
net::{SocketAddr, UdpSocket},
path::Path,
sync::Arc,
thread,
time::{Duration, Instant},
};
/// `QueryThreadGs4.GAME_TYPE` and `GAME_ID`.
const GAME_TYPE: &str = "SMP";
const GAME_ID: &str = "MINECRAFT";
/// `QueryThreadGs4.buffer`: the most a request takes.
const MAX_PACKET: usize = 1460;
/// `CHALLENGE_CHECK_INTERVAL`: tokens are dropped this often (every one made before the check goes).
const PRUNE_EVERY: Duration = Duration::from_secs(30);
/// `RESPONSE_CACHE_TIME`: the full stat is kept this long.
const RULES_CACHE: Duration = Duration::from_secs(5);
/// How long what the game said about its players is believed.
const INFO_CACHE: Duration = Duration::from_secs(1);
/// `socket.setSoTimeout`: how often the listener wakes up to drop old tokens.
const WAKE: Duration = Duration::from_millis(500);
/// The request types: the handshake and a stat.
const HANDSHAKE: u8 = 9;
const STAT: u8 = 0;
/// A full stat request is a basic one plus four bytes of padding: magic (2), type (1), session id (4), token
/// (4), padding (4).
const FULL_STAT_LENGTH: usize = 15;
/// What a reply says about the server.
#[derive(Clone, Debug, PartialEq)]
struct Info {
motd: String,
world: String,
version: String,
players: Vec<String>,
max_players: i32,
port: u16,
host_ip: String,
}
/// A handshake's token for one address (`QueryThreadGs4.RequestChallenge`).
struct Challenge {
/// The session id the handshake carried, which the stats echo.
ident: [u8; 4],
token: i32,
}
/// The listener's state: the tokens handed out, when they are dropped next, and the last full stat.
struct Query {
challenges: HashMap<SocketAddr, Challenge>,
last_prune: Instant,
/// When the full stat was built and its bytes (the session id at 1..5 is put in again for each reply).
rules: Option<(Instant, Vec<u8>)>,
}
impl Query {
fn new(now: Instant) -> Self {
Self {
challenges: HashMap::new(),
last_prune: now,
rules: None,
}
}
/// `pruneChallenges`: every 30 seconds each token made before now goes.
fn prune(&mut self, now: Instant) {
if now.duration_since(self.last_prune) >= PRUNE_EVERY {
self.last_prune = now;
self.challenges.clear();
}
}
/// `processPacket`: the reply to `packet` from `from`, or none. `token` makes a new challenge (vanilla's
/// `nextInt(16777216)`), `info` is asked only when a stat is built.
fn answer(
&mut self,
packet: &[u8],
from: SocketAddr,
now: Instant,
token: impl FnOnce() -> i32,
info: impl FnOnce() -> Option<Info>,
) -> Option<Vec<u8>> {
self.prune(now);
if packet.len() < 3 || packet[0] != 0xFE || packet[1] != 0xFD {
return None;
}
match packet[2] {
HANDSHAKE => {
let mut ident = [0; 4];
for (at, byte) in ident.iter_mut().zip(packet.iter().skip(3)) {
*at = *byte;
}
let token = token();
self.challenges.insert(from, Challenge { ident, token });
let mut reply = vec![HANDSHAKE];
reply.extend(String::from_utf8_lossy(&ident).as_bytes());
reply.extend(token.to_string().as_bytes());
reply.push(0);
Some(reply)
}
STAT => {
let challenge = self.challenges.get(&from)?;
// `PktUtils.intFromNetworkByteArray(data, 7, length)`: big-endian, 0 when the packet is short.
let given = packet
.get(7..11)
.map_or(0, |b| i32::from_be_bytes([b[0], b[1], b[2], b[3]]));
if given != challenge.token {
return None;
}
let ident = challenge.ident;
if packet.len() == FULL_STAT_LENGTH {
let stale = self
.rules
.as_ref()
.is_none_or(|(at, _)| now.duration_since(*at) >= RULES_CACHE);
if stale {
self.rules = Some((now, full_stat(&info()?)));
}
let mut reply = self.rules.as_ref()?.1.clone();
reply[1..5].copy_from_slice(&ident);
Some(reply)
} else {
Some(basic_stat(&info()?, ident))
}
}
_ => None,
}
}
}
/// `NetworkDataOutputStream.writeString`: UTF-8 and a NUL.
fn string(out: &mut Vec<u8>, text: &str) {
out.extend(text.as_bytes());
out.push(0);
}
/// The basic stat: the type (0), the session id, then MOTD, game type, world, players online, max players,
/// the port (a little-endian short) and the host address.
fn basic_stat(info: &Info, ident: [u8; 4]) -> Vec<u8> {
let mut out = vec![STAT];
out.extend(ident);
string(&mut out, &info.motd);
string(&mut out, GAME_TYPE);
string(&mut out, &info.world);
string(&mut out, &info.players.len().to_string());
string(&mut out, &info.max_players.to_string());
out.extend(info.port.to_le_bytes());
string(&mut out, &info.host_ip);
out
}
/// The full stat (`buildRuleResponse`) with a placeholder session id: the type, the id, `splitnum` and its two
/// bytes, the key/value pairs, then the `player_` section with each name and a closing NUL.
fn full_stat(info: &Info) -> Vec<u8> {
let mut out = vec![STAT, 0, 0, 0, 0];
string(&mut out, "splitnum");
out.extend([0x80, 0]);
for (key, value) in [
("hostname", info.motd.as_str()),
("gametype", GAME_TYPE),
("game_id", GAME_ID),
("version", info.version.as_str()),
// `ServerInterface.getPluginNames` of a vanilla dedicated server.
("plugins", ""),
("map", info.world.as_str()),
("numplayers", &info.players.len().to_string()),
("maxplayers", &info.max_players.to_string()),
("hostport", &info.port.to_string()),
("hostip", info.host_ip.as_str()),
] {
string(&mut out, key);
string(&mut out, value);
}
out.extend([0, 1]);
string(&mut out, "player_");
out.push(0);
for name in &info.players {
string(&mut out, name);
}
out.push(0);
out
}
/// What server.properties makes of the query, as `QueryThreadGs4.create` reads it.
enum Setup {
/// `enable-query=false`.
Off,
/// On, but vanilla leaves it off with this warning.
Refused(String),
On {
host: String,
port: u16,
},
}
fn setup(dir: &Path) -> Result<Setup, String> {
let file = crate::config::read_properties(&dir.join(crate::config::PROPERTIES))?;
let p = ServerProperties::new(file, &mut SystemEntropy::default())?;
if !p.enable_query {
return Ok(Setup::Off);
}
let Some(port) = u16::try_from(p.query_port).ok().filter(|port| *port > 0) else {
return Ok(Setup::Refused(format!(
"Invalid query port {} found in server.properties (queries disabled)",
p.query_port
)));
};
Ok(Setup::On {
host: if p.server_ip.is_empty() {
"0.0.0.0".into()
} else {
p.server_ip
},
port,
})
}
/// Starts the query if server.properties turns it on. As in vanilla, what keeps it off is a warning in the
/// log, not a server that doesn't start.
pub fn start(shared: Arc<Shared>) -> Result<Option<SocketAddr>, String> {
// The file the game reads, as server.toml makes it.
crate::config::Config::load(&shared.dir)?
.write_properties(&shared.dir)
.map_err(|e| format!("server.properties: {e}"))?;
let (host, port) = match setup(&shared.dir)? {
Setup::Off => return Ok(None),
Setup::Refused(why) => {
note(&shared, "WARN", &why);
return Ok(None);
}
Setup::On { host, port } => (host, port),
};
let socket = match UdpSocket::bind((host.as_str(), port)) {
Ok(socket) => socket,
Err(e) => {
note(
&shared,
"WARN",
&format!("Unable to initialise query system on {host}:{port}: {e}"),
);
return Ok(None);
}
};
let address = socket.local_addr().map_err(|e| e.to_string())?;
let game = shared.clone();
let mut provider = Provider::default();
serve(socket, move || provider.info(&game)).map_err(|e| format!("query: {e}"))?;
note(&shared, "INFO", &format!("Query running on {host}:{port}"));
Ok(Some(address))
}
/// Who is online, asked of the game at most once a second.
#[derive(Default)]
struct Provider {
last: Option<(Instant, Info)>,
}
impl Provider {
fn info(&mut self, shared: &Shared) -> Option<Info> {
if let Some((at, info)) = &self.last
&& at.elapsed() < INFO_CACHE
{
return Some(info.clone());
}
let info = read_info(shared)?;
self.last = Some((Instant::now(), info.clone()));
Some(info)
}
}
/// The server as the query tells it: server.properties and the game's `server-status`. None while the game
/// isn't running or doesn't answer.
fn read_info(shared: &Shared) -> Option<Info> {
if shared.state.lock().ok()?.phase != Phase::Running {
return None;
}
let file = crate::config::read_properties(&shared.dir.join(crate::config::PROPERTIES)).ok()?;
let p = ServerProperties::new(file, &mut SystemEntropy::default()).ok()?;
let status = engine::Control::connect(&shared.dir)
.and_then(|mut game| game.ask("server-status"))
.ok()?;
let players = status["players"]
.as_array()
.into_iter()
.flatten()
.filter_map(|p| p["name"].as_str().map(str::to_owned))
.collect();
Some(Info {
motd: p.motd,
world: p.level_name,
version: horizon_core::version::VERSION_ID.to_owned(),
players,
max_players: status["max_players"]
.as_i64()
.and_then(|n| i32::try_from(n).ok())
.unwrap_or(p.max_players),
port: u16::try_from(p.server_port).unwrap_or(25565),
host_ip: if p.server_ip.is_empty() {
local_ip()
} else {
p.server_ip
},
})
}
/// The host address vanilla gives from the machine's own name when no `server-ip` is set: here the address the
/// machine reaches the internet from (a UDP socket is connected, nothing is sent), else `0.0.0.0` as vanilla
/// leaves it when it can't tell.
fn local_ip() -> String {
UdpSocket::bind("0.0.0.0:0")
.and_then(|s| s.connect("192.0.2.1:9").map(|()| s))
.and_then(|s| s.local_addr())
.map_or_else(|_| "0.0.0.0".into(), |a| a.ip().to_string())
}
/// A token as `nextInt(16777216)` makes one: 24 random bits, from the OS's random hasher seeds and the time.
fn random_token() -> i32 {
use std::hash::{BuildHasher, Hasher};
let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
hasher.write_u128(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos()),
);
(hasher.finish() & 0xFF_FFFF) as i32
}
/// Serves `socket` until the process ends. `info` says how the server is; None means nothing answers.
fn serve(
socket: UdpSocket,
mut info: impl FnMut() -> Option<Info> + Send + 'static,
) -> io::Result<()> {
socket.set_read_timeout(Some(WAKE))?;
thread::Builder::new().name("query".into()).spawn(move || {
let mut query = Query::new(Instant::now());
let mut buffer = [0u8; MAX_PACKET];
loop {
match socket.recv_from(&mut buffer) {
Ok((length, from)) => {
let reply = query.answer(
&buffer[..length],
from,
Instant::now(),
random_token,
&mut info,
);
if let Some(reply) = reply {
let _ = socket.send_to(&reply, from);
}
}
// The timeout is the wake-up that drops old tokens; a refused reply (Windows reports the
// peer's ICMP error on the next read) is the peer's business.
Err(_) => query.prune(Instant::now()),
}
}
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn address(port: u16) -> SocketAddr {
SocketAddr::from(([127, 0, 0, 1], port))
}
fn info() -> Info {
Info {
motd: "A Horizon Server".into(),
world: "world".into(),
version: "26.3".into(),
players: vec!["Kim0".into(), "Kim1".into()],
max_players: 20,
port: 25565,
host_ip: "192.168.1.10".into(),
}
}
/// A handshake request with session id `session`.
fn handshake(session: [u8; 4]) -> Vec<u8> {
let mut p = vec![0xFE, 0xFD, HANDSHAKE];
p.extend(session);
p
}
/// A stat request: the session id, the token and `padding` bytes after it (4 asks for the full stat).
fn stat(session: [u8; 4], token: i32, padding: usize) -> Vec<u8> {
let mut p = vec![0xFE, 0xFD, STAT];
p.extend(session);
p.extend(token.to_be_bytes());
p.extend(vec![0; padding]);
p
}
/// A listener that has handed out `token` to `from` for `session`.
fn after_handshake(from: SocketAddr, session: [u8; 4], token: i32) -> (Query, Instant) {
let now = Instant::now();
let mut query = Query::new(now);
query
.answer(&handshake(session), from, now, || token, || None)
.unwrap();
(query, now)
}
#[test]
fn the_handshake_answers_with_the_session_and_the_token_as_text() {
let now = Instant::now();
let mut query = Query::new(now);
let reply = query
.answer(
&handshake([1, 2, 3, 4]),
address(1),
now,
|| 9_513_671,
|| None,
)
.unwrap();
assert_eq!(reply, b"\x09\x01\x02\x03\x049513671\0");
}
#[test]
fn a_session_id_with_bytes_outside_ascii_is_echoed_as_text() {
let now = Instant::now();
let mut query = Query::new(now);
let reply = query
.answer(
&handshake([0x80, b'a', 0, 7]),
address(1),
now,
|| 5,
|| None,
)
.unwrap();
assert_eq!(reply, "\u{9}\u{FFFD}a\0\u{7}5\0".as_bytes());
}
#[test]
fn packets_that_are_not_requests_get_no_answer() {
let now = Instant::now();
let mut query = Query::new(now);
for packet in [
&[][..],
&[0xFE, 0xFD][..],
&[0xFD, 0xFE, HANDSHAKE, 1, 2, 3, 4][..],
&[0xFE, 0xFC, HANDSHAKE, 1, 2, 3, 4][..],
// A type nobody answers.
&[0xFE, 0xFD, 7, 1, 2, 3, 4][..],
] {
assert_eq!(
query.answer(packet, address(1), now, || 1, || Some(info())),
None
);
}
}
#[test]
fn a_stat_needs_the_token_the_handshake_gave_that_address() {
let (mut query, now) = after_handshake(address(1), [1, 2, 3, 4], 777);
// No handshake from this address.
assert_eq!(
query.answer(
&stat([1, 2, 3, 4], 777, 0),
address(2),
now,
|| 0,
|| Some(info())
),
None
);
// The wrong token.
assert_eq!(
query.answer(
&stat([1, 2, 3, 4], 778, 0),
address(1),
now,
|| 0,
|| Some(info())
),
None
);
// A request too short to hold one reads as token 0.
assert_eq!(
query.answer(
&[0xFE, 0xFD, STAT, 1, 2, 3, 4],
address(1),
now,
|| 0,
|| Some(info())
),
None
);
assert!(
query
.answer(
&stat([1, 2, 3, 4], 777, 0),
address(1),
now,
|| 0,
|| Some(info())
)
.is_some()
);
}
#[test]
fn the_basic_stat_is_the_motd_world_players_port_and_address() {
let (mut query, now) = after_handshake(address(1), [1, 2, 3, 4], 42);
let reply = query
.answer(
&stat([9, 9, 9, 9], 42, 0),
address(1),
now,
|| 0,
|| Some(info()),
)
.unwrap();
// The session id is the handshake's, whatever the stat request carries.
let mut expected = vec![0, 1, 2, 3, 4];
expected.extend(b"A Horizon Server\0SMP\0world\x002\x0020\0");
expected.extend(25565u16.to_le_bytes());
expected.extend(b"192.168.1.10\0");
assert_eq!(reply, expected);
}
#[test]
fn the_full_stat_lists_the_pairs_then_the_players() {
let (mut query, now) = after_handshake(address(1), [1, 2, 3, 4], 42);
let reply = query
.answer(
&stat([1, 2, 3, 4], 42, 4),
address(1),
now,
|| 0,
|| Some(info()),
)
.unwrap();
let mut expected = vec![0, 1, 2, 3, 4];
expected.extend(b"splitnum\0\x80\0");
expected.extend(
b"hostname\0A Horizon Server\0gametype\0SMP\0game_id\0MINECRAFT\0version\x0026.3\0plugins\0\0\
map\0world\0numplayers\x002\0maxplayers\x0020\0hostport\x0025565\0hostip\x00192.168.1.10\0",
);
expected.extend(b"\0\x01player_\0\0Kim0\0Kim1\0\0");
assert_eq!(reply, expected);
}
#[test]
fn any_other_length_than_fifteen_is_a_basic_stat() {
let (mut query, now) = after_handshake(address(1), [1, 2, 3, 4], 42);
for padding in [0, 1, 3, 5, 40] {
let reply = query
.answer(
&stat([1, 2, 3, 4], 42, padding),
address(1),
now,
|| 0,
|| Some(info()),
)
.unwrap();
assert!(
reply.starts_with(&[0, 1, 2, 3, 4, b'A']),
"padding {padding}"
);
}
}
#[test]
fn the_full_stat_is_kept_five_seconds_with_each_askers_session_id() {
let now = Instant::now();
let mut query = Query::new(now);
for (port, session) in [(1, [1, 1, 1, 1]), (2, [2, 2, 2, 2])] {
query
.answer(&handshake(session), address(port), now, || 5, || None)
.unwrap();
}
let first = query
.answer(
&stat([1, 1, 1, 1], 5, 4),
address(1),
now,
|| 0,
|| Some(info()),
)
.unwrap();
let mut changed = info();
changed.players = vec!["Someone".into()];
// Inside the cache the old stat is sent, with the other session id and without asking for the info.
let later = now + Duration::from_secs(4);
let second = query
.answer(
&stat([2, 2, 2, 2], 5, 4),
address(2),
later,
|| 0,
|| panic!("not asked"),
)
.unwrap();
assert_eq!(&second[1..5], [2, 2, 2, 2]);
assert_eq!(second[5..], first[5..]);
// After it, a new one.
let later = now + Duration::from_secs(5);
let third = query
.answer(
&stat([1, 1, 1, 1], 5, 4),
address(1),
later,
|| 0,
|| Some(changed),
)
.unwrap();
assert!(third.windows(7).any(|w| w == b"Someone"));
assert_ne!(third[5..], first[5..]);
}
#[test]
fn a_token_lasts_until_the_next_check_which_comes_every_thirty_seconds() {
let (mut query, now) = after_handshake(address(1), [1, 2, 3, 4], 42);
let early = now + Duration::from_secs(29);
assert!(
query
.answer(
&stat([1, 2, 3, 4], 42, 0),
address(1),
early,
|| 0,
|| Some(info())
)
.is_some()
);
// The check drops every token made before it, this one included.
let check = now + Duration::from_secs(30);
assert_eq!(
query.answer(
&stat([1, 2, 3, 4], 42, 0),
address(1),
check,
|| 0,
|| Some(info())
),
None
);
}
#[test]
fn nothing_answers_a_stat_while_the_server_is_unknown() {
let (mut query, now) = after_handshake(address(1), [1, 2, 3, 4], 42);
assert_eq!(
query.answer(&stat([1, 2, 3, 4], 42, 0), address(1), now, || 0, || None),
None
);
assert_eq!(
query.answer(&stat([1, 2, 3, 4], 42, 4), address(1), now, || 0, || None),
None
);
}
#[test]
fn the_tokens_are_24_bits() {
for _ in 0..1000 {
assert!((0..1 << 24).contains(&random_token()));
}
}
#[test]
fn a_listener_on_a_real_socket_answers_the_handshake_and_both_stats() {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
let server = socket.local_addr().unwrap();
serve(socket, || Some(info())).unwrap();
let client = UdpSocket::bind("127.0.0.1:0").unwrap();
client
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut buffer = [0u8; 2048];
let mut ask = |packet: &[u8]| {
client.send_to(packet, server).unwrap();
let (n, _) = client.recv_from(&mut buffer).unwrap();
buffer[..n].to_vec()
};
let reply = ask(&handshake([0, 1, 0, 1]));
assert_eq!(reply[0], HANDSHAKE);
assert_eq!(&reply[1..5], [0, 1, 0, 1]);
assert_eq!(reply.last(), Some(&0));
let token: i32 = std::str::from_utf8(&reply[5..reply.len() - 1])
.unwrap()
.parse()
.unwrap();
let basic = ask(&stat([0, 1, 0, 1], token, 0));
assert_eq!(basic[0], STAT);
assert!(basic[5..].starts_with(b"A Horizon Server\0"));
let full = ask(&stat([0, 1, 0, 1], token, 4));
assert!(full.windows(8).any(|w| w == b"numplayers"[..8].to_vec()));
assert!(full.ends_with(b"player_\0\0Kim0\0Kim1\0\0"));
}
#[test]
fn an_invalid_port_leaves_the_query_off_with_vanillas_warning() {
let dir = std::env::temp_dir().join(format!("horizon-query-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let write = |text: &str| std::fs::write(dir.join(crate::config::PROPERTIES), text).unwrap();
write("enable-query=false\nquery.port=0\n");
assert!(matches!(setup(&dir), Ok(Setup::Off)));
for port in ["0", "65536", "-3"] {
write(&format!("enable-query=true\nquery.port={port}\n"));
match setup(&dir) {
Ok(Setup::Refused(why)) => assert_eq!(
why,
format!(
"Invalid query port {port} found in server.properties (queries disabled)"
)
),
_ => panic!("port {port} should be refused"),
}
}
write("enable-query=true\nquery.port=25570\nserver-ip=\n");
assert!(matches!(
setup(&dir),
Ok(Setup::On { ref host, port: 25570 }) if host == "0.0.0.0"
));
std::fs::remove_dir_all(&dir).unwrap();
}
}
+1
View File
@@ -120,6 +120,7 @@ pub fn run(dir: &Path) -> Result<(), String> {
);
}
crate::rcon::start(shared.clone())?;
crate::query::start(shared.clone())?;
match config.panel.address() {
Ok(Some(address)) => match crate::panel::start(shared.clone(), address) {
Ok(()) => note(
+184
View File
@@ -60,6 +60,70 @@ impl PackOverlay<'_> {
}
}
/// A loot function of a mod's own kind: the entry named a `type` in a
/// namespace outside `minecraft:`, and the roll kept it for this call.
/// The kind, the JSON, the context and the stack to change are here.
/// API @API:loot_kinds@.
#[inline(never)]
pub fn native_loot_function(
kind: &str,
data: &Value,
ctx: &NativeLootContext<'_>,
stack: &mut LootStack,
) {
let _ = (kind, data, ctx, stack);
}
/// A loot condition of a mod's own kind: the entry named a `type` in a
/// namespace outside `minecraft:`. The kind, the JSON, the context and
/// whether it holds are here (`holds` starts `false`; a mod that claims
/// the kind sets it). API @API:loot_kinds@.
#[inline(never)]
pub fn native_loot_condition(
kind: &str,
data: &Value,
ctx: &NativeLootContext<'_>,
holds: &mut bool,
) {
let _ = (kind, data, ctx, holds);
}
/// The tool a loot context names: its item id and enchantments.
pub struct NativeLootTool {
pub item: String,
pub enchantments: Vec<(String, i32)>,
}
/// What a mod's own loot kinds see of the roll: the entity, the damage,
/// the player who killed it, the tool, the block, the luck, the biome,
/// and a seed from the roll's random. The tool and block are filled by
/// the items engine (block drops, entity drops, loot_table, interact_loot);
/// the entity, damage and player by the main loot engine; each is None in
/// the other. A table without a mod's kinds draws nothing and stays
/// bit-for-bit the same.
pub struct NativeLootContext<'a> {
/// The entity the table names (`this`), if any.
pub entity: Option<&'a LootEntity>,
/// The damage that killed it, if any.
pub damage: Option<&'a LootDamage>,
/// The player whose kill dropped the table (`LootEntity`), if any.
pub player: Option<&'a LootEntity>,
/// The tool used, if any. The items engine fills it (all of its
/// tables: block drops, entity drops, loot_table, interact_loot);
/// the main loot engine has no tool in its context.
pub tool: Option<NativeLootTool>,
/// The block a block-drop table breaks, if any. The items engine
/// fills it; the main loot engine has no block in its context.
pub block: Option<&'a str>,
/// `LootContext.getLuck`.
pub luck: f32,
/// The biome the roll is in, if any.
pub biome: Option<&'a str>,
/// A seed from the roll's random, for a mod's own draws. Drawn only
/// when a mod's kind is in the table.
pub seed: i64,
}
/// One generated item stack.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LootStack {
@@ -1255,6 +1319,13 @@ impl<R: RandomSource> Context<'_, R> {
_ => false,
}
}
// A mod's own kind: the hook answers; a bare type name is
// minecraft's own and an unknown one fails.
k if k.contains(':') && !k.starts_with("minecraft:") => {
let mut holds = false;
native_loot_condition(k, c, &self.native_ctx(), &mut holds);
holds
}
// An unknown type fails, as in items.rs `condition`.
_ => false,
},
@@ -1280,6 +1351,21 @@ impl<R: RandomSource> Context<'_, R> {
_ => stack,
}
}
/// The context a mod's own loot kinds see: drawn lazily, only when
/// the table has a mod's kind, so the random stays untouched without.
fn native_ctx(&mut self) -> NativeLootContext<'_> {
let seed = self.random.next_long();
NativeLootContext {
entity: self.entity.map(|e| &e.this),
damage: self.entity.and_then(|e| e.damage.as_ref()),
player: self.entity.and_then(|e| e.player.as_ref()),
tool: None, // The tool is not in this engine's context.
block: None,
luck: self.luck,
biome: self.biome,
seed,
}
}
fn run(&mut self, f: &Value, mut stack: LootStack) -> LootStack {
let data = self.data;
match f["type"].as_str().unwrap_or("") {
@@ -1487,6 +1573,10 @@ impl<R: RandomSource> Context<'_, R> {
stack.components.push((id.clone(), value.clone()));
}
}
// A mod's own kind: the hook changes the stack.
k if k.contains(':') && !k.starts_with("minecraft:") => {
native_loot_function(k, f, &self.native_ctx(), &mut stack);
}
// Lore and other data without random draws.
_ => {}
}
@@ -1963,6 +2053,100 @@ mod namespace_tests {
}
}
#[cfg(test)]
mod native_kinds_tests {
use super::*;
fn table(name: &str, json: &str) -> LootData {
let root = std::env::temp_dir().join(format!("loot-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join("data/minecraft/loot_table");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("test.json"), json).unwrap();
// LootData::load reads items.json too.
std::fs::write(root.join("items.json"), "{}").unwrap();
let data = LootData::load(&root, &PackOverlay::NONE).unwrap();
let _ = std::fs::remove_dir_all(&root);
data
}
/// An unclaimed condition fails (the pool drops nothing) and an
/// unclaimed function leaves the stack's count as it was.
#[test]
fn unclaimed_kinds_fail_the_condition_and_leave_the_stack() {
let data = table(
"cond",
r#"{"pools":[{"rolls":1,"condition":{"type":"mymod:always_true"},
"entries":[{"type":"minecraft:item","name":"minecraft:stone"}]}]}"#,
);
let mut random = horizon_core::random::LegacyRandom::new(42);
let items = data.roll("minecraft:test", &mut random, 0.0, None, None);
assert!(items.is_empty());
let data = table(
"func",
r#"{"pools":[{"rolls":1,"entries":[{"type":"minecraft:item","name":"minecraft:stone"}],
"modifier":[{"type":"mymod:duplicate","factor":2}]}]}"#,
);
let mut random = horizon_core::random::LegacyRandom::new(42);
let items = data.roll("minecraft:test", &mut random, 0.0, None, None);
assert_eq!(items.len(), 1);
assert_eq!(items[0].count, 1);
}
/// A table without a mod's kind doesn't shift the random; one with a
/// kind draws exactly one `next_long`; the same seed gives the same
/// result.
#[test]
fn a_mod_kind_draws_exactly_one_long_and_a_vanilla_table_none() {
// A vanilla table: the random is untouched.
let vanilla = table(
"seed_vanilla",
r#"{"pools":[{"rolls":1,"entries":[{"type":"minecraft:item","name":"minecraft:stone"}]}]}"#,
);
let mut r1 = horizon_core::random::LegacyRandom::new(100);
let mut r2 = horizon_core::random::LegacyRandom::new(100);
let _ = vanilla.roll("minecraft:test", &mut r1, 0.0, None, None);
let expected: Vec<_> = (0..5).map(|_| r2.next_long()).collect();
let got: Vec<_> = (0..5).map(|_| r1.next_long()).collect();
assert_eq!(got, expected, "vanilla table shifted the random");
// A mod kind: exactly one next_long is drawn.
let modded = table(
"seed_mod",
r#"{"pools":[{"rolls":1,"entries":[{"type":"minecraft:item","name":"minecraft:stone"}],
"modifier":[{"type":"lootmod:x"}]}]}"#,
);
let mut r3 = horizon_core::random::LegacyRandom::new(100);
let mut r4 = horizon_core::random::LegacyRandom::new(100);
let _ = modded.roll("minecraft:test", &mut r3, 0.0, None, None);
let _ = r4.next_long(); // the one draw
let after_mod: Vec<_> = (0..5).map(|_| r4.next_long()).collect();
let after_run: Vec<_> = (0..5).map(|_| r3.next_long()).collect();
assert_eq!(after_run, after_mod, "mod table drew != 1 next_long");
// Same seed, same result.
let mut r5 = horizon_core::random::LegacyRandom::new(42);
let a = modded.roll("minecraft:test", &mut r5, 0.0, None, None);
let mut r6 = horizon_core::random::LegacyRandom::new(42);
let b = modded.roll("minecraft:test", &mut r6, 0.0, None, None);
assert_eq!(a, b, "same seed, different result");
}
#[test]
fn an_unknown_minecraft_function_is_skipped() {
let data = table(
"vanilla",
r#"{"pools":[{"rolls":1,"entries":[{"type":"minecraft:item","name":"minecraft:stone"}],
"modifier":[{"type":"minecraft:future_function"},{"type":"minecraft:set_count","count":5}]}]}"#,
);
let mut random = horizon_core::random::LegacyRandom::new(42);
let items = data.roll("minecraft:test", &mut random, 0.0, None, None);
assert_eq!(items.len(), 1);
assert_eq!(items[0].count, 5);
}
}
#[cfg(test)]
mod recipe_result_tests {
use super::*;
+4
View File
@@ -39,6 +39,8 @@ address), op and deop, the whitelist, and metrics as Prometheus text.
26.3's does, and `rpc.discover` describes it.
8. A tool that speaks RCON (a hosting panel, a Discord bridge): turn on `enable-rcon` with a password in
`[properties]` ([hosting.md](../hosting.md#tools-that-speak-rcon)); a command runs as one typed in the console.
9. A tool that reads the GS4 query (a server list, a status page): turn on `enable-query` in `[properties]`
([hosting.md](../hosting.md#tools-that-read-the-query)); it answers the MOTD, the version and the players over UDP.
## Reference
@@ -90,6 +92,8 @@ addition; a change to one starts `/api/v2`, served beside `/api/v1`.
`whitelist/{add,remove}`, `metrics/prometheus`, `GET`/`PATCH config`, `DELETE backups`, `GET backups/download`;
`server.toml` gets `[api]` (`bind`, `token`, `allow_public`); `GET events` (server-sent events, or JSON).
- RCON (`enable-rcon`, `rcon.port`, `rcon.password`): vanilla's remote console, served by the supervisor.
- GS4 query (`enable-query`, `query.port`): vanilla's UDP query (handshake, basic and full stat), served by the
supervisor.
- Vanilla's management protocol (`management-server-*`): the WebSocket, its authentication, `rpc.discover`,
`players`, `players/kick`, `server/{status,save,stop,system_message}`, and the four lists' getters.
- The management protocol's TLS (on by default, as in vanilla): a PKCS12 keystore, or a self-signed one generated
+21
View File
@@ -447,6 +447,27 @@ Where it differs from vanilla: a connection that hasn't logged in within 30 seco
at once, and a command a client's TCP splits in two still works. A command shows as the console's (`Server`, not
`Rcon`), and `broadcast-rcon-to-ops` isn't honoured yet.
### Tools that read the query
Server lists, status pages and monitoring tools (`mcstatus`, Prometheus exporters, hosting panels) often read the
MOTD, the version and who is online through vanilla's GS4 query, a UDP port, and Horizon's server serves it. It's off
by default; turn it on in `server.toml`:
```toml
[properties]
enable-query = true
"query.port" = 25565
```
It listens on UDP on the address `[server] bind` names (every address when it's empty), as vanilla's does: the same
number as the game's TCP port is fine, they are different ports. The query tells anyone who can reach the port the
player names and the version, and has no password: open the port only if you want that. As in vanilla, a port
outside 1 to 65535 leaves it off with a warning in the log, and a port already in use is a warning too. The
settings are read when the server starts. To turn it off again, set `enable-query = false`.
Where it differs from vanilla: while the game isn't running (starting, restarting) nothing answers, and who is
online is read from the game at most once a second.
## 7. Backups
```sh
+20 -3
View File
@@ -415,6 +415,21 @@ dropped it silently — reaches `native_consume_effect(kind, data)` when the ite
thread, for the acting player. The kind and the JSON the file wrote are there; the game's own effects,
sounds, teleports and drops are open to the hook. An unclaimed kind does nothing.
### A mod's own loot functions and conditions
A loot table's function or condition that names a `type` in a namespace outside `minecraft:` — where the
loot roll used to silently skip it — reaches `native_loot_function(kind, data, ctx, stack)` or
`native_loot_condition(kind, data, ctx, holds)` when the table rolls. The function changes the stack; the
condition says whether it holds. The context brings the entity, the damage, the player who killed it, the
tool, the block, the luck, the biome and a seed from the roll's random. An unclaimed function leaves the
stack, an unclaimed condition fails, as unknown kinds do; a bare type name is minecraft's own.
The tables the items engine rolls (block drops, entity drops, `loot_table`, `interact_loot` and the
built-in tables) answer the condition hook, with the tool and the block in the context; the function hook
there follows when the stack types converge. In the items engine's context the `entity`, `damage`, `player`
and `biome` fields are `None` and `luck` is 0; in the main loot engine's context the `tool` and `block`
fields are `None`.
### Data files
A bundle's `data/` folder (any files) is staged as `native-data/<mod>/` at the root of the workspace. A helper or
@@ -765,9 +780,11 @@ Every path the build's tools see — the toolchain, the Cargo home, `native/` an
TMP and TEMP, where gcc, the assembler, `ld` and `cargo install` put their scratch files) — comes from
`backend::build_dir`: the launcher's data folder, resolved (never through a link, which horizon-mixins
refuses). On Windows, when that path is not spelled in ASCII — a Cyrillic user name in `%APPDATA%`, which
MinGW-w64's tools can't open — it is the folder's own short (8.3) name where the volume keeps one, else a
folder under `ProgramData` named for it (`horizon-launcher-<id>`); a data folder that is itself a junction to
a drive with an ASCII path resolves to that and needs neither.
MinGW-w64's tools can't open — it is a real folder under `ProgramData` named for it (`horizon-launcher-<id>`),
and only when that can't be created the data folder's short (8.3) name. A short name alone is not enough
(#6009): rustc canonicalizes its own path to find its sysroot, which spells the long name again for the
linker. A data folder that is itself a junction to a drive with an ASCII path resolves to that and needs
neither.
## Install from GitHub
+8
View File
@@ -480,6 +480,14 @@ runtime or new dependency. Server-sent events can replace the polling later if a
named in `rcon.rs`: the packets are read from the stream, the password is compared in constant time, a
connection that hasn't logged in within 30 seconds is closed and at most 16 are open. Not yet: the sender
`Rcon` and `broadcast-rcon-to-ops` (a command shows as the console's).
- **GS4 query.** `enable-query` and `query.port` turn on vanilla's UDP query, which server lists, status pages and
monitoring tools read the MOTD, the version and the players from. The supervisor serves it (`query.rs`, std
only), reads its settings as `QueryThreadGs4.create` does (a port outside 1 to 65535 leaves it off with
vanilla's warning) and speaks `QueryThreadGs4`'s protocol: the handshake (type 9, a token below 2^24 as text),
the basic stat and the full stat (a request of 15 bytes), each only with the token. The players come from the
game's `server-status`, read at most once a second, and a full stat is kept 5 seconds, as in vanilla. Differences,
each one named in `query.rs`: nothing answers while the game isn't running, and a session id outside ASCII is
echoed as lossy UTF-8.
- **Vanilla's own management protocol.** Vanilla 26.3 has a server management protocol: JSON-RPC over a
WebSocket, set up by the `management-server-*` keys in server.properties, so tools written for vanilla can
manage a Horizon server too. The supervisor serves it (`management.rs`, the WebSocket in `websocket.rs`, std
+9
View File
@@ -266,6 +266,15 @@ minimize, maximize/restore and close; macOS and Linux keep their system title ba
The launcher looks for a newer Nightly when it starts and every 30 minutes while it is open, and
shows a small "available, Install" line on Play.
Release lists and commits come from GitHub's API, which allows 60 anonymous calls an hour per
address: players behind a shared one (a school, a dorm, a carrier's NAT) spend them together.
So each answer is kept in `cache/github/` in the data folder and used again for 5 minutes without
asking, then revalidated with its ETag. When GitHub answers with its rate limit, or not at all,
Horizon's own releases are read through Kira's read-only mirror
(`https://mcp.playhorizon.dev/github/public`; `HORIZON_GITHUB_MIRROR` replaces it, empty turns it
off); when both fail the last saved answer is used, else the error names both. The release files
themselves come from their download links, which that limit doesn't cover.
It also updates itself from GitHub releases tagged `[email protected]` (the game's are `nightly`, and
`vX.Y.Z` before 2026-10-28; neither is ever taken for the other). Before 0.2 they were `launcher-vX.Y.Z` and `vYYYY.MM.DD`:
every 0.2 tag counts as newer than all of those (docs/versioning.md). It downloads the new build, replaces its
+59 -16
View File
@@ -141,7 +141,7 @@ pub(crate) const CLIENT: &str = if cfg!(windows) {
/// The agent `url` is fetched with: the launcher's own user agent, through the player's proxy when
/// one is set (`proxy`).
fn agent(url: &str) -> ureq::Agent {
pub(crate) fn agent(url: &str) -> ureq::Agent {
crate::proxy::agent(
ureq::AgentBuilder::new()
.user_agent(&format!("horizon-launcher/{VERSION}"))
@@ -175,7 +175,10 @@ pub(crate) fn get(url: &str) -> Result<ureq::Response, String> {
}
}
request.call().map_err(|e| match e {
ureq::Error::Status(403, _) => {
ureq::Error::Status(code, r)
if url.starts_with("https://api.github.com/")
&& crate::github::rate_limited(code, |h| r.header(h).map(str::to_owned)) =>
{
"GitHub's rate limit was reached. Try again in a few minutes.".into()
}
ureq::Error::Status(code, _) => format!("{url} answered {code}"),
@@ -311,10 +314,12 @@ pub fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
release_order(a).cmp(&release_order(b))
}
/// Every published release of the repository, both kinds, as GitHub's JSON.
/// Every published release of the repository, both kinds, as GitHub's JSON (kept on disk with its
/// ETag, and read through Kira's mirror past GitHub's rate limit: [`crate::github`]).
fn release_list() -> Result<serde_json::Value, String> {
let url = format!("https://api.github.com/repos/{REPO}/releases?per_page=100");
get(&url)?.into_json().map_err(|e| e.to_string())
crate::github::json(&format!(
"https://api.github.com/repos/{REPO}/releases?per_page=100"
))
}
/// The releases in `list` whose tag passes `keep`, newest first, with the asset named `asset`.
@@ -391,9 +396,7 @@ pub(crate) fn game_releases(list: &serde_json::Value) -> Vec<Release> {
/// `launcher@` and `launcher-v` tags are looked up one by one (a draft's tag is skipped: it has no
/// public release).
fn launcher_releases_in(list: &serde_json::Value) -> Vec<Release> {
launcher_releases_in_with(list, |url| {
get(&url)?.into_json().map_err(|e| e.to_string())
})
launcher_releases_in_with(list, |url| crate::github::json(&url))
}
fn launcher_releases_in_with(
@@ -572,17 +575,30 @@ pub fn build_dir() -> PathBuf {
real
}
/// [`build_dir`] when `real` isn't [`ascii`]: the folder's own short (8.3) name where the
/// volume keeps one — the same files, spelled in ASCII — else a folder of ProgramData's,
/// named for the path so each user's is its own and can be created there.
/// [`build_dir`] when `real` isn't [`ascii`]: [`ascii_build_dir`], a real folder of
/// ProgramData's. Only when it can't be created, the data folder's short (8.3) name.
///
/// The short name came first until #6009, and wasn't enough: it is only another spelling of the
/// same folder, and rustc canonicalizes its own path to find its sysroot, which spells the long,
/// Cyrillic name again and hands it to the linker (`x86_64-w64-mingw32-gcc`: "No such file or
/// directory" on `C:/Users/\xca\xe8…`). A folder whose real name is ASCII stays ASCII however a
/// tool resolves it.
#[cfg(windows)]
fn windows_build_dir(real: &Path) -> PathBuf {
use sha2::{Digest, Sha256};
if let Some(short) = short_path(real).filter(|p| ascii(p)) {
return short;
let dir = ascii_build_dir(real, std::env::var_os("ProgramData").map(PathBuf::from));
if fs::create_dir_all(&dir).is_ok() {
return dir;
}
let public = std::env::var_os("ProgramData")
.map(PathBuf::from)
short_path(real).filter(|p| ascii(p)).unwrap_or(dir)
}
/// The build folder for a data folder `real` whose path isn't [`ascii`]: in `program_data`
/// (`%ProgramData%`, `C:\ProgramData` when unset, relative or not ASCII), which every user may
/// create folders in, named for `real` so each user's is their own.
#[cfg_attr(not(windows), allow(dead_code))]
fn ascii_build_dir(real: &Path, program_data: Option<PathBuf>) -> PathBuf {
use sha2::{Digest, Sha256};
let public = program_data
.filter(|p| p.is_absolute() && ascii(p))
.unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"));
let key = Sha256::digest(real.as_os_str().as_encoded_bytes());
@@ -3422,6 +3438,33 @@ mod tests {
}
}
/// [#6009](https://github.com/horizon-rust/horizon/issues/6009): with a Cyrillic user name the
/// build folder is a real ASCII folder of ProgramData's (not a short name of the data folder,
/// which rustc resolves back to the Cyrillic one), its own for each data folder.
#[test]
fn a_cyrillic_data_folder_builds_in_an_ascii_folder_of_its_own() {
let pavlo = Path::new(r"C:\Users\Кириченко Павло\AppData\Roaming\horizon-launcher");
let other = Path::new(r"C:\Users\Олена\AppData\Roaming\horizon-launcher");
let public = PathBuf::from(if cfg!(windows) { r"D:\Data" } else { "/data" });
let dir = ascii_build_dir(pavlo, Some(public.clone()));
assert!(ascii(&dir), "{}", dir.display());
assert_eq!(dir.parent(), Some(public.as_path()));
let name = dir.file_name().unwrap().to_str().unwrap();
assert!(
name.starts_with("horizon-launcher-") && name.len() == 25,
"{name}"
);
assert_eq!(dir, ascii_build_dir(pavlo, Some(public.clone())), "stable");
assert_ne!(dir, ascii_build_dir(other, Some(public)), "one per user");
// %ProgramData% unset, relative or itself not ASCII: C:\ProgramData.
let default = PathBuf::from(r"C:\ProgramData");
for pd in [None, Some("ProgramData".into()), Some(r"C:\Дані".into())] {
let dir = ascii_build_dir(pavlo, pd);
assert_eq!(dir.parent(), Some(default.as_path()));
assert!(ascii(&dir));
}
}
/// The game program's mod API level is its mark's; a program from before the mark (0.2.68
/// and older) or none at all serves level 0.
#[test]
+234
View File
@@ -0,0 +1,234 @@
//! GitHub's REST API, as the launcher reads it: release lists, releases by tag, commits.
//!
//! Without a token GitHub allows 60 API calls an hour per address, and players behind a shared one
//! (a school, a dorm, a carrier's NAT) spend them together. So:
//! - every answer is kept on disk (`<data>/cache/github/`) and read again without asking for
//! [`FRESH_SECS`]: a launcher started a few times in a row, or several pages asking for the same
//! list, spend one call. After that the read is a conditional request with the saved ETag. An
//! anonymous 304 still counts against the hour (measured 2026-11-01), so that only saves the
//! download; with `HORIZON_GITHUB_TOKEN` (CI) a 304 is free;
//! - when GitHub answers with its rate limit (or not at all), Horizon's own repository is read
//! through Kira's public mirror ([`MIRROR`], kira's `src/launcher-github.js`), which caches
//! GitHub's answers for everyone;
//! - when both fail, the last answer kept on disk is used, else the error says what happened.
//!
//! Release files are not read here: their `browser_download_url` (github.com, then
//! release-assets.githubusercontent.com) is not part of the API and has no such limit.
use crate::backend;
use sha2::Digest;
use std::fs;
use std::path::{Path, PathBuf};
const API: &str = "https://api.github.com";
/// Kira's read-only mirror of Horizon's release reads. `HORIZON_GITHUB_MIRROR` replaces it (an
/// empty value turns it off).
pub const MIRROR: &str = "https://mcp.playhorizon.dev/github/public";
/// How long a saved answer is used without asking again: the launcher looks for releases every
/// [`backend::CHECK_EVERY`], so a few minutes late at worst.
pub const FRESH_SECS: u64 = 5 * 60;
/// What one source answered.
#[derive(Debug)]
pub(crate) enum Answer {
/// 200, with the ETag GitHub gave (none from the mirror).
Fresh(serde_json::Value, Option<String>),
/// 304: the copy on disk is still current.
NotModified,
NotFound,
/// 429, or 403 with GitHub's rate-limit headers.
RateLimited,
/// Any other status, or no connection: the error to show.
Failed(String),
}
/// GitHub's JSON for `url` (`https://api.github.com/...`). A 404 is the error `<url> answered 404`.
pub fn json(url: &str) -> Result<serde_json::Value, String> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
json_with(url, &cache_dir(), now, github, mirror)
}
/// [`json`] with its cache folder, the time (Unix seconds) and its two sources given (tests).
pub(crate) fn json_with(
url: &str,
cache: &Path,
now: u64,
github: impl Fn(&str, Option<&str>) -> Answer,
mirror: impl Fn(&str) -> Answer,
) -> Result<serde_json::Value, String> {
let file = cache.join(cache_name(url));
let kept = read_kept(&file, url);
if let Some(k) = kept.as_ref() {
// A copy saved "later" than now (the clock was set back) is not trusted as fresh.
if now >= k.saved && now - k.saved < FRESH_SECS {
return Ok(k.body.clone());
}
}
let etag = kept
.as_ref()
.map(|k| k.etag.as_str())
.filter(|e| !e.is_empty());
let why = match github(url, etag) {
Answer::Fresh(body, etag) => {
write_kept(&file, url, etag.as_deref().unwrap_or(""), &body, now);
return Ok(body);
}
Answer::NotModified => match kept {
Some(k) => {
write_kept(&file, url, &k.etag, &k.body, now);
return Ok(k.body);
}
// A 304 to a request that had no ETag: can't happen, but don't trust it.
None => "GitHub answered 304 without a saved copy".to_owned(),
},
Answer::NotFound => return Err(format!("{url} answered 404")),
Answer::RateLimited => RATE_LIMITED.to_owned(),
Answer::Failed(e) => e,
};
let from_mirror = match mirror_url(url) {
Some(m) => match mirror(&m) {
Answer::Fresh(body, _) => {
write_kept(&file, url, "", &body, now);
return Ok(body);
}
Answer::NotFound => return Err(format!("{url} answered 404")),
Answer::RateLimited => Some("too many requests from this network".to_owned()),
Answer::Failed(e) => Some(e),
Answer::NotModified => Some("an unexpected 304".to_owned()),
},
None => None,
};
if let Some(k) = kept {
return Ok(k.body);
}
Err(match from_mirror {
Some(m) => format!(
"{why}, and Horizon's mirror of GitHub couldn't help either ({m}). Try again in a few minutes."
),
None => why,
})
}
const RATE_LIMITED: &str = "GitHub's rate limit was reached for your network";
/// The mirror's URL for an API URL it serves (Horizon's own repository), or None.
pub(crate) fn mirror_url(url: &str) -> Option<String> {
mirror_url_with(url, &mirror_base())
}
fn mirror_url_with(url: &str, base: &str) -> Option<String> {
let path = url.strip_prefix(API)?;
let repo = format!("/repos/{}/", backend::REPO);
(!base.is_empty() && path.starts_with(&repo))
.then(|| format!("{}{path}", base.trim_end_matches('/')))
}
fn mirror_base() -> String {
std::env::var("HORIZON_GITHUB_MIRROR").unwrap_or_else(|_| MIRROR.to_owned())
}
/// GitHub itself, with the saved ETag when there is one.
fn github(url: &str, etag: Option<&str>) -> Answer {
let mut request = backend::agent(url).get(url);
// CI runners share addresses and hit GitHub's anonymous rate limit; they pass their own token.
if let Some(token) = std::env::var("HORIZON_GITHUB_TOKEN")
.ok()
.filter(|t| !t.is_empty())
{
request = request.set("Authorization", &format!("Bearer {token}"));
}
if let Some(etag) = etag {
request = request.set("If-None-Match", etag);
}
match request.call() {
Ok(r) if r.status() == 304 => Answer::NotModified,
Ok(r) => {
let etag = r.header("ETag").map(str::to_owned);
match r.into_json() {
Ok(body) => Answer::Fresh(body, etag),
Err(e) => Answer::Failed(e.to_string()),
}
}
Err(e) => failure(url, e),
}
}
fn mirror(url: &str) -> Answer {
match backend::agent(url).get(url).call() {
Ok(r) => match r.into_json() {
Ok(body) => Answer::Fresh(body, None),
Err(e) => Answer::Failed(e.to_string()),
},
Err(e) => failure(url, e),
}
}
fn failure(url: &str, e: ureq::Error) -> Answer {
match e {
ureq::Error::Status(404, _) => Answer::NotFound,
ureq::Error::Status(code, r) if rate_limited(code, |h| r.header(h).map(str::to_owned)) => {
Answer::RateLimited
}
ureq::Error::Status(code, _) => Answer::Failed(format!("{url} answered {code}")),
ureq::Error::Transport(t) => Answer::Failed(format!("No connection: {t}")),
}
}
/// Whether a status with these headers is GitHub's rate limit: always a 429; a 403 only with
/// `x-ratelimit-remaining: 0` (the hourly limit) or `retry-after` (the secondary one), since a 403
/// can also be a refusal.
pub(crate) fn rate_limited(code: u16, header: impl Fn(&str) -> Option<String>) -> bool {
code == 429
|| (code == 403
&& (header("x-ratelimit-remaining").as_deref() == Some("0")
|| header("retry-after").is_some()))
}
fn cache_dir() -> PathBuf {
backend::data_dir().join("cache").join("github")
}
fn cache_name(url: &str) -> String {
let hash = sha2::Sha256::digest(url.as_bytes());
let hex: String = hash[..12].iter().map(|b| format!("{b:02x}")).collect();
format!("{hex}.json")
}
#[derive(serde::Serialize, serde::Deserialize)]
struct Kept {
url: String,
/// GitHub's ETag, or empty (the mirror's answers, or GitHub gave none).
etag: String,
/// When it was saved or last confirmed (Unix seconds).
saved: u64,
body: serde_json::Value,
}
fn read_kept(file: &Path, url: &str) -> Option<Kept> {
let kept: Kept = serde_json::from_slice(&fs::read(file).ok()?).ok()?;
(kept.url == url).then_some(kept)
}
/// Saves an answer for the next reads; a failure only costs that saving.
fn write_kept(file: &Path, url: &str, etag: &str, body: &serde_json::Value, saved: u64) {
let kept = Kept {
url: url.to_owned(),
etag: etag.to_owned(),
saved,
body: body.clone(),
};
let Some(dir) = file.parent() else { return };
let Ok(text) = serde_json::to_vec(&kept) else {
return;
};
let partial = file.with_extension("partial");
if fs::create_dir_all(dir).is_ok() && fs::write(&partial, text).is_ok() {
let _ = fs::rename(&partial, file);
}
}
#[cfg(test)]
#[path = "github/tests.rs"]
mod tests;
+278
View File
@@ -0,0 +1,278 @@
use super::*;
use std::cell::RefCell;
fn scratch(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("horizon-github-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
root
}
const T0: u64 = 1_800_000_000;
const LATER: u64 = T0 + FRESH_SECS;
const RELEASES: &str = "https://api.github.com/repos/horizon-rust/horizon/releases?per_page=100";
fn list(tag: &str) -> serde_json::Value {
serde_json::json!([{ "tag_name": tag }])
}
fn no_mirror(_: &str) -> Answer {
panic!("the mirror is only asked when GitHub can't answer")
}
#[test]
fn mirror_serves_only_horizons_repository() {
let base = "https://mcp.example/github/public";
assert_eq!(
mirror_url_with(RELEASES, base).as_deref(),
Some("https://mcp.example/github/public/repos/horizon-rust/horizon/releases?per_page=100")
);
assert_eq!(
mirror_url_with(
"https://api.github.com/repos/horizon-rust/horizon/releases/tags/[email protected]",
&format!("{base}/")
)
.as_deref(),
Some("https://mcp.example/github/public/repos/horizon-rust/horizon/releases/tags/[email protected]")
);
assert_eq!(
mirror_url_with("https://api.github.com/repos/someone/mod/commits/abc", base),
None
);
assert_eq!(
mirror_url_with("https://github.com/horizon-rust/horizon/releases", base),
None
);
assert_eq!(
mirror_url_with(RELEASES, ""),
None,
"an empty mirror is off"
);
}
#[test]
fn rate_limit_is_429_or_403_with_githubs_headers() {
let none = |_: &str| None;
assert!(rate_limited(429, none));
assert!(!rate_limited(403, none), "a plain 403 is a refusal");
assert!(rate_limited(403, |h| (h == "x-ratelimit-remaining").then(|| "0".into())));
assert!(!rate_limited(403, |h| (h == "x-ratelimit-remaining").then(|| "12".into())));
assert!(rate_limited(403, |h| (h == "retry-after").then(|| "60".into())));
assert!(!rate_limited(500, |h| (h == "retry-after").then(|| "60".into())));
}
#[test]
fn etag_is_kept_and_a_304_reads_the_saved_copy() {
let cache = scratch("etag");
let sent = RefCell::new(Vec::new());
let first = json_with(
RELEASES,
&cache,
T0,
|_, etag| {
sent.borrow_mut().push(etag.map(str::to_owned));
Answer::Fresh(list("nightly"), Some("\"abc\"".into()))
},
no_mirror,
);
assert_eq!(first.unwrap(), list("nightly"));
// Started again within FRESH_SECS: the saved list, without asking.
let again = json_with(
RELEASES,
&cache,
T0 + FRESH_SECS - 1,
|_, _| panic!("a fresh copy is used without asking"),
no_mirror,
);
assert_eq!(again.unwrap(), list("nightly"));
// Later: a conditional request; a 304 gives the saved list and starts a new fresh window.
let second = json_with(
RELEASES,
&cache,
LATER,
|_, etag| {
sent.borrow_mut().push(etag.map(str::to_owned));
Answer::NotModified
},
no_mirror,
);
assert_eq!(second.unwrap(), list("nightly"));
assert_eq!(*sent.borrow(), [None, Some("\"abc\"".to_owned())]);
let third = json_with(
RELEASES,
&cache,
LATER + FRESH_SECS - 1,
|_, _| panic!("confirmed by the 304"),
no_mirror,
);
assert_eq!(third.unwrap(), list("nightly"));
// A clock set back doesn't make the copy fresh forever.
let back = json_with(
RELEASES,
&cache,
T0 - 1,
|_, _| Answer::Fresh(list("v9"), None),
no_mirror,
);
assert_eq!(back.unwrap(), list("v9"));
// Another URL doesn't read this one's copy.
let other = "https://api.github.com/repos/horizon-rust/horizon/releases/latest";
let etag_sent = RefCell::new(Some(String::new()));
let _ = json_with(
other,
&cache,
T0,
|_, etag| {
*etag_sent.borrow_mut() = etag.map(str::to_owned);
Answer::Fresh(list("v1"), None)
},
no_mirror,
);
assert_eq!(*etag_sent.borrow(), None);
let _ = fs::remove_dir_all(cache);
}
#[test]
fn rate_limited_reads_go_through_the_mirror() {
let cache = scratch("mirror");
let asked = RefCell::new(None);
let got = json_with(
RELEASES,
&cache,
T0,
|_, _| Answer::RateLimited,
|url| {
*asked.borrow_mut() = Some(url.to_owned());
Answer::Fresh(list("nightly"), None)
},
);
assert_eq!(got.unwrap(), list("nightly"));
assert!(asked
.borrow()
.as_deref()
.unwrap()
.ends_with("/github/public/repos/horizon-rust/horizon/releases?per_page=100"));
// The mirror's answer is kept like GitHub's.
let kept = json_with(RELEASES, &cache, T0 + 1, |_, _| panic!("fresh"), no_mirror);
assert_eq!(kept.unwrap(), list("nightly"));
// No connection to GitHub (blocked, DNS): the mirror too.
let got = json_with(
RELEASES,
&cache,
LATER,
|_, _| Answer::Failed("No connection: dns".into()),
|_| Answer::Fresh(list("v2"), None),
);
assert_eq!(got.unwrap(), list("v2"));
let _ = fs::remove_dir_all(cache);
}
#[test]
fn a_404_is_not_retried_on_the_mirror() {
let cache = scratch("404");
let err = json_with(RELEASES, &cache, T0, |_, _| Answer::NotFound, no_mirror).unwrap_err();
assert!(err.ends_with("answered 404"), "{err}");
let err = json_with(
RELEASES,
&cache,
T0,
|_, _| Answer::RateLimited,
|_| Answer::NotFound,
)
.unwrap_err();
assert!(err.ends_with("answered 404"), "{err}");
let _ = fs::remove_dir_all(cache);
}
#[test]
fn both_failing_uses_the_saved_copy_else_says_so() {
let cache = scratch("both");
let err = json_with(
RELEASES,
&cache,
T0,
|_, _| Answer::RateLimited,
|_| Answer::Failed("No connection: timed out".into()),
)
.unwrap_err();
assert!(err.contains("rate limit"), "{err}");
assert!(err.contains("mirror"), "{err}");
assert!(err.contains("timed out"), "{err}");
assert!(err.contains("Try again in a few minutes"), "{err}");
// With a copy from an earlier start, that copy is better than nothing.
json_with(
RELEASES,
&cache,
T0,
|_, _| Answer::Fresh(list("nightly"), Some("\"e\"".into())),
no_mirror,
)
.unwrap();
let got = json_with(
RELEASES,
&cache,
LATER,
|_, _| Answer::RateLimited,
|_| Answer::RateLimited,
);
assert_eq!(got.unwrap(), list("nightly"));
let _ = fs::remove_dir_all(cache);
}
#[test]
fn other_repositories_have_no_mirror() {
let cache = scratch("other");
let err = json_with(
"https://api.github.com/repos/someone/mod/commits/abc",
&cache,
T0,
|_, _| Answer::RateLimited,
no_mirror,
)
.unwrap_err();
assert!(err.contains("rate limit"), "{err}");
assert!(!err.contains("mirror"), "{err}");
let _ = fs::remove_dir_all(cache);
}
#[test]
fn a_corrupt_or_foreign_copy_is_ignored() {
let cache = scratch("corrupt");
fs::write(cache.join(cache_name(RELEASES)), b"not json").unwrap();
let got = json_with(
RELEASES,
&cache,
T0,
|_, etag| {
assert_eq!(etag, None);
Answer::Fresh(list("v1"), None)
},
no_mirror,
);
assert_eq!(got.unwrap(), list("v1"));
let foreign = Kept {
url: "https://api.github.com/other".into(),
etag: "\"x\"".into(),
saved: T0,
body: list("x"),
};
fs::write(
cache.join(cache_name(RELEASES)),
serde_json::to_vec(&foreign).unwrap(),
)
.unwrap();
let got = json_with(
RELEASES,
&cache,
T0,
|_, etag| {
assert_eq!(etag, None);
Answer::Fresh(list("v2"), None)
},
no_mirror,
);
assert_eq!(got.unwrap(), list("v2"));
let _ = fs::remove_dir_all(cache);
}
+1
View File
@@ -10,6 +10,7 @@ pub mod export;
#[cfg(unix)]
pub mod game_app;
pub mod game_log;
pub mod github;
pub mod github_mods;
pub mod heads;
pub mod i18n;
+2 -2
View File
@@ -540,7 +540,7 @@ fn remembered(
fn ask_github(repo: &str, revision: &str) -> Result<String, String> {
let url = format!("https://api.github.com/repos/{repo}/commits/{revision}");
let commit: serde_json::Value = backend::get(&url)?.into_json().map_err(|e| e.to_string())?;
let commit = crate::github::json(&url)?;
commit["commit"]["committer"]["date"]
.as_str()
.map(str::to_owned)
@@ -691,7 +691,7 @@ pub fn revision_of(version: &str) -> Result<String, String> {
"https://api.github.com/repos/{}/commits/{reference}",
backend::REPO
);
let commit: serde_json::Value = backend::get(&url)?.into_json().map_err(|e| e.to_string())?;
let commit = crate::github::json(&url)?;
commit["sha"]
.as_str()
.filter(|s| is_revision(s))
+29 -10
View File
@@ -355,6 +355,21 @@ def entities():
cmd('/tp @e[type=minecraft:tnt] 4.5 215 8.5'),
cmd('/kill @e[type=minecraft:tnt]'),
cmd('/execute if entity @e[type=minecraft:tnt]'),
# So is a falling block, named after its block (`getTypeName`).
cmd('/summon minecraft:falling_block 4.5 210 8.5 {BlockState:{Name:"minecraft:sand"},NoGravity:1b,Tags:["f"]}'),
cmd('/execute if entity @e[type=minecraft:falling_block,tag=f]'),
cmd('/tag @e[type=minecraft:falling_block,limit=1] list'),
cmd('/data get entity @e[type=minecraft:falling_block,limit=1] BlockState.Name'),
cmd('/tp @e[type=minecraft:falling_block] 4.5 215 8.5'),
cmd('/kill @e[type=minecraft:falling_block]'),
cmd('/execute if entity @e[type=minecraft:falling_block]'),
# And the thrown items.
*[c for kind in ('snowball', 'egg', 'ender_pearl') for c in (
cmd(f'/summon minecraft:{kind} 4.5 210 8.5 {{NoGravity:1b}}'),
cmd(f'/execute if entity @e[type=minecraft:{kind}]'),
cmd(f'/data get entity @e[type=minecraft:{kind},limit=1] Item.id'),
cmd(f'/kill @e[type=minecraft:{kind}]'),
)],
*setup('/kill @e[type=minecraft:armor_stand]'),
{'op': 'tick', 'n': 25},
cmd('/execute if entity @e[tag=h]'),
@@ -1498,24 +1513,28 @@ def piglin_pickups():
def experience_orb_stuck():
"""`ExperienceOrb.tick`'s push out of a block, the one path of the tick that
draws a random: a 3x3 block placed over a settled orb leaves it colliding
with its only free side the top, and with no player within the 8 block
follow radius the server nudges it up, `nextFloat() * 0.2 + 0.1` at a time,
gravity skipped while its box is inside blocks. The draw's magnitude only
decides how many ticks the escape takes, so the settled orb is the same
either way; an orb that is never pushed stays boxed in at y 215 instead of
resting on top of the block at 216. 25 blocks from the player, out of the
follow radius."""
draws a random: a 3x3 block placed over a settled orb leaves it colliding,
and with no player within the 8 block follow radius the server nudges it
towards the nearest free side (`Entity.moveTowardsClosestSpace`),
`nextFloat() * 0.2 + 0.1` at a time, gravity skipped while its box is inside
blocks. The orb's random is unseeded in vanilla (`RandomSource.create()`),
so the outcome must not depend on the draws: a ring of stone one block
higher keeps the top the only free side all the way out. Without it, once
the orb's middle rose past y 216 with its box still in the block, north
tied with south, west and east, won as the first, and some draws slid the
orb off the block (at z 23.25, 22.x or down to y 215 on the floor).
An orb that is never pushed stays boxed in at y 215 instead of resting in
the ring at 216. 25 blocks from the player, out of the follow radius."""
return [
*setup('/fill 30 214 20 36 214 26 minecraft:stone'),
cmd('/summon minecraft:experience_orb 33.5 215 23.5 {Value:1s}'),
{'op': 'tick', 'n': 2},
cmd('/data get entity @e[type=minecraft:experience_orb,limit=1] Pos'),
*setup('/fill 32 215 22 34 215 24 minecraft:stone'),
*setup('/fill 32 215 22 34 216 24 minecraft:stone', '/setblock 33 216 23 minecraft:air'),
{'op': 'tick', 'n': 60},
cmd('/data get entity @e[type=minecraft:experience_orb,limit=1] Pos'),
cmd('/data get entity @e[type=minecraft:experience_orb,limit=1] Motion'),
*setup('/kill @e[type=minecraft:experience_orb]', '/fill 30 214 20 36 215 26 minecraft:air'),
*setup('/kill @e[type=minecraft:experience_orb]', '/fill 30 214 20 36 216 26 minecraft:air'),
{'op': 'tick', 'n': 5},
]
@@ -0,0 +1,17 @@
{
"hooks": [
{
"name": "loot_function",
"target": "horizon_world::loot::native_loot_function"
},
{
"name": "loot_condition",
"target": "horizon_world::loot::native_loot_condition"
}
],
"rust_summary": "a loot table's function and condition of a mod's\nown kind (`native_loot_function` and `native_loot_condition`, which the\nunknown `type` in a namespace of its own reaches with the kind, its JSON,\nthe loot context and the stack or the holds).",
"doc_summary": "a loot table's function and condition of a mod's own kind. The entry that names a `type` in a namespace outside `minecraft:` — where the loot roll used to silently skip it — reaches `native_loot_function(kind, data, ctx, stack)` or `native_loot_condition(kind, data, ctx, holds)` when the table rolls. The context brings the entity, the damage, the player, the tool, the block, the luck, the biome and a seed from the roll's random. The condition hook answers in the tables the items engine rolls (block drops, entity drops, loot_table, interact_loot), where entity, damage, player and biome are None; the function hook there follows when the stack types converge. An unclaimed function leaves the stack, an unclaimed condition fails, as unknown kinds do. Bundles that use them need \"hooks\": @API:loot_kinds@.",
"comments": [
"crates/horizon-world/src/loot.rs"
]
}
+13 -13
View File
@@ -34,25 +34,25 @@ UserWhiteList.add port crates/horizon-server-files/src/user_lists.rs::UserList::
UserWhiteList.remove port crates/horizon-server-files/src/user_lists.rs::UserList::remove gap: minor: the notice is in console_whitelist's remove arm
UserWhiteList.clear skip:server only the JSON-RPC management API calls clear(); Horizon has no such API
UserWhiteList.getUserList skip:ui tab-completion names only (WhitelistCommand suggestions)
NetworkDataOutputStream.writeString skip:server RCON and query server code; Horizon has no RCON or GS4 query port
NetworkDataOutputStream.writeString inline crates/horizon-server/src/query.rs::string UTF-8 and a NUL; the rest of NetworkDataOutputStream is the GS4 query server (the short is little-endian: `u16::to_le_bytes`)
PktUtils.stringFromByteArray inline crates/horizon-server/src/rcon.rs::read_request the payload up to its first NUL; the rest of PktUtils is the GS4 query server
PktUtils.intFromByteArray inline crates/horizon-server/src/rcon.rs::read_request the little-endian length, request id and type; the rest of PktUtils is the GS4 query server
PktUtils.intFromNetworkByteArray skip:server RCON and query server code; Horizon has no RCON or GS4 query port
PktUtils.intFromNetworkByteArray inline crates/horizon-server/src/query.rs::Query::answer the token of a stat request: four big-endian bytes at 7, zero when the packet is short
PktUtils.toHexString skip:server RCON and query server code; Horizon has no RCON or GS4 query port
RconConsoleSource.createCommandSourceStack inline crates/horizon-server/src/rcon.rs::run_on_game gap: minor: a command runs as the console does, through the game's `console` request; the sender shows as Server, not Rcon, and broadcast-rcon-to-ops is not read
GenericThread.start inline crates/horizon-server/src/rcon.rs::serve the listener and a thread per connection; GenericThread is also the GS4 query server's helper
GenericThread.stop skip:server the RCON threads end with the supervisor; GenericThread is also the GS4 query server's helper
QueryThreadGs4.create skip:server GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port
QueryThreadGs4.sendTo skip:server GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port
QueryThreadGs4.processPacket skip:server GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port
QueryThreadGs4.buildRuleResponse skip:server GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port
QueryThreadGs4.validChallenge skip:server GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port
QueryThreadGs4.sendChallenge skip:server GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port
QueryThreadGs4.pruneChallenges skip:server GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port
QueryThreadGs4.run skip:server GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port
QueryThreadGs4.start skip:server GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port
QueryThreadGs4.recoverSocketError skip:server GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port
QueryThreadGs4.initSocket skip:server GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port
QueryThreadGs4.create port crates/horizon-server/src/query.rs::setup query.port 1 to 65535 with vanilla's warning, server-ip (0.0.0.0 when empty)
QueryThreadGs4.sendTo inline crates/horizon-server/src/query.rs::serve the reply goes to the sender's address
QueryThreadGs4.processPacket port crates/horizon-server/src/query.rs::Query::answer the magic, the handshake (9) and a stat (0) with a valid token, full when the request is 15 bytes; no answer otherwise
QueryThreadGs4.buildRuleResponse port crates/horizon-server/src/query.rs::full_stat splitnum, the key/value pairs and the player_ section; kept 5 seconds with each asker's session id
QueryThreadGs4.validChallenge inline crates/horizon-server/src/query.rs::Query::answer the token of that address, compared with the one the request carries
QueryThreadGs4.sendChallenge inline crates/horizon-server/src/query.rs::Query::answer a token below 2^24 as text after the session id; gap: minor: a session id outside ASCII is echoed as lossy UTF-8
QueryThreadGs4.pruneChallenges port crates/horizon-server/src/query.rs::Query::prune every 30 seconds all the tokens made before the check go
QueryThreadGs4.run port crates/horizon-server/src/query.rs::serve a thread that reads a datagram (500 ms timeout) and answers it; gap: minor: nothing answers while the game isn't running
QueryThreadGs4.start port crates/horizon-server/src/query.rs::start started by the supervisor next to RCON; a port that can't be bound is a warning
QueryThreadGs4.recoverSocketError inline crates/horizon-server/src/query.rs::serve a failed read is ignored and the socket kept
QueryThreadGs4.initSocket port crates/horizon-server/src/query.rs::start the UDP socket on server-ip and query.port, with vanilla's warning when it can't be opened
RconClient.run port crates/horizon-server/src/rcon.rs::client login, command and unknown request, with vanilla's answers; gap: minor: the packets are read from the stream, not one read each
RconClient.send port crates/horizon-server/src/rcon.rs::send -
RconClient.sendCmdResponse port crates/horizon-server/src/rcon.rs::send_output packets of 4096 characters, one even when empty; gap: minor: cut at 4096 characters, vanilla at 4096 UTF-16 units
1 PlayerList.updateEntireScoreboard port crates/horizon-client/src/lan/scoreboard.rs::View::changes the join is View::changes against the guest's empty view, sent from game/scoreboard.rs lan_sync_scoreboard
34 UserWhiteList.remove port crates/horizon-server-files/src/user_lists.rs::UserList::remove gap: minor: the notice is in console_whitelist's remove arm
35 UserWhiteList.clear skip:server only the JSON-RPC management API calls clear(); Horizon has no such API
36 UserWhiteList.getUserList skip:ui tab-completion names only (WhitelistCommand suggestions)
37 NetworkDataOutputStream.writeString skip:server inline crates/horizon-server/src/query.rs::string RCON and query server code; Horizon has no RCON or GS4 query port UTF-8 and a NUL; the rest of NetworkDataOutputStream is the GS4 query server (the short is little-endian: `u16::to_le_bytes`)
38 PktUtils.stringFromByteArray inline crates/horizon-server/src/rcon.rs::read_request the payload up to its first NUL; the rest of PktUtils is the GS4 query server
39 PktUtils.intFromByteArray inline crates/horizon-server/src/rcon.rs::read_request the little-endian length, request id and type; the rest of PktUtils is the GS4 query server
40 PktUtils.intFromNetworkByteArray skip:server inline crates/horizon-server/src/query.rs::Query::answer RCON and query server code; Horizon has no RCON or GS4 query port the token of a stat request: four big-endian bytes at 7, zero when the packet is short
41 PktUtils.toHexString skip:server RCON and query server code; Horizon has no RCON or GS4 query port
42 RconConsoleSource.createCommandSourceStack inline crates/horizon-server/src/rcon.rs::run_on_game gap: minor: a command runs as the console does, through the game's `console` request; the sender shows as Server, not Rcon, and broadcast-rcon-to-ops is not read
43 GenericThread.start inline crates/horizon-server/src/rcon.rs::serve the listener and a thread per connection; GenericThread is also the GS4 query server's helper
44 GenericThread.stop skip:server the RCON threads end with the supervisor; GenericThread is also the GS4 query server's helper
45 QueryThreadGs4.create skip:server port crates/horizon-server/src/query.rs::setup GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port query.port 1 to 65535 with vanilla's warning, server-ip (0.0.0.0 when empty)
46 QueryThreadGs4.sendTo skip:server inline crates/horizon-server/src/query.rs::serve GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port the reply goes to the sender's address
47 QueryThreadGs4.processPacket skip:server port crates/horizon-server/src/query.rs::Query::answer GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port the magic, the handshake (9) and a stat (0) with a valid token, full when the request is 15 bytes; no answer otherwise
48 QueryThreadGs4.buildRuleResponse skip:server port crates/horizon-server/src/query.rs::full_stat GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port splitnum, the key/value pairs and the player_ section; kept 5 seconds with each asker's session id
49 QueryThreadGs4.validChallenge skip:server inline crates/horizon-server/src/query.rs::Query::answer GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port the token of that address, compared with the one the request carries
50 QueryThreadGs4.sendChallenge skip:server inline crates/horizon-server/src/query.rs::Query::answer GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port a token below 2^24 as text after the session id; gap: minor: a session id outside ASCII is echoed as lossy UTF-8
51 QueryThreadGs4.pruneChallenges skip:server port crates/horizon-server/src/query.rs::Query::prune GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port every 30 seconds all the tokens made before the check go
52 QueryThreadGs4.run skip:server port crates/horizon-server/src/query.rs::serve GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port a thread that reads a datagram (500 ms timeout) and answers it; gap: minor: nothing answers while the game isn't running
53 QueryThreadGs4.start skip:server port crates/horizon-server/src/query.rs::start GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port started by the supervisor next to RCON; a port that can't be bound is a warning
54 QueryThreadGs4.recoverSocketError skip:server inline crates/horizon-server/src/query.rs::serve GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port a failed read is ignored and the socket kept
55 QueryThreadGs4.initSocket skip:server port crates/horizon-server/src/query.rs::start GS4 query server; RCON and query server code; Horizon has no RCON or GS4 query port the UDP socket on server-ip and query.port, with vanilla's warning when it can't be opened
56 RconClient.run port crates/horizon-server/src/rcon.rs::client login, command and unknown request, with vanilla's answers; gap: minor: the packets are read from the stream, not one read each
57 RconClient.send port crates/horizon-server/src/rcon.rs::send -
58 RconClient.sendCmdResponse port crates/horizon-server/src/rcon.rs::send_output packets of 4096 characters, one even when empty; gap: minor: cut at 4096 characters, vanilla at 4096 UTF-16 units