mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
16 files changed
+611
-94
No files matched your search
@@ -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:") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user