diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index deaaf1b44..dc56fc4fa 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -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, /// 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:") { diff --git a/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index 15dfa4d1a..5d7d294dd 100644 --- a/crates/horizon-client/src/game/agent.rs +++ b/crates/horizon-client/src/game/agent.rs @@ -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 { diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index 11b1e0267..20aeeb7fd 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -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; diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 97104ac00..6e9b3d84a 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -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", diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 70ae005f3..7c9bd380b 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -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::>(), }), _ => 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); } } diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index de97d0d91..e2f91080e 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -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, /// The inventory menu and the crafting table the guest crafts in. @@ -121,6 +123,8 @@ pub(super) struct GuestSwap { acting: (Option, Option, Option), /// The packets queued for that guest. packets: Vec, + /// 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) { + 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 = 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, + }) +} diff --git a/crates/horizon-client/src/game/lan_crafting.rs b/crates/horizon-client/src/game/lan_crafting.rs index 5351c0575..de26e84aa 100644 --- a/crates/horizon-client/src/game/lan_crafting.rs +++ b/crates/horizon-client/src/game/lan_crafting.rs @@ -21,9 +21,10 @@ pub(super) struct GuestCrafting { inventory: Option>, /// The open crafting table: its window, state id and menu. table: Option<(i32, i32, Box)>, - /// 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)>, + /// 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)>, } /// 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::>() }); - 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 } diff --git a/crates/horizon-client/src/game/lan_mobs.rs b/crates/horizon-client/src/game/lan_mobs.rs index 99888058c..0ee991737 100644 --- a/crates/horizon-client/src/game/lan_mobs.rs +++ b/crates/horizon-client/src/game/lan_mobs.rs @@ -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 { diff --git a/crates/horizon-client/src/game/machines.rs b/crates/horizon-client/src/game/machines.rs index ee2705c23..c05c7295f 100644 --- a/crates/horizon-client/src/game/machines.rs +++ b/crates/horizon-client/src/game/machines.rs @@ -770,6 +770,14 @@ enum Screen { impl Game<'_> { fn machine_screen(&self) -> Option { 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 { diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 545133427..43e0e775c 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -151,7 +151,12 @@ pub(super) struct Remote { synced: Vec>, decode_errors: u32, clock: Option, + /// `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 { let s = usize::try_from(slot).ok()?; - let rows = |n: usize| -> Option { + // A container of `n` slots, then the inventory and the hotbar. + let slots = |n: usize| -> Option { 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 { + 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 { 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(), diff --git a/crates/horizon-client/src/game/server_travel.rs b/crates/horizon-client/src/game/server_travel.rs index 6c0ec48e7..56fdbaca5 100644 --- a/crates/horizon-client/src/game/server_travel.rs +++ b/crates/horizon-client/src/game/server_travel.rs @@ -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), diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index 03045b403..e8872b159 100644 --- a/crates/horizon-client/src/game/stations.rs +++ b/crates/horizon-client/src/game/stations.rs @@ -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( diff --git a/crates/horizon-client/src/lan/containers.rs b/crates/horizon-client/src/lan/containers.rs index 3ecd58b80..66fbfe4b6 100644 --- a/crates/horizon-client/src/lan/containers.rs +++ b/crates/horizon-client/src/lan/containers.rs @@ -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, + direct: Option, + 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) { 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; diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index f74a3c7d1..475bd7611 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -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 { diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index 8f1193fc9..95a68d1ef 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -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 => OptVarInt, + direct: Option => OptVarInt, + position: Option<[f64; 3]> => Opt, + } +); 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, diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 61c88d981..3f04c57f6 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -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)