Merge pull request #913 from ludovic111/integ/bl4

🤖 Agent: verify passes; local checks in the PR body. Merges bugs-lan-4's #881–#886.
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-10-18 20:27:03 +02:00
commit 573f3a64f7
16 files changed
+611 -94

No files matched your search

+21 -5
View File
@@ -463,6 +463,8 @@ struct GameLevel<'w> {
raining: bool,
thundering: bool,
player_box: Option<([f64; 3], [f64; 3])>,
/// The LAN guests in this level who trigger blocks (not spectators).
guest_boxes: Vec<([f64; 3], [f64; 3])>,
player_collision: Option<PlayerCollision>,
/// Minecart boxes (detector rails).
cart_boxes: Vec<([f64; 3], [f64; 3])>,
@@ -676,7 +678,7 @@ impl LevelAccess for GameLevel<'_> {
.unwrap_or(false)
}
/// Entities that trigger pressure plates, tripwires and detector rails:
/// the player (a mob) and the minecarts.
/// the players (the host and the LAN guests, mobs) and the minecarts.
fn count_entities(&self, area: Aabb, query: EntityQuery) -> usize {
let hit = |(lo, hi): &([f64; 3], [f64; 3])| {
area.intersects(Aabb::new(
@@ -685,10 +687,17 @@ impl LevelAccess for GameLevel<'_> {
))
};
let carts = || self.cart_boxes.iter().filter(|b| hit(b)).count();
let players = || {
self.player_box
.iter()
.chain(&self.guest_boxes)
.filter(|b| hit(b))
.count()
};
match query {
EntityQuery::Minecart => carts(),
EntityQuery::Mobs => self.player_box.iter().filter(|b| hit(b)).count(),
EntityQuery::Everything => self.player_box.iter().filter(|b| hit(b)).count() + carts(),
EntityQuery::Mobs => players(),
EntityQuery::Everything => players() + carts(),
}
}
fn emit(&mut self, event: LevelEvent) {
@@ -1999,6 +2008,7 @@ impl Game<'_> {
)
})
.collect();
let guest_boxes = self.lan_block_trigger_boxes();
let dimension = match self.dim {
Dim::Overworld => LevelDimension::Overworld,
Dim::Nether => LevelDimension::Nether,
@@ -2016,6 +2026,7 @@ impl Game<'_> {
raining: rain > 0.2,
thundering: thunder > 0.9,
player_box,
guest_boxes,
player_collision,
biomes: self.res.biomes(),
seed: self.level.seed,
@@ -2094,8 +2105,9 @@ impl Game<'_> {
ticking.push((x, z, sections));
}
// Entity.checkInsideBlocks for the plates, tripwires and detector rails
// under the player and the minecarts.
// under the players (the host and the LAN guests) and the minecarts.
let mut inside = Vec::new();
let guests = self.lan_block_trigger_boxes();
let boxes = self
.player
.iter()
@@ -2114,7 +2126,8 @@ impl Game<'_> {
[p.x - 0.49, p.y, p.z - 0.49],
[p.x + 0.49, p.y + 0.7, p.z + 0.49],
)
}));
}))
.chain(guests);
for (lo, hi) in boxes {
for x in (lo[0] + 1e-7).floor() as i32..=(hi[0] - 1e-7).floor() as i32 {
for y in (lo[1] + 1e-7).floor() as i32..=(hi[1] - 1e-7).floor() as i32 {
@@ -5067,6 +5080,9 @@ impl Game<'_> {
};
(kind, size)
}
// A server's BrewingStandMenu; a local stand's clicks are the
// station screen's.
Some(BlockEntity::Brewing(_)) if self.remote.is_some() => (Kind::Brewing, 5),
Some(BlockEntity::Furnace(f)) => (
Kind::Furnace(
match f.kind.recipe_type().trim_start_matches("minecraft:") {
+3 -1
View File
@@ -477,6 +477,8 @@ impl Game<'_> {
"sleeping": self.sleeping,
// The item in use: its slot and for how many ticks.
"using": self.using.map(|(slot, ticks)| json!({ "slot": slot, "ticks": ticks })),
// The arm swings started (LivingEntity.swing).
"swings": self.swings_started,
"time": { "day_time": self.world.day_time, "day": self.world.day_time.div_euclid(24000), "time_of_day": self.world.day_time.rem_euclid(24000) },
"weather": { "raining": self.world.weather.raining, "thundering": self.world.weather.thundering },
"paused": self.pause,
@@ -579,7 +581,7 @@ impl Game<'_> {
push(
[m.position.x, m.position.y, m.position.z],
m.kind.id.as_ref(),
json!({ "id": m.id, "kind": "mob", "health": m.health, "baby": m.is_baby() }),
json!({ "id": m.id, "kind": "mob", "health": m.health, "baby": m.is_baby(), "hurt_time": m.hurt_time }),
);
}
for item in &self.dropped {
+10
View File
@@ -697,6 +697,16 @@ impl Game<'_> {
c.partial -= whole;
self.world.day_time += whole as i64;
}
/// `ServerClockInstance.packNetworkState`: the rate players' clients
/// advance the overworld clock at, 0 while it is paused (`/time pause`)
/// or `advance_time` is off.
pub(super) fn network_clock_rate(&self) -> f32 {
if self.ui.rules.bool("advance_time") && !self.ui.clock.paused {
self.ui.clock.rate
} else {
0.0
}
}
/// The engine-side rule values (block behaviour and health).
pub(super) fn level_rules(&self) -> horizon_world::level::GameRules {
let r = &self.ui.rules;
+1 -1
View File
@@ -3016,7 +3016,7 @@ impl Game<'_> {
}
/// Mob.getBaseExperienceReward
/// The damage type id of a Horizon damage kind.
fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) -> &'static str {
pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) -> &'static str {
use horizon_client::mobs::DamageKind as K;
match kind {
K::PlayerAttack => "minecraft:player_attack",
+36 -8
View File
@@ -42,6 +42,8 @@ pub(super) struct LanHost {
pub hub: Hub,
pub settings: LanSettings,
pub host_entity: i32,
/// The host's swings the guests were told of (`Game::swings_started`).
pub host_swings: u32,
pub host_uuid: u128,
/// The host's profile name (the account's, or the offline name).
pub host_name: String,
@@ -398,6 +400,8 @@ impl Game<'_> {
[&m.extra.head, &m.extra.chest, &m.extra.legs, &m.extra.feet]
.map(|s| s.as_deref())
}),
// The host draws this guest's arm swinging.
"model_swinging": h.models.get(&g.conn).map(|m| m.ai.swinging),
})).collect::<Vec<_>>(),
}),
_ => json!({ "open": false }),
@@ -512,6 +516,7 @@ impl Game<'_> {
hub,
settings: LanSettings { port, ..settings },
host_entity,
host_swings: self.swings_started,
host_uuid,
host_name: self.options.player_name(),
models: HashMap::new(),
@@ -827,6 +832,7 @@ impl Game<'_> {
};
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
let events = {
let clock_rate = self.network_clock_rate();
let mut ctx = TickCtx {
world: &mut self.world,
host: state.as_ref(),
@@ -835,11 +841,7 @@ impl Game<'_> {
spawn,
difficulty: i32::from(self.ui.difficulty),
difficulty_locked: self.level.difficulty_locked,
clock_rate: if self.ui.rules.bool("advance_time") {
self.ui.clock.rate
} else {
0.0
},
clock_rate,
tick_rate: self.clock.rate(),
tick_frozen: self.clock.frozen(),
collision: Some(self.collision),
@@ -1009,6 +1011,7 @@ impl Game<'_> {
} else {
entities
};
let clock_rate = self.network_clock_rate();
let mut ctx = TickCtx {
world: &mut self.world,
host: state,
@@ -1017,7 +1020,7 @@ impl Game<'_> {
spawn,
difficulty: i32::from(self.ui.difficulty),
difficulty_locked: self.level.difficulty_locked,
clock_rate: self.ui.clock.rate,
clock_rate,
tick_rate: self.clock.rate(),
tick_frozen: self.clock.frozen(),
collision: Some(self.collision),
@@ -1086,8 +1089,30 @@ impl Game<'_> {
.and_then(|s| s.get(&m.id))
.copied();
let at = [m.position.x, m.position.y, m.position.z];
if m.hurt_time > old.map_or(0, |o| o.0) && !dead {
host.hub.entity_event(m.id as i32, 2, at);
// LivingEntity.hurtServer: a hurt that restarted the hurt time.
if m.hurt_time > old.map_or(0, |o| o.0)
&& !dead
&& let Some((source, _)) = m.last_damage
{
use horizon_client::mobs::{DamageKind as K, Who};
let player = source.attacker == Some(Who::Player);
let kind = super::creatures::damage_type(source.kind, player);
// The player a mob sees is the guest acting, else the host.
let cause = source.attacker.map(|who| match who {
Who::Player => host
.mob_players
.get(&m.id)
.and_then(|conn| host.hub.guest(*conn))
.map_or(host.host_entity, |g| g.entity_id),
Who::Mob(id) => id as i32,
});
// A projectile is the direct entity of its hit: not tracked here.
let melee = matches!(
source.kind,
K::PlayerAttack | K::MaceSmash | K::MobAttack | K::Sting | K::SonicBoom
);
let direct = cause.filter(|_| melee);
host.hub.damage_event(m.id as i32, kind, cause, direct, at);
}
if dead && !old.is_some_and(|o| o.1) {
host.hub.entity_event(m.id as i32, 3, at);
@@ -1176,6 +1201,9 @@ impl Game<'_> {
.each_ref()
.map(|s| s.as_ref().map(|s| s.item.clone()));
m.extra.crouching = g.sneaking;
// LivingEntity.updateSwingTime: the arm swing lan_swings started.
m.anim.attack_anim_o = m.anim.attack_anim;
horizon_client::mobs::ai::tick_swing(m);
}
}
+93 -20
View File
@@ -10,6 +10,7 @@ use horizon_client::{
command::vanilla,
containers::BlockEntity,
entities::Experience,
first_person::{Hand, SwingAnimation},
items::ItemStack,
lan::{
guest::SPECTATOR,
@@ -43,8 +44,9 @@ pub(super) struct GuestPlay {
/// its progress since `dig_start` is complete (ServerPlayerGameMode.hasDelayedDestroy).
delayed: Option<([i32; 3], u64)>,
last_progress: i32,
/// Hand movements the client announced and the host animated.
swung: bool,
/// Swings the guest's actions started this tick, for the players around
/// (`LivingEntity.swing` sends ClientboundSwingAnimationPacket).
swings: u32,
/// The trading screen the guest has open.
pub(super) merchant: Option<super::lan_trading::GuestMerchant>,
/// The inventory menu and the crafting table the guest crafts in.
@@ -121,6 +123,8 @@ pub(super) struct GuestSwap {
acting: (Option<u32>, Option<String>, Option<u128>),
/// The packets queued for that guest.
packets: Vec<play::Clientbound>,
/// The host's count of swings started, kept while the guest acts.
swings: u32,
}
struct ItemRules<'a>(&'a horizon_client::items::Items);
@@ -286,6 +290,7 @@ impl Game<'_> {
self.lan_acting_uuid.replace(g.uuid),
);
let packets = std::mem::take(&mut self.guest_packets);
let swings = self.swings_started;
Some(GuestSwap {
conn,
i,
@@ -328,6 +333,7 @@ impl Game<'_> {
death_cause,
acting,
packets,
swings,
})
}
@@ -376,7 +382,11 @@ impl Game<'_> {
death_cause,
acting,
packets,
swings,
} = swap;
// The guest's swings are theirs: the host's own count stays the host's.
play.swings += self.swings_started.wrapping_sub(swings);
self.swings_started = swings;
for packet in std::mem::replace(&mut self.guest_packets, packets) {
host.hub.send_to(conn, &packet);
}
@@ -570,6 +580,13 @@ impl Game<'_> {
_ => ("minecraft:furnace", "container.furnace"),
};
(menu, title, Slots::Block(pos))
} else if let Some(BlockEntity::Brewing(_)) = self.world.block_entities.get(&pos) {
// BrewingStandBlock.useWithoutItem: BrewingStandMenu.
(
"minecraft:brewing_stand",
"container.brewing",
Slots::Block(pos),
)
} else if let Some((other, right)) = self.chest_partner(pos) {
// ChestBlock.combine: the right half's slots come first.
let halves = if right {
@@ -717,6 +734,16 @@ impl Game<'_> {
}
}
}
// ServerGamePacketListenerImpl.handlePunch: the main hand swings
// with the held item's attack animation (mining, attacking, a
// click on nothing), seen by the players around, and the attack
// strength starts again.
S::Punch(_) => {
self.as_guest(host, conn, |g| {
g.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
g.reset_attack_strength();
});
}
S::UseItemOn(p) => {
host.hub.ack(conn, p.sequence);
if spectator {
@@ -732,11 +759,14 @@ impl Game<'_> {
f64::from(p.pos[2]) + f64::from(p.cursor[2]),
],
};
self.as_guest(host, conn, |g| {
let swung = self.as_guest(host, conn, |g| {
g.forced_hit = Some(hit);
let swings = g.swings_started;
let _ = g.use_item();
g.forced_hit = None;
g.swings_started != swings
});
self.lan_swing_back(host, conn, swung);
// The client predicted a result: correct it with what really is.
for pos in [
p.pos,
@@ -754,20 +784,26 @@ impl Game<'_> {
}
// handleUseItem: the client's rotation, then the item alone. What it
// looked at a moment ago (a block, a mob) is UseItemOn's or Interact's.
self.as_guest(host, conn, |g| {
let swung = self.as_guest(host, conn, |g| {
(g.yaw, g.pitch) = (p.yaw, p.pitch);
g.lan_item_only = true;
let swings = g.swings_started;
let _ = g.use_item();
g.lan_item_only = false;
g.swings_started != swings
});
self.lan_swing_back(host, conn, swung);
}
S::Attack(p) => self.guest_attack(host, conn, p.id),
S::Interact(p) => {
if let Some((position, eye)) = self.look_at_entity(host, gi, p.id) {
let _ = (position, eye);
self.as_guest(host, conn, |g| {
let swung = self.as_guest(host, conn, |g| {
let swings = g.swings_started;
let _ = g.use_item();
g.swings_started != swings
});
self.lan_swing_back(host, conn, swung);
}
}
S::SetCreativeModeSlot(p) => {
@@ -781,7 +817,7 @@ impl Game<'_> {
S::ContainerClick(p) if self.lan_merchant_click(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_inventory_click(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_crafting_click(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_furnace_click(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_machine_click(host, conn, &p) => {}
S::SelectTrade(p) => self.lan_select_trade(host, conn, p.item),
S::ContainerClose(p) if self.lan_merchant_closed(host, conn, Some(p.container)) => {
let g = &mut host.hub.guests[gi];
@@ -1253,6 +1289,7 @@ impl Game<'_> {
if let Some(play) = host.play.get_mut(&conn) {
play.potions = Default::default();
}
let clock_rate = self.network_clock_rate();
let ctx = TickCtx {
world: &mut self.world,
host: None,
@@ -1261,11 +1298,7 @@ impl Game<'_> {
spawn,
difficulty: i32::from(self.ui.difficulty),
difficulty_locked: self.level.difficulty_locked,
clock_rate: if self.ui.rules.bool("advance_time") {
self.ui.clock.rate
} else {
0.0
},
clock_rate,
tick_rate: self.clock.rate(),
tick_frozen: self.clock.frozen(),
collision: Some(self.collision),
@@ -1828,23 +1861,63 @@ impl Game<'_> {
}
}
/// Guests' arm swings for everyone else.
/// A use the server ran swung the guest's hand: their own client is told
/// too (`swing(hand, true)` for a SwingSource.SERVER result). Horizon's
/// client does not run uses itself, so it does not predict the swing; a
/// client that did (a block placed) is still in the first half of its
/// swing and ignores it (`SwingState.startIfAble`).
fn lan_swing_back(&mut self, host: &mut LanHost, conn: u32, swung: Option<bool>) {
if swung != Some(true) {
return;
}
let (Some(g), Some(play)) = (host.hub.guest(conn), host.play.get(&conn)) else {
return;
};
if let Some(c) = play.swing.current {
host.hub
.send_to(conn, &swing_packet(g.entity_id, c.hand, c.animation));
}
}
/// The tick's arm swings for the players around (the guests' to everyone
/// else, the host's to the guests), and `LivingEntity.updateSwingTime`
/// for the guests' bodies here.
pub(super) fn lan_swings(&mut self, host: &mut LanHost) {
let conns: Vec<u32> = host.play.keys().copied().collect();
for conn in conns {
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
continue;
};
if !host.hub.here(g) {
continue;
}
let (id, at) = (g.entity_id, g.position);
let (id, at, here) = (g.entity_id, g.position, host.hub.here(g));
let play = host.play.get_mut(&conn).expect("play");
let swinging = play.swing.swinging() && play.swing.ticks == 0;
if swinging && !play.swung {
host.hub.swing(id, 0, at, Some(conn));
let swung = std::mem::take(&mut play.swings) > 0;
if let (true, true, Some(c)) = (swung, here, play.swing.current) {
let packet = swing_packet(id, c.hand, c.animation);
host.hub.broadcast_near(at, 128.0, &packet, Some(conn));
if let Some(m) = host.models.get_mut(&conn) {
m.swing();
}
}
play.swing.tick();
}
if self.swings_started != host.host_swings {
host.host_swings = self.swings_started;
if let (Some(p), Some(c)) = (self.player.as_ref(), self.swing.current) {
let at = [p.position.x, p.position.y, p.position.z];
let packet = swing_packet(host.host_entity, c.hand, c.animation);
host.hub.broadcast_near(at, 128.0, &packet, None);
}
play.swung = swinging;
}
}
}
/// ClientboundSwingAnimationPacket: the hand and the animation (its type and
/// base duration; each client applies the swinger's haste and fatigue).
fn swing_packet(id: i32, hand: Hand, animation: SwingAnimation) -> C {
C::SwingAnimation(play::SwingAnimation {
id,
hand: i32::from(hand == Hand::Off),
kind: i32::from(animation.kind),
duration: animation.duration,
})
}
+37 -23
View File
@@ -21,9 +21,10 @@ pub(super) struct GuestCrafting {
inventory: Option<Box<Menu>>,
/// The open crafting table: its window, state id and menu.
table: Option<(i32, i32, Box<Menu>)>,
/// The open furnace's window and menu (`AbstractFurnaceMenu`); its slots live in
/// the furnace block entity and the hub's container menu keeps the client in sync.
furnace: Option<(i32, Box<Menu>)>,
/// The open furnace's or brewing stand's window and menu (`AbstractFurnaceMenu`,
/// `BrewingStandMenu`); its slots live in the block entity and the hub's
/// container menu keeps the client in sync.
machine: Option<(i32, Box<Menu>)>,
}
/// ServerboundContainerClickPacket's input.
@@ -157,10 +158,11 @@ impl Game<'_> {
true
}
/// A click on the guest's open furnace, blast furnace or smoker: the host's
/// `AbstractFurnaceMenu` (fuel slot, take-only result, quick moves by recipe and
/// fuel) over the block entity's slots. False for other windows.
pub(super) fn lan_furnace_click(
/// A click on the guest's open furnace, blast furnace, smoker or brewing stand:
/// the host's `AbstractFurnaceMenu` (fuel slot, take-only result, quick moves by
/// recipe and fuel) or `BrewingStandMenu` (one bottle per slot, ingredient and
/// blaze powder slots) over the block entity's slots. False for other windows.
pub(super) fn lan_machine_click(
&mut self,
host: &mut LanHost,
conn: u32,
@@ -180,15 +182,18 @@ impl Game<'_> {
else {
return false;
};
let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get(&pos) else {
return false;
let (kind, items) = match self.world.block_entities.get(&pos) {
Some(BlockEntity::Furnace(f)) => {
let recipe = match f.kind.recipe_type() {
"minecraft:blasting" => "blasting",
"minecraft:smoking" => "smoking",
_ => "smelting",
};
(Kind::Furnace(recipe), f.items.to_vec())
}
Some(BlockEntity::Brewing(b)) => (Kind::Brewing, b.items.to_vec()),
_ => return false,
};
let recipe = match f.kind.recipe_type() {
"minecraft:blasting" => "blasting",
"minecraft:smoking" => "smoking",
_ => "smelting",
};
let items = f.items.clone();
if host.hub.guests[gi].spectator() {
return true;
}
@@ -197,10 +202,10 @@ impl Game<'_> {
.entry(conn)
.or_default()
.crafting
.furnace
.machine
.take()
.filter(|(window, _)| *window == p.container)
.map_or_else(|| Box::new(Menu::new(Kind::Furnace(recipe))), |(_, m)| m);
.map_or_else(|| Box::new(Menu::new(kind)), |(_, m)| m);
let changed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
for (k, s) in items.iter().enumerate() {
menu.set_slot(env, k, s.clone());
@@ -213,14 +218,23 @@ impl Game<'_> {
.filter(|&s| menu.slot(s) != before[s].as_ref())
.collect::<Vec<_>>()
});
if let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) {
for k in 0..3 {
f.items[k] = menu.slot(k).cloned();
match self.world.block_entities.get_mut(&pos) {
Some(BlockEntity::Furnace(f)) => {
for k in 0..3 {
f.items[k] = menu.slot(k).cloned();
}
// AbstractFurnaceBlockEntity.setItem: only a new input restarts cooking.
f.input_clicked(self.res.recipes(), items[0].clone());
}
// AbstractFurnaceBlockEntity.setItem: only a new input restarts cooking.
f.input_clicked(self.res.recipes(), items[0].clone());
// BrewingStandBlockEntity.serverTick notices a new ingredient itself.
Some(BlockEntity::Brewing(b)) => {
for k in 0..5 {
b.items[k] = menu.slot(k).cloned();
}
}
_ => {}
}
host.play.entry(conn).or_default().crafting.furnace = Some((p.container, menu));
host.play.entry(conn).or_default().crafting.machine = Some((p.container, menu));
host.hub.inventory_clicked(conn, p, &self.world, &changed);
true
}
@@ -131,6 +131,26 @@ impl Game<'_> {
self.lan = Some(host);
}
/// The guests' boxes for the blocks entities trigger (pressure plates,
/// tripwires, detector rails): `Entity.checkInsideBlocks` and
/// `Level.getEntities` see every player in the level, but not spectators
/// (`Entity.isIgnoringBlockTriggers`).
pub(super) fn lan_block_trigger_boxes(&self) -> Vec<([f64; 3], [f64; 3])> {
let Some(host) = self.lan.as_ref() else {
return Vec::new();
};
host.hub
.guests
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
.map(|g| {
let [x, y, z] = g.position;
let height = if g.sneaking { 1.5 } else { 1.8 };
([x - 0.3, y, z - 0.3], [x + 0.3, y + height, z + 0.3])
})
.collect()
}
/// The guests a projectile can hit, under their entity ids.
pub(super) fn lan_projectile_targets(&self) -> Vec<(Who, Aabb)> {
let Some(host) = self.lan.as_ref() else {
+11 -1
View File
@@ -770,6 +770,14 @@ enum Screen {
impl Game<'_> {
fn machine_screen(&self) -> Option<Screen> {
let key = self.container?;
// A server's menu shows by its type, whatever block (or minecart) holds it.
if let Some(menu) = self.remote_menu_type() {
return match menu {
"minecraft:hopper" => Some(Screen::Hopper),
"minecraft:generic_3x3" => Some(Screen::Dispenser),
_ => None,
};
}
if key.1 == super::minecarts::CART_KEY_Y {
return (self.open_cart_kind() == Some(super::minecarts::CartKind::Hopper))
.then_some(Screen::Hopper);
@@ -852,7 +860,9 @@ impl Game<'_> {
batch.rect_uv(ox, oy, 176.0, height, r, u32::MAX);
}
let grey = horizon_client::hud::rgba(64, 64, 64, 255);
let title = &self.container_title(title.to_owned());
let title = &self
.remote_menu_title()
.unwrap_or_else(|| self.container_title(title.to_owned()));
let title_x = if screen == Screen::Hopper {
8.0
} else {
+97 -14
View File
@@ -151,7 +151,12 @@ pub(super) struct Remote {
synced: Vec<Option<ItemStack>>,
decode_errors: u32,
clock: Option<i32>,
/// `ClientClockManager.ClientClockInstance`'s rate and partial tick for
/// the level's clock (its total is `world.day_time`), and
/// `lastTickGameTime`.
clock_rate: f32,
clock_partial: f32,
clock_game_time: i64,
/// `ClientChunkCache.Storage`'s view centre (ClientboundSetChunkCacheCenterPacket).
chunk_center: (i32, i32),
}
@@ -341,6 +346,8 @@ impl Game<'_> {
decode_errors: 0,
clock: None,
clock_rate: 1.0,
clock_partial: 0.0,
clock_game_time: 0,
chunk_center: (0, 0),
});
// The level's TickRateManager follows the server's ticking packets.
@@ -1141,14 +1148,16 @@ impl Game<'_> {
/// Map a menu slot to Horizon's inventory screen slot ids.
fn menu_slot(menu: &str, slot: i32) -> Option<usize> {
let s = usize::try_from(slot).ok()?;
let rows = |n: usize| -> Option<usize> {
// A container of `n` slots, then the inventory and the hotbar.
let slots = |n: usize| -> Option<usize> {
match s {
_ if s < n * 9 => Some(300 + s),
_ if s < n * 9 + 27 => Some(9 + s - n * 9),
_ if s < n * 9 + 36 => Some(s - n * 9 - 27),
_ if s < n => Some(300 + s),
_ if s < n + 27 => Some(9 + s - n),
_ if s < n + 36 => Some(s - n - 27),
_ => None,
}
};
let rows = |n: usize| slots(n * 9);
match menu {
"inventory" => match s {
0 => Some(200),
@@ -1186,6 +1195,10 @@ impl Game<'_> {
"minecraft:generic_9x4" => rows(4),
"minecraft:generic_9x5" => rows(5),
"minecraft:generic_9x6" => rows(6),
// HopperMenu; DispenserMenu (dispensers and droppers).
"minecraft:hopper" => slots(5),
"minecraft:generic_3x3" => slots(9),
"minecraft:brewing_stand" => slots(5),
_ => None,
}
}
@@ -1600,6 +1613,15 @@ impl Game<'_> {
pl.velocity = Vec3::new(p.velocity[0], p.velocity[1], p.velocity[2]);
}
}
// ClientPacketListener.handleDamageEvent: LivingEntity.handleDamageEvent
// jolts the walk animation and restarts the hurt flash. Its hurt
// sound comes as a sound from Horizon's host, which plays it itself.
C::DamageEvent(p) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.limb_speed = 1.5;
m.hurt_time = 10;
}
}
C::HurtAnimation(p) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.hurt_time = 10;
@@ -1627,6 +1649,21 @@ impl Game<'_> {
}
}
C::Animate(p) => self.remote_animate(p.id, p.action),
// ClientPacketListener.handleSwingAnimation: the entity swings
// (LivingEntity.swing, which a swing in its first half ignores).
C::SwingAnimation(p) => {
use horizon_client::first_person::Hand;
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
let animation = horizon_client::first_person::SwingAnimation {
kind: p.kind.clamp(0, 255) as u8,
duration: p.duration,
};
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
self.swing_hand(hand, animation);
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.swing();
}
}
C::UpdateMobEffect(p) => self.remote_effect(&p),
C::RemoveMobEffect(p) => {
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
@@ -1676,8 +1713,12 @@ impl Game<'_> {
total: p.total,
}
}
// ClientPacketListener.handleSetTime: the game time, then
// ClientClockManager.handleUpdates: the clock catches up with the
// game time and takes the server's state.
C::SetTime(p) => {
self.world.game_time = p.game_time;
self.remote_clock_tick();
let clock = {
let r = self.remote.as_ref();
let names =
@@ -1698,6 +1739,7 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.clock = Some(c.clock);
r.clock_rate = c.rate;
r.clock_partial = c.partial_tick;
}
}
}
@@ -1781,17 +1823,27 @@ impl Game<'_> {
self.remote_synced();
}
C::ContainerSetData(p) => {
if let Some(key) = self.container
&& let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&key)
let v = i32::from(p.value);
match self
.container
.and_then(|key| self.world.block_entities.get_mut(&key))
{
let v = i32::from(p.value);
match p.id {
Some(BlockEntity::Furnace(f)) => match p.id {
0 => f.lit_remaining = v,
1 => f.lit_total = v,
2 => f.cooking_timer = v,
3 => f.cooking_total = v,
_ => {}
}
},
// BrewingStandBlockEntity.dataAccess.
Some(BlockEntity::Brewing(b)) => match p.id {
0 => b.brew_time = v,
1 => b.fuel = v,
2 => b.total_brew_time = v,
3 => b.total_fuel = v,
_ => {}
},
_ => {}
}
}
C::OpenScreen(p) => self.remote_open_screen(p),
@@ -2408,15 +2460,12 @@ impl Game<'_> {
horizon_protocol::packets::configuration::ClientInformation::new(view),
));
}
// Minecraft.tick: TickRateManager.tick; ClientLevel.tick advances
// Minecraft.tick: TickRateManager.tick; ClientLevel.tickTime advances
// the time only while the level runs normally.
let running = self.clock.world_runs();
if running {
self.world.game_time += 1;
let rate = self.remote.as_ref().map_or(1.0, |r| r.clock_rate);
if rate > 0.0 {
self.world.day_time += rate.round() as i64;
}
self.remote_clock_tick();
}
// Weather levels drift toward their targets between server updates.
let w = &mut self.world.weather;
@@ -2661,6 +2710,9 @@ impl Game<'_> {
if !m.alive() {
m.death_time += 1;
}
// LivingEntity.updateSwingTime.
m.anim.attack_anim_o = m.anim.attack_anim;
horizon_client::mobs::ai::tick_swing(m);
// Entity.commonTick: LinearInterpolationHandler.interpolate.
if let Some(l) = lerps.get_mut(&(m.id as i32))
&& l.steps > 0
@@ -3589,6 +3641,14 @@ impl Game<'_> {
speed: 1.0,
}))
}
// HopperMenu, and DispenserMenu (MenuType.GENERIC_3x3: dispensers
// and droppers).
"minecraft:hopper" => Some(BlockEntity::Chest(vec![None; 5])),
// BrewingStandMenu: its times come in ContainerSetData.
"minecraft:brewing_stand" => Some(BlockEntity::Brewing(Box::new(
horizon_client::brewing::BrewingStand::new(),
))),
"minecraft:generic_3x3" => Some(BlockEntity::Chest(vec![None; 9])),
m if m.starts_with("minecraft:generic_9x") || m == "minecraft:shulker_box" => {
let rows = m
.strip_prefix("minecraft:generic_9x")
@@ -3636,6 +3696,14 @@ impl Game<'_> {
self.screen = Screen::Inventory;
self.capture(false);
}
/// The type of the menu a server opened (`MenuType`'s id).
pub(super) fn remote_menu_type(&self) -> Option<&str> {
Some(self.remote.as_ref()?.menu.as_ref()?.1.as_str())
}
/// The title the server gave the menu it opened.
pub(super) fn remote_menu_title(&self) -> Option<String> {
self.remote.as_ref()?.menu_title.clone()
}
/// A server's chest-like menu (`MenuType.GENERIC_9xN`): its rows and title.
pub(super) fn remote_chest_menu(&self) -> Option<(usize, String)> {
let r = self.remote.as_ref()?;
@@ -3746,6 +3814,20 @@ impl Game<'_> {
.filter_map(|(id, uuid)| Some((*id as u32, r.players.get(uuid)?.clone())))
.collect()
}
/// `ClientClockManager.tick`: the clock moves on by the game time that
/// passed since its last tick, at its rate. A game time the server sets
/// forward (its sync every second) moves the clock with it.
fn remote_clock_tick(&mut self) {
let Some(r) = self.remote.as_mut() else {
return;
};
let delta = self.world.game_time - r.clock_game_time;
r.clock_game_time = self.world.game_time;
let partial = f64::from(r.clock_partial) + delta as f64 * f64::from(r.clock_rate);
let full = partial.floor();
r.clock_partial = (partial - full) as f32;
self.world.day_time += full as i64;
}
pub(super) fn remote_status(&self) -> Option<serde_json::Value> {
let r = self.remote.as_ref()?;
Some(serde_json::json!({
@@ -3776,6 +3858,7 @@ impl Game<'_> {
let armor = [&e.head, &e.chest, &e.legs, &e.feet].map(|s| s.as_deref());
serde_json::json!({
"id": m.id,
"swinging": m.ai.swinging,
"armor": armor,
"main_hand": e.main_hand.as_deref(),
"off_hand": e.off_hand.as_deref(),
@@ -304,6 +304,7 @@ impl Game<'_> {
/// The hub's view of the server for packets sent outside its own tick.
pub(super) fn server_ctx(&mut self) -> TickCtx<'_> {
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
let clock_rate = self.network_clock_rate();
TickCtx {
world: &mut self.world,
host: None,
@@ -312,11 +313,7 @@ impl Game<'_> {
spawn,
difficulty: i32::from(self.ui.difficulty),
difficulty_locked: self.level.difficulty_locked,
clock_rate: if self.ui.rules.bool("advance_time") {
self.ui.clock.rate
} else {
0.0
},
clock_rate,
tick_rate: self.clock.rate(),
tick_frozen: self.clock.frozen(),
collision: Some(self.collision),
+3 -1
View File
@@ -1058,7 +1058,8 @@ impl Game<'_> {
}
}
pub(super) fn station_click(&mut self, right: bool) -> bool {
if self.station.is_none() && !self.brewing_open() {
// A server's brewing stand: its slots are the server's.
if self.station.is_none() && (!self.brewing_open() || self.remote.is_some()) {
return false;
}
let Some(size) = self
@@ -1403,6 +1404,7 @@ impl Game<'_> {
.as_ref()
.and_then(|s| self.world.block_names.get(&(s.pos[0], s.pos[1], s.pos[2])))
.cloned()
.or_else(|| self.remote_menu_title())
.unwrap_or(default);
let tw = HudBatch::text_width(atlas, &title);
batch.text(
+38 -9
View File
@@ -109,6 +109,33 @@ impl Hub {
);
}
/// `ServerLevel.broadcastDamageEvent`: the clients around show the hurt
/// entity's flash and hear its hurt sound.
pub fn damage_event(
&self,
id: i32,
kind: &str,
cause: Option<i32>,
direct: Option<i32>,
at: [f64; 3],
) {
let Some(source_type) = self.sync.id("minecraft:damage_type", kind) else {
return;
};
self.broadcast_near(
at,
128.0,
&C::DamageEvent(play::DamageEvent {
id,
source_type,
cause,
direct,
position: None,
}),
None,
);
}
pub fn swing(&self, id: i32, hand: i32, at: [f64; 3], except: Option<u32>) {
self.broadcast_near(
at,
@@ -428,15 +455,17 @@ impl Hub {
let Slots::Block(pos) = m.slots else {
return;
};
let Some(BlockEntity::Furnace(f)) = world.block_entities.get(&pos) else {
return;
};
let data = [
f.lit_remaining,
f.lit_total,
f.cooking_timer,
f.cooking_total,
]
// AbstractFurnaceBlockEntity.dataAccess, BrewingStandBlockEntity.dataAccess.
let data = match world.block_entities.get(&pos) {
Some(BlockEntity::Furnace(f)) => [
f.lit_remaining,
f.lit_total,
f.cooking_timer,
f.cooking_total,
],
Some(BlockEntity::Brewing(b)) => [b.brew_time, b.fuel, b.total_brew_time, b.total_fuel],
_ => return,
}
.map(|v| v as i16);
m.sent_data.resize(data.len(), i16::MIN);
let window = m.window;
+1 -1
View File
@@ -429,7 +429,7 @@ pub fn tick(
true
}
/// LivingEntity.updateSwingTime (6-tick swing duration).
fn tick_swing(m: &mut Mob) {
pub fn tick_swing(m: &mut Mob) {
if m.ai.swinging {
m.ai.swing_time += 1;
if m.ai.swing_time >= 6 {
@@ -244,6 +244,18 @@ packet!(EntityPositionSync { id: i32 => VarInt, path: PositionPath => PositionPa
packet!(ForgetLevelChunk { pos: [i32; 2] => ChunkPos });
packet!(GameEvent { event: u8 => UByte, param: f32 => Float });
packet!(HurtAnimation { id: i32 => VarInt, yaw: f32 => Float });
packet!(
/// ClientboundDamageEventPacket: the hurt entity, the damage type's
/// registry id, the causing and the direct entity (none: -1 on the wire's
/// id + 1) and the source's position.
DamageEvent {
id: i32 => VarInt,
source_type: i32 => VarInt,
cause: Option<i32> => OptVarInt,
direct: Option<i32> => OptVarInt,
position: Option<[f64; 3]> => Opt<Vec3d>,
}
);
packet!(LevelChunkWithLight { x: i32 => Int, z: i32 => Int, chunk: ChunkData => ChunkData, light: LightData => LightData });
packet!(LevelEvent { kind: i32 => Int, pos: [i32; 3] => Pos, data: i32 => Int, global: bool => Bool });
packet!(LightUpdate { x: i32 => VarInt, z: i32 => VarInt, light: LightData => LightData });
@@ -1406,6 +1418,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
GameEvent(GameEvent) = GAME_EVENT,
GameRuleValues(GameRuleValues) = GAME_RULE_VALUES,
HurtAnimation(HurtAnimation) = HURT_ANIMATION,
DamageEvent(DamageEvent) = DAMAGE_EVENT,
KeepAlive(KeepAlive) = KEEP_ALIVE,
InitializeBorder(InitializeBorder) = INITIALIZE_BORDER,
LevelChunkWithLight(LevelChunkWithLight) = LEVEL_CHUNK_WITH_LIGHT,
@@ -2141,6 +2154,13 @@ mod tests {
}],
}],
}),
Clientbound::DamageEvent(DamageEvent {
id: 7,
source_type: 31,
cause: Some(0),
direct: None,
position: Some([1.5, -2.0, 3.25]),
}),
Clientbound::SetHealth(SetHealth {
health: 17.5,
food: 18,
+218 -5
View File
@@ -22,8 +22,15 @@ address. The script then checks, from both sides, what a guest's game used to ge
- respawn: a player killed is drawn dead by the other side, and alive again once it respawns (the
respawned player is a new entity to the others); the host dies, and on the server thread
the guest too;
- hurt: a pig the guest hits, and one the host hits, flashes red on the other side too (the
server's damage event) (#848);
- containers: the guest's ender chest holds the guest's items, not the host's, and a double chest
opens as 9x6 with the right half first;
- machines: a hopper, a dispenser and a dropper open on the guest's screen with their 5 and 9
slots, and keep what the guest shift-clicks into them (#848);
- brewing: a brewing stand opens on the guest's screen; blaze powder, nether wart and a bottle the
guest shift-clicks go to the fuel, ingredient and bottle slots, and the stand's fuel shows
(#848);
- feedback: the guest's command feedback names the guest and shows the numbers;
- chat: a line the host sends reaches the guest's event log (`wait-for chat`), and on the
server thread the host window's too;
@@ -54,6 +61,12 @@ address. The script then checks, from both sides, what a guest's game used to ge
guest as the host has them (kind, place, rotation, what they hold or wear, the variant).
- projectiles: primed TNT (one falling), an end crystal and an arrow that lands reach the guest where
the host has them; a falling block lands on both sides.
- plates: a stone pressure plate the guest steps on is pressed on both sides, and comes up once
the guest steps off (#848).
- swings: each side sees the other's arm swing while it mines a block, and the guest sees its own
swing when its hoe tills dirt (the server's use) (#848).
- time: the guest's clock is the host's: it stops with `/time pause`, and catches up with the
server's game time after the guest's game stalls for a few seconds (#848).
- light: the guest's light matches the host's: around a torch placed near it, under a roof and
once a block of the roof is removed under the sky, and in the last chunk it tracks next to
glowstone in a chunk it doesn't (only the server's light updates bring that one).
@@ -74,6 +87,7 @@ import json
import os
import re
import shutil
import signal
import subprocess
import sys
import tempfile
@@ -89,7 +103,7 @@ ROOT = Path(__file__).resolve().parents[1]
GUEST_NAME = 'Guest'
# The host plays single player on its server thread (--server-thread).
SERVER_THREAD = False
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'commands', 'recipes', 'trials', 'clicks', 'sleep', 'push', 'eat', 'xp', 'objects', 'projectiles', 'light', 'effects', 'portals', 'distance']
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'hurt', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'commands', 'recipes', 'machines', 'brewing', 'trials', 'clicks', 'sleep', 'push', 'eat', 'xp', 'objects', 'projectiles', 'light', 'time', 'swings', 'plates', 'effects', 'portals', 'distance']
class Game:
@@ -619,6 +633,33 @@ def check_respawn(host, guest, out):
return results
def check_hurt(host, guest, out):
for g in (host, guest):
g('command', command='gamemode survival')
g('command', command='clear @s')
face_each_other(host, guest)
hx, hy, hz = pos_of(host)
# Between the two, within both players' reach; bare hands leave it alive.
host('command', command=f'summon minecraft:pig {hx + 1.5:.2f} {hy:.2f} {hz + 0.8:.2f} {{NoAI:1b}}')
host.wait(30)
def pig(game):
return game.raw('entities', 6, 'pig')['entities'][0]
seen = {}
for name, attacker, watcher in (('guest hits', guest, host), ('host hits', host, guest)):
attacker.wait(30) # a full attack charge
attacker.raw('attack', pig(attacker)['id'])
seen[name] = {'attacker': pig(attacker)['hurt_time'], 'watcher': pig(watcher)['hurt_time']}
attacker.shot(out, 'hurt-' + name.replace(' ', '-'))
watcher.shot(out, 'hurt-' + name.replace(' ', '-') + '-seen')
attacker.wait(20)
host('command', command='kill @e[type=minecraft:pig]')
# LivingEntity.hurtTime: 10 when hurt, one less each tick; the reads come a few ticks later.
for name, sides in seen.items():
assert all(t > 0 for t in sides.values()), f'{name}: the pig\'s hurt flash {seen}'
return seen
def container_items(game):
"""The open container's stacks by slot (Horizon's container slots are 300 and up) and its size."""
slots = [s for s in game('screen').get('slots', []) if s.get('slot', 0) >= 300]
@@ -681,6 +722,69 @@ def check_containers(host, guest, out):
return {'ender': ender, 'double': double, 'taken': taken}
def check_machines(host, guest, out):
# After `recipes`: the stacks given here unlock recipes, which would move the book's first one.
guest('command', command='gamemode survival')
x, y, z = (int(v // 1) for v in pos_of(guest))
at = (x, y, z + 2)
seen = {}
for block in ('hopper', 'dispenser', 'dropper'):
guest('command', command='clear @s')
guest('command', command='give @s minecraft:cobblestone 4')
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:{block}')
guest.wait(10)
guest.raw('interact', *at)
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
guest.wait(10)
size = container_items(guest)[1]
guest.shot(out, f'machines-{block}')
guest.raw('click-slot', hotbar_slot(guest, 'minecraft:cobblestone'), 'shift')
guest.wait(10)
guest.raw('key', 'esc', 'tap')
guest.wait(10)
# Opened again, the menu holds what the server has.
seen[block] = (size, open_container(guest, at, out, f'machines-{block}-after')[0])
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
want = {'hopper': (5, {0: 'minecraft:cobblestone'}), 'dispenser': (9, {0: 'minecraft:cobblestone'}),
'dropper': (9, {0: 'minecraft:cobblestone'})}
assert seen == want, f'the machines on the guest: {seen}'
return seen
def check_brewing(host, guest, out):
guest('command', command='gamemode survival')
guest('command', command='clear @s')
loads = ('blaze_powder', 'nether_wart', 'glass_bottle')
for item in loads:
guest('command', command=f'give @s minecraft:{item}')
x, y, z = (int(v // 1) for v in pos_of(guest))
at = (x, y, z + 2)
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:brewing_stand')
guest.wait(10)
guest.raw('interact', *at)
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
guest.wait(10)
size = container_items(guest)[1]
# BrewingStandMenu.quickMoveStack: blaze powder to the fuel slot, an ingredient to its slot,
# a bottle to the bottle slots.
for item in loads:
guest.raw('click-slot', hotbar_slot(guest, f'minecraft:{item}'), 'shift')
guest.wait(5)
# BrewingStandBlockEntity.serverTick burns the blaze powder into 20 uses; the guest sees them
# through the menu's data slots.
guest.wait(10)
info = guest.raw('menu')['info']
guest.shot(out, 'brewing')
guest.raw('key', 'esc', 'tap')
guest.wait(10)
items = open_container(guest, at, out, 'brewing-after')[0]
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
seen = {'size': size, 'items': items, 'fuel': info and info.get('fuel')}
want = {'size': 5, 'items': {0: 'minecraft:glass_bottle', 3: 'minecraft:nether_wart'}, 'fuel': 20}
assert seen == want, f'the brewing stand on the guest: {seen}'
return seen
def slot_point(game, slot):
"""The GUI point in the middle of slot `slot` of the open screen (`ui`'s slot-N)."""
for w in game.raw('ui')['widgets']:
@@ -1013,6 +1117,27 @@ def wait_state(game, pos, key, want, ticks):
return state_of(game, pos, key)
def check_plates(host, guest, out):
guest('command', command='gamemode survival')
x, y, z = (int(v // 1) for v in pos_of(guest))
plate = (x + 2, y, z)
guest('command', command=f'setblock {plate[0]} {plate[1]} {plate[2]} minecraft:stone_pressure_plate')
guest.wait(10)
guest('command', command=f'tp @s {plate[0] + 0.5} {plate[1]} {plate[2] + 0.5}')
pressed = {'host': wait_state(host, plate, 'powered', 'true', 40),
'guest': wait_state(guest, plate, 'powered', 'true', 40)}
guest.shot(out, 'plate-pressed')
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
# PressurePlateBlock.getPressedTime: a stone plate checks again every 20 ticks.
released = {'host': wait_state(host, plate, 'powered', 'false', 60),
'guest': wait_state(guest, plate, 'powered', 'false', 60)}
guest('command', command=f'setblock {plate[0]} {plate[1]} {plate[2]} minecraft:air')
detail = {'pressed': pressed, 'released': released}
assert pressed == {'host': 'true', 'guest': 'true'}, f'the plate under the guest: {detail}'
assert released == {'host': 'false', 'guest': 'false'}, f'the plate after the guest left: {detail}'
return detail
def check_trials(host, guest, out):
# The host watches as a spectator, whom neither block detects: only the guest can wake them.
hx, hy, hz = pos_of(host)
@@ -1150,12 +1275,72 @@ def check_eat(host, guest, out):
return detail
def watch_mining(miner, pos, seen):
"""Mine the block at `pos` (by hand, for seconds) and sample `seen()` meanwhile."""
dig = threading.Thread(target=miner.raw, args=('break', *pos))
dig.start()
samples = []
for _ in range(8):
time.sleep(0.25)
samples.append(seen())
dig.join()
return samples
def check_swings(host, guest, out):
for g in (host, guest):
g('command', command='gamemode survival')
g('command', command='clear @s')
face_each_other(host, guest)
hx, hy, hz = (int(v // 1) for v in pos_of(host))
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
# Stone by hand takes 7.5 seconds: ServerboundPunchPacket every tick keeps the arm swinging.
guest('command', command=f'setblock {gx} {gy} {gz + 1} minecraft:stone')
host('command', command=f'setblock {hx} {hy} {hz + 1} minecraft:stone')
host.wait(10)
def swinging(game):
# On its server thread the host's window is a client of its server too.
if game is host and not SERVER_THREAD:
return lan_guest(host).get('model_swinging')
return any(e.get('swinging') for e in remote(game).get('player_equipment', []))
# Most samples: a swing restarts only past its first half (SwingState.startIfAble), so a sample can
# fall between two.
host_sees = watch_mining(guest, (gx, gy, gz + 1), lambda: swinging(host))
guest_sees = watch_mining(host, (hx, hy, hz + 1), lambda: swinging(guest))
# A hoe on dirt: the server tills it and swings the guest's hand, on the guest's screen too.
# Where the guest stands now (an earlier check can leave it on a ledge it then drops from).
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
soil = (gx, gy - 1, gz + 1)
guest('command', command=f'setblock {soil[0]} {soil[1]} {soil[2]} minecraft:dirt')
# HoeItem.useOn tills only under air.
guest('command', command=f'setblock {soil[0]} {soil[1] + 1} {soil[2]} minecraft:air')
guest('command', command='give @s minecraft:wooden_hoe')
guest.wait(10)
guest.raw('select', 'minecraft:wooden_hoe')
before = guest('state')['swings']
guest.raw('interact', *soil)
guest.wait(10)
hoe = {'swings': guest('state')['swings'] - before, 'soil': host.raw('block', *soil)['block']}
guest('command', command=f'setblock {soil[0]} {soil[1]} {soil[2]} minecraft:stone')
detail = {'host_sees_guest': host_sees, 'guest_sees_host': guest_sees, 'hoe': hoe}
assert sum(map(bool, host_sees)) >= 4, f'the host does not see the guest mine: {detail}'
assert sum(map(bool, guest_sees)) >= 4, f'the guest does not see the host mine: {detail}'
assert hoe == {'swings': 1, 'soil': 'minecraft:farmland'}, f'the guest\'s hoe: {detail}'
return detail
def check_xp(host, guest, out):
for g in (host, guest):
g('command', command='gamemode survival')
g('command', command='xp set @s 0 levels')
g('command', command='xp set @s 0 points')
# Out of the orbs' way wherever an earlier check left the host (ExperienceOrb follows the
# nearest player who isn't a spectator).
host('command', command='gamemode spectator')
guest.wait(10)
# `xp set` leaves Player.totalExperience (the score): experience from earlier checks stays in it.
total = guest('state')['player']['xp']['total']
host_total = host('state')['player']['xp']['total']
x, y, z = pos_of(guest)
# Three orbs of 7 points 4 blocks east of the guest (out of reach, within the 8 blocks orbs
# follow from): they fly to the guest.
@@ -1171,12 +1356,35 @@ def check_xp(host, guest, out):
detail = {'seen': seen, 'guest': xp, 'host': host_xp, 'left': left}
# ExperienceOrb.scanForMerges: orbs of one value side by side can merge into one.
assert 1 <= seen <= 3, f'the guest saw {seen} orbs: {detail}'
assert xp['total'] == 21 and xp['level'] == 2, f'the guest\'s experience after three 7-point orbs: {detail}'
assert host_xp['total'] == 0, f'the host took the orbs: {detail}'
assert xp['total'] - total == 21 and xp['level'] == 2, f'the guest\'s experience after three 7-point orbs: {detail}'
assert host_xp['total'] == host_total, f'the host took the orbs: {detail}'
assert left == 0, f'orbs left on the guest\'s screen: {detail}'
return {'seen': seen, 'guest': xp, 'host': host_xp}
def check_time(host, guest, out):
def times():
return host('state')['time']['day_time'], guest('state')['time']['day_time']
host('command', command='time set 1000')
host('command', command='time pause')
host.wait(60)
paused = times()
host('command', command='time resume')
guest.wait(20)
# A stalled game runs at most 10 missed ticks when it wakes (DeltaTracker); the server's
# game time, sent every second, moves the guest's clock on (ClientClockManager.tick).
os.kill(guest.pid, signal.SIGSTOP)
time.sleep(4)
os.kill(guest.pid, signal.SIGCONT)
guest.wait(60)
stalled = times()
detail = {'paused': paused, 'after_stall': stalled}
assert paused[0] == paused[1] and 1000 <= paused[0] < 1010, f'the clock while paused: {detail}'
# The two reads are a tick or two apart.
assert abs(stalled[0] - stalled[1]) <= 3, f'the guest\'s clock after its game stalled: {detail}'
return detail
def objects_near(game, centre, key='objects'):
"""`status`'s objects within 8 blocks of centre, without their ids (each side numbers its own)."""
out = []
@@ -1335,12 +1543,17 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'name
'mobs': check_mobs, 'hazards': check_hazards, 'pvp': check_pvp,
'respawn': check_respawn,
'containers': check_containers, 'feedback': check_feedback, 'titles': check_titles, 'chat': check_chat,
'machines': check_machines,
'brewing': check_brewing,
'hurt': check_hurt,
'commands': check_commands,
'recipes': check_recipes, 'trials': check_trials, 'walls': check_walls,
'effects': check_effects, 'portals': check_portals, 'distance': check_distance, 'clicks': check_clicks,
'sleep': check_sleep, 'push': check_push, 'eat': check_eat,
'xp': check_xp, 'objects': check_objects, 'projectiles': check_projectiles,
'light': check_light}
'light': check_light, 'plates': check_plates,
'swings': check_swings,
'time': check_time}
def run(ctl, out, resources, checks):
@@ -1363,7 +1576,7 @@ def run(ctl, out, resources, checks):
host('command', command='gamerule spawn_mobs false')
port = host.raw('lan', 'open', 0, 'commands')['port']
host.player = host.raw('lan', 'status').get('host', 'Player') # the host's profile name
guest('launch', wait=False, offscreen=sys.platform == 'darwin')
guest.pid = guest('launch', wait=False, offscreen=sys.platform == 'darwin')['pid']
guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME)
guest.raw('wait-for', 'loaded', '--timeout', 300)
guest.wait(40)