mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 10:31:29 -10:00
Merge remote-tracking branch 'origin/main' into fix/6152-submerged-mount
This commit is contained in:
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,然后选择档案和游玩位置。"
|
||||
}
|
||||
@@ -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,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()))),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -20,6 +20,7 @@ mod management_tls;
|
||||
mod mcp;
|
||||
mod panel;
|
||||
mod process;
|
||||
mod query;
|
||||
mod rcon;
|
||||
#[cfg(test)]
|
||||
mod reference;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
Reference in new issue
Block a user