Skip to main content

ghast_client/
engine.rs

1//! The deterministic client core: game state, the behaviour contract and the tick.
2//!
3//! No I/O and no clock here: the connection feeds decoded [`Inbound`] packets and the time
4//! in, and takes the [`Outbound`] packets out, so the tick order can be tested without a
5//! socket.
6//!
7//! # Tick model
8//!
9//! Mirrors 1.8.9 `MinecraftClient.tick` as recorded in vanilla-client packet traces (and is the same
10//! in 1.16.5):
11//!
12//! 1. inbound packets that arrived since the last tick have been applied (on arrival, like
13//!    vanilla's per-frame task queue; keep-alives, teleports and window confirms are
14//!    answered then);
15//! 2. [`Behavior::tick`] runs. Actions (`chat`, `select_slot`, `swing`, `respawn`, …) are
16//!    written immediately, in call order, before this tick's movement packet;
17//!    [`TickContext::set_aim`] / [`TickContext::set_controls`] only record intent;
18//! 3. held slot sync → rotation step (whole mouse counts) → movement step → sprint change →
19//!    sneak change → exactly one movement packet.
20//!
21//! The server therefore judges this tick's actions with the *previous* tick's sent pose
22//! ([`TickContext::sent`]).
23
24use std::panic::{AssertUnwindSafe, catch_unwind};
25use std::sync::Arc;
26use std::time::{Duration, Instant};
27
28use crate::common::{
29    Abilities, ChatMessage, ClientSettings, Controls, GameMode, Health, Inbound, MovePacket,
30    Outbound, Pose, Vec3,
31};
32use crate::hands::{self, BlockHit, Buttons, DigFactors, Hands, HandsIo};
33use crate::interest::Interests;
34use crate::rotation::Rotation;
35use crate::sim::{Sim, WorldView};
36use crate::version::GameVersion;
37
38/// Pending chat messages kept for the behaviour; more are dropped (and counted) while the
39/// behaviour is not ticking.
40const MAX_PENDING_CHAT: usize = 512;
41
42/// A bot behaviour: the code that decides what the player does each tick.
43///
44/// Behaviours never see packets or versions: they read a [`TickContext`] and call its
45/// actions. A panic in a behaviour stops that behaviour (its [`Behavior::stop`] is called with
46/// `"panic"`) but not the connection: the player keeps standing and answering the server.
47pub trait Behavior {
48    /// Name for logs.
49    fn name(&self) -> &str {
50        "behavior"
51    }
52
53    /// Optional packet groups this behaviour needs. Read every tick; a change updates the
54    /// packet filter. Everything not asked for is dropped before it is decoded.
55    fn interests(&self) -> Interests {
56        Interests::NONE
57    }
58
59    /// Extra clientbound play packet ids (of `version`) to copy undecoded into
60    /// [`TickEvents::raw`], including packets the core consumes itself (the core's handling
61    /// does not change). Read every tick like [`Behavior::interests`]; a change updates the
62    /// packet filter. Decoding is the behaviour's job (`ghast::protocol::v1_8::play::clientbound`
63    /// / `v1_16_5`).
64    fn raw_packets(&self, version: GameVersion) -> std::borrow::Cow<'static, [i32]> {
65        let _ = version;
66        std::borrow::Cow::Borrowed(&[])
67    }
68
69    /// Called on the first tick the player is in the world, before that tick's
70    /// [`Behavior::tick`] (both see the same events).
71    fn start(&mut self, ctx: &mut TickContext<'_>) {
72        let _ = ctx;
73    }
74
75    /// Called every tick while the player is in the world and the server is answering.
76    fn tick(&mut self, ctx: &mut TickContext<'_>);
77
78    /// Whether the behaviour still wants ticks. Once it returns `false` the behaviour is done:
79    /// it is not ticked again (the player keeps standing and answering the server) and
80    /// [`Behavior::stop`] still runs when the session ends. Read after every start / tick.
81    fn running(&self) -> bool {
82        true
83    }
84
85    /// Called after every tick's movement step (also while the behaviour is on hold or has
86    /// finished), with a read-only view of what the server now has: the pose sent this tick,
87    /// the world, entities and inventory. For observers (a viewer, a recorder); it cannot act.
88    /// Default: nothing.
89    fn after_tick(&mut self, view: &TickView<'_>) {
90        let _ = view;
91    }
92
93    /// Called once when the session ends (or the behaviour panicked).
94    fn stop(&mut self, reason: &str) {
95        let _ = reason;
96    }
97}
98
99/// A boxed behaviour is a behaviour (a runner can pick one from its config at run time).
100impl<B: Behavior + ?Sized> Behavior for Box<B> {
101    fn name(&self) -> &str {
102        (**self).name()
103    }
104    fn interests(&self) -> Interests {
105        (**self).interests()
106    }
107    fn raw_packets(&self, version: GameVersion) -> std::borrow::Cow<'static, [i32]> {
108        (**self).raw_packets(version)
109    }
110    fn start(&mut self, ctx: &mut TickContext<'_>) {
111        (**self).start(ctx);
112    }
113    fn tick(&mut self, ctx: &mut TickContext<'_>) {
114        (**self).tick(ctx);
115    }
116    fn running(&self) -> bool {
117        (**self).running()
118    }
119    fn after_tick(&mut self, view: &TickView<'_>) {
120        (**self).after_tick(view);
121    }
122    fn stop(&mut self, reason: &str) {
123        (**self).stop(reason);
124    }
125}
126
127/// Why [`TickContext::click`] (or opening / closing a window) sent nothing.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129#[non_exhaustive]
130pub enum ClickRefused {
131    /// The player is turning: click only while still.
132    Turning,
133    /// The inventory refused it.
134    Inventory(ghast_inventory::ClickError),
135}
136
137impl std::fmt::Display for ClickRefused {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        match self {
140            ClickRefused::Turning => f.write_str("the player is turning"),
141            ClickRefused::Inventory(e) => write!(f, "{e}"),
142        }
143    }
144}
145
146impl std::error::Error for ClickRefused {}
147
148/// A click that was sent: match it with [`TickEvents::inventory`]'s `clicks` on a later tick
149/// (window id and action number, as the server's confirm carries them).
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
151pub struct ClickTicket {
152    /// Window id the click was sent for.
153    pub window_id: i32,
154    /// Its action number.
155    pub action: i16,
156}
157
158impl ClickTicket {
159    /// Whether `r` is the result of this click.
160    #[must_use]
161    pub fn matches(&self, r: &ghast_inventory::ClickResult) -> bool {
162        r.window_id == self.window_id && r.action == self.action
163    }
164}
165
166/// A behaviour that does nothing (the player stands still).
167#[derive(Debug, Clone, Copy, Default)]
168pub struct Idle;
169
170impl Behavior for Idle {
171    fn name(&self) -> &str {
172        "idle"
173    }
174
175    fn tick(&mut self, _ctx: &mut TickContext<'_>) {}
176}
177
178/// A clientbound play packet copied for the behaviour ([`Behavior::raw_packets`]).
179#[derive(Debug, Clone, PartialEq, Eq)]
180#[non_exhaustive]
181pub struct RawInbound {
182    /// Packet id (version-specific).
183    pub id: i32,
184    /// The body after the id, undecoded (a cheap reference-counted copy).
185    pub body: bytes::Bytes,
186    /// When the frame was decoded from the socket (frames read together share it).
187    pub at: Instant,
188}
189
190/// A server teleport / correction (`position`) with the poses around it.
191#[derive(Debug, Clone, Copy, PartialEq)]
192#[non_exhaustive]
193pub struct TeleportEvent {
194    /// The packet's values as received: absolute or relative per
195    /// [`crate::common::teleport_flags`] in `flags`.
196    pub teleport: crate::common::Teleport,
197    /// What the server had from us just before ([`TickContext::sent`] before the overwrite):
198    /// compare with [`TeleportEvent::after`] to see what the server moved / turned us by.
199    pub sent_before: Pose,
200    /// The client pose just before (it may be ahead of `sent_before` within a tick).
201    pub pose_before: Pose,
202    /// The pose after applying it (relative fields resolved).
203    pub after: Pose,
204}
205
206/// Most teleports kept between two behaviour ticks.
207const MAX_PENDING_TELEPORTS: usize = 64;
208
209/// Most raw packets kept between two behaviour ticks.
210const MAX_PENDING_RAW: usize = 4096;
211
212/// What happened since the behaviour's previous tick.
213#[derive(Debug, Clone, Default, PartialEq)]
214#[non_exhaustive]
215pub struct TickEvents {
216    /// Chat messages in arrival order (only with [`Interests::CHAT`]).
217    pub chat: Vec<ChatMessage>,
218    /// Chat messages dropped because too many piled up while the behaviour was not ticking.
219    pub chat_dropped: usize,
220    /// The server teleported / corrected the player.
221    pub teleported: bool,
222    /// Every teleport in arrival order, with the sent pose before it (at most 64 per tick).
223    pub teleports: Vec<TeleportEvent>,
224    /// A join game packet arrived (first join or a proxy server switch).
225    pub joined: bool,
226    /// The player respawned or changed dimension.
227    pub respawned: bool,
228    /// The player died (health reached 0).
229    pub died: bool,
230    /// Health, food or saturation changed.
231    pub health_changed: bool,
232    /// The game mode changed.
233    pub game_mode_changed: bool,
234    /// Inventory / window changes and click results (ghast-inventory).
235    pub inventory: ghast_inventory::Events,
236    /// Block changes in arrival order (only with [`Interests::BLOCK_UPDATES`]): position and
237    /// the version's raw block state (1.8 `id << 4 | meta`, 1.16 the global palette id).
238    /// The world already holds them when the behaviour ticks.
239    pub block_updates: Vec<(ghast_protocol::BlockPos, u32)>,
240    /// Block changes dropped because too many piled up between two behaviour ticks.
241    pub block_updates_dropped: usize,
242    /// The packets asked for with [`Behavior::raw_packets`], in arrival order (kept while the
243    /// behaviour is on hold, like chat).
244    pub raw: Vec<RawInbound>,
245    /// Raw packets dropped because more than 4096 piled up between two behaviour ticks.
246    pub raw_dropped: usize,
247}
248
249impl TickEvents {
250    fn clear(&mut self) {
251        self.chat.clear();
252        self.chat_dropped = 0;
253        self.teleported = false;
254        self.teleports.clear();
255        self.joined = false;
256        self.respawned = false;
257        self.died = false;
258        self.health_changed = false;
259        self.game_mode_changed = false;
260        self.inventory = ghast_inventory::Events::default();
261        self.block_updates.clear();
262        self.block_updates_dropped = 0;
263        self.raw.clear();
264        self.raw_dropped = 0;
265    }
266}
267
268/// Tunables of the core.
269#[derive(Debug, Clone)]
270pub(crate) struct CoreOptions {
271    pub name: Arc<str>,
272    pub version: GameVersion,
273    pub settings: ClientSettings,
274    pub brand: Option<String>,
275    pub sensitivity: f32,
276    pub silent_hold: Duration,
277    pub inventory: ghast_inventory::Version,
278    /// Keep the look still on a tick that dug / placed when the turn would take the
279    /// crosshair off the clicked block (Grim RotationPlace re-checks with the next look).
280    pub hold_turn_on_action: bool,
281}
282
283/// Client state shared by the connection and the behaviour context.
284#[derive(Debug)]
285pub(crate) struct Core {
286    opts: CoreOptions,
287    tick: u64,
288    entity_id: i32,
289    joined: bool,
290    teleported_once: bool,
291    ready: bool,
292    pose: Pose,
293    sent: Pose,
294    rotation: Rotation,
295    aimed: bool,
296    controls: Controls,
297    health: Health,
298    dead: bool,
299    abilities: Abilities,
300    game_mode: GameMode,
301    held_slot: u8,
302    sent_slot: u8,
303    open_window: i32,
304    dimension: String,
305    respawn_screen: bool,
306    pending: TickEvents,
307    current: TickEvents,
308    out: Vec<Outbound>,
309    last_inbound: Option<Instant>,
310    silent: bool,
311    quit: Option<String>,
312    status: String,
313    hands: Hands,
314    buttons: Buttons,
315    /// Inventory and windows (ghast-inventory).
316    inv: ghast_inventory::Inventory,
317    /// The look changed in the last movement step (window clicks need a still player).
318    turned: bool,
319    /// Our active status effects: id → amplifier.
320    effects: std::collections::BTreeMap<i8, i8>,
321    /// Aqua affinity on the helmet (dig speed under water).
322    aqua_affinity: bool,
323    /// Blocks broken on the client this tick (predicted air).
324    broken_now: Vec<ghast_protocol::BlockPos>,
325    /// Flight wanted by the behaviour this tick ([`TickContext::set_flying`]).
326    want_flying: Option<bool>,
327    /// Turns held back because they would have moved the crosshair off a clicked block.
328    held_turns: u64,
329    /// Arm swings (main and off hand) written in finished ticks.
330    swings: u64,
331    /// Test hook: this tick's look, set instead of a rotation step.
332    #[cfg(test)]
333    forced_look: Option<(f32, f32)>,
334}
335
336/// Behaviour bookkeeping of the engine.
337#[derive(Debug, Default)]
338struct BehaviorState {
339    started: bool,
340    failed: bool,
341    /// The behaviour reported [`Behavior::running`] `false`.
342    done: bool,
343}
344
345/// The core plus the behaviour's lifecycle.
346#[derive(Debug)]
347pub(crate) struct Engine<S> {
348    pub core: Core,
349    pub sim: S,
350    state: BehaviorState,
351}
352
353impl Core {
354    fn new(opts: CoreOptions) -> Self {
355        let rotation = Rotation::new(opts.sensitivity);
356        let inv = ghast_inventory::Inventory::new(opts.inventory);
357        Core {
358            opts,
359            tick: 0,
360            entity_id: 0,
361            joined: false,
362            teleported_once: false,
363            ready: false,
364            pose: Pose::default(),
365            sent: Pose::default(),
366            rotation,
367            aimed: false,
368            controls: Controls::default(),
369            health: Health::default(),
370            dead: false,
371            abilities: Abilities::default(),
372            game_mode: GameMode::default(),
373            held_slot: 0,
374            sent_slot: 0,
375            open_window: 0,
376            dimension: String::new(),
377            respawn_screen: true,
378            pending: TickEvents::default(),
379            current: TickEvents::default(),
380            out: Vec::with_capacity(16),
381            last_inbound: None,
382            silent: false,
383            quit: None,
384            status: String::new(),
385            hands: Hands::default(),
386            buttons: Buttons::default(),
387            inv,
388            turned: false,
389            effects: std::collections::BTreeMap::new(),
390            aqua_affinity: false,
391            broken_now: Vec::new(),
392            want_flying: None,
393            held_turns: 0,
394            swings: 0,
395            #[cfg(test)]
396            forced_look: None,
397        }
398    }
399
400    /// A new client player entity (join, respawn): `lastReported*` zero, nothing held down,
401    /// waiting for the server's position.
402    fn reset_player(&mut self, why: &str) {
403        self.teleported_once = false;
404        self.ready = false;
405        self.pose = Pose::default();
406        self.sent = Pose::default();
407        self.controls = Controls::default();
408        self.aimed = false;
409        self.open_window = 0;
410        self.dead = false;
411        self.hands.reset();
412        self.buttons = Buttons::default();
413        self.effects.clear();
414        log::debug!(
415            "[{}] t{} {why}: waiting for the server position",
416            self.opts.name,
417            self.tick
418        );
419    }
420
421    fn column_loaded(&self, world: &dyn WorldView) -> bool {
422        // BlockPos(posX, 0, posZ) floors the coordinates.
423        let cx = (self.pose.pos.x.floor() as i64 >> 4) as i32;
424        let cz = (self.pose.pos.z.floor() as i64 >> 4) as i32;
425        world.is_column_loaded(cx, cz)
426    }
427}
428
429/// The held stack's dig / use facts.
430fn held_item<W: Sim + ?Sized>(
431    inv: &ghast_inventory::Inventory,
432    sim: &W,
433) -> Option<hands::HeldItem> {
434    let s = inv.held()?;
435    let id = u16::try_from(s.id).ok()?;
436    let count = u8::try_from(s.count.clamp(0, 255)).unwrap_or(0);
437    Some(sim.held_item(id, count, efficiency(s)))
438}
439
440/// Whether rays hit the entity (`Entity.collides`): living entities, players,
441/// paintings and the solid objects (boats, minecarts, TNT, falling blocks, item frames, armor
442/// stands, leash knots, end crystals); not items, orbs, projectiles.
443fn collidable(e: &ghast_entity::Entity) -> bool {
444    use ghast_entity::{EntityClass, EntityType};
445    match e.class() {
446        EntityClass::Player | EntityClass::Mob | EntityClass::Painting => true,
447        EntityClass::Item | EntityClass::ExperienceOrb => false,
448        EntityClass::Object => match e.entity_type() {
449            EntityType::LegacyObject(t) => matches!(t, 1 | 10 | 50 | 51 | 70 | 71 | 77 | 78),
450            EntityType::Registry(_) => {
451                ghast_entity::v1_16_5::info(e.entity_type()).is_some_and(|i| {
452                    matches!(
453                        i.name,
454                        "boat"
455                            | "tnt"
456                            | "end_crystal"
457                            | "falling_block"
458                            | "item_frame"
459                            | "leash_knot"
460                            | "armor_stand"
461                            | "shulker_bullet"
462                    ) || i.name.contains("minecart")
463                })
464            }
465            _ => false,
466        },
467    }
468}
469
470/// The entity under the crosshair from `eyes` along `look` (vanilla `GameRenderer.updateTargetedEntity`), if
471/// entities are tracked.
472fn entity_pick<W: WorldView + ?Sized>(
473    w: &W,
474    version: GameVersion,
475    eyes: Vec3,
476    look: Vec3,
477    block_distance: Option<f64>,
478) -> Option<hands::EntityHit> {
479    let ents = w.entities()?;
480    let riding = ents.me().and_then(ghast_entity::Entity::vehicle);
481    // 1.8 grows entity boxes by the 0.1 collision border; 1.9+ does not.
482    let border = if version == GameVersion::V1_8_9 {
483        0.1
484    } else {
485        0.0
486    };
487    let cands = ents
488        .within([eyes.x, eyes.y, eyes.z], 8.0)
489        .filter(|e| collidable(e))
490        .filter_map(|e| {
491            let p = e.position();
492            Some((e.id(), e.bounding_box()?, Vec3::new(p[0], p[1], p[2])))
493        });
494    hands::pick_entity(cands, eyes, look, block_distance, border, riding)
495}
496
497/// The off-hand stack's facts (1.9+).
498fn off_held_item<W: Sim + ?Sized>(
499    inv: &ghast_inventory::Inventory,
500    sim: &W,
501) -> Option<hands::HeldItem> {
502    let s = inv.offhand()?;
503    let id = u16::try_from(s.id).ok()?;
504    let count = u8::try_from(s.count.clamp(0, 255)).unwrap_or(0);
505    Some(sim.held_item(id, count, 0))
506}
507
508/// Efficiency level of a stack (see [`enchant_level`]).
509fn efficiency(s: &ghast_inventory::Stack) -> u8 {
510    enchant_level(s, 32, "minecraft:efficiency")
511}
512
513/// An enchantment level from the stack NBT: the 1.8 `ench` list with numeric ids, or the 1.13+
514/// `Enchantments` list with names. Parsed only when the stack has NBT.
515fn enchant_level(s: &ghast_inventory::Stack, id_1_8: i32, name: &str) -> u8 {
516    use ghast_protocol::nbt::Tag;
517    let Some(root) = s.nbt.as_ref().and_then(|n| n.parse().ok().flatten()) else {
518        return 0;
519    };
520    let Some(c) = root.tag.as_compound() else {
521        return 0;
522    };
523    let num = |t: Option<&Tag>| match t {
524        Some(Tag::Short(v)) => i32::from(*v),
525        Some(Tag::Int(v)) => *v,
526        Some(Tag::Byte(v)) => i32::from(*v),
527        _ => -1,
528    };
529    for key in ["ench", "Enchantments"] {
530        if let Some(Tag::List(l)) = c.get(key) {
531            for e in l.items.iter().filter_map(Tag::as_compound) {
532                let hit = match e.get("id") {
533                    Some(Tag::String(n)) => n == name,
534                    other => num(other) == id_1_8,
535                };
536                if hit {
537                    return u8::try_from(num(e.get("lvl")).clamp(0, 255)).unwrap_or(0);
538                }
539            }
540        }
541    }
542    0
543}
544
545impl<S: Sim> Engine<S> {
546    pub(crate) fn new(opts: CoreOptions, sim: S) -> Self {
547        Engine {
548            core: Core::new(opts),
549            sim,
550            state: BehaviorState::default(),
551        }
552    }
553
554    /// Bytes arrived from the server (liveness for the silent-server guard).
555    pub(crate) fn note_inbound(&mut self, now: Instant) {
556        self.core.last_inbound = Some(now);
557    }
558
559    /// Time of the last inbound bytes.
560    pub(crate) fn last_inbound(&self) -> Option<Instant> {
561        self.core.last_inbound
562    }
563
564    /// The bot's name (for logs).
565    pub(crate) fn name(&self) -> &str {
566        &self.core.opts.name
567    }
568
569    /// Queues a raw packet copy for the behaviour's next tick.
570    pub(crate) fn push_raw(&mut self, p: RawInbound) {
571        let ev = &mut self.core.pending;
572        if ev.raw.len() < MAX_PENDING_RAW {
573            ev.raw.push(p);
574        } else {
575            ev.raw_dropped += 1;
576        }
577    }
578
579    /// The raw packet ids the behaviour wants (none once it failed or finished).
580    pub(crate) fn raw_packets<B: Behavior>(&self, b: &B) -> std::borrow::Cow<'static, [i32]> {
581        if self.state.failed || self.state.done {
582            std::borrow::Cow::Borrowed(&[])
583        } else {
584            b.raw_packets(self.core.opts.version)
585        }
586    }
587
588    /// The inventory (the connection feeds it its packets).
589    pub(crate) fn inventory_mut(&mut self) -> &mut ghast_inventory::Inventory {
590        &mut self.core.inv
591    }
592
593    /// Outbound packets produced since the last call, in send order.
594    pub(crate) fn outbound(&mut self) -> &mut Vec<Outbound> {
595        &mut self.core.out
596    }
597
598    /// Set when the behaviour asked to disconnect.
599    pub(crate) fn quit_requested(&self) -> Option<&str> {
600        self.core.quit.as_deref()
601    }
602
603    pub(crate) fn core_name(&self) -> &str {
604        &self.core.opts.name
605    }
606
607    pub(crate) fn tick_count(&self) -> u64 {
608        self.core.tick
609    }
610
611    /// Applies one inbound packet (except disconnect and compression, which the connection
612    /// handles).
613    pub(crate) fn handle(&mut self, p: Inbound) {
614        let c = &mut self.core;
615        let sim = &mut self.sim;
616        match p {
617            Inbound::KeepAlive(id) => c.out.push(Outbound::KeepAlive(id)),
618            Inbound::JoinGame(j) => {
619                c.entity_id = j.entity_id;
620                c.game_mode = j.game_mode;
621                c.respawn_screen = j.respawn_screen;
622                // A join always creates a new client world (the world itself was cleared by
623                // the world packet routing).
624                c.dimension = j.dimension;
625                c.joined = true;
626                c.pending.joined = true;
627                c.health = Health::default();
628                c.abilities = Abilities::default();
629                c.reset_player("join game");
630                sim.reset_player();
631                sim.spectator(c.game_mode == GameMode::Spectator);
632                // ClientPlayNetworkHandler.onGameJoin: settings, then the brand.
633                c.out.push(Outbound::Settings(c.opts.settings.clone()));
634                if let Some(brand) = &c.opts.brand {
635                    c.out.push(Outbound::Brand(brand.clone()));
636                }
637                log::info!(
638                    "[{}] joined: entity {} {:?} dimension {}",
639                    c.opts.name,
640                    c.entity_id,
641                    c.game_mode,
642                    c.dimension
643                );
644            }
645            Inbound::Respawn {
646                dimension,
647                game_mode,
648            } => {
649                if dimension != c.dimension {
650                    c.dimension = dimension;
651                }
652                c.game_mode = game_mode;
653                c.pending.respawned = true;
654                c.reset_player("respawn");
655                sim.reset_player();
656                sim.spectator(c.game_mode == GameMode::Spectator);
657            }
658            Inbound::Teleport(t) => {
659                if c.teleported_once {
660                    log::debug!(
661                        "[{}] t{} teleport {:?} {:?} {:?} yaw {:?} pitch {:?} flags {}; we had sent \
662                         {:?} {:?} {:?} yaw {:?} pitch {:?} ground {}",
663                        c.opts.name,
664                        c.tick,
665                        t.x,
666                        t.y,
667                        t.z,
668                        t.yaw,
669                        t.pitch,
670                        t.flags,
671                        c.sent.pos.x,
672                        c.sent.pos.y,
673                        c.sent.pos.z,
674                        c.sent.yaw,
675                        c.sent.pitch,
676                        c.sent.on_ground
677                    );
678                }
679                let (sent_before, pose_before) = (c.sent, c.pose);
680                sim.teleport(&mut c.pose, &t);
681                if c.pending.teleports.len() < MAX_PENDING_TELEPORTS {
682                    c.pending.teleports.push(TeleportEvent {
683                        teleport: t,
684                        sent_before,
685                        pose_before,
686                        after: c.pose,
687                    });
688                }
689                if let Some(id) = t.teleport_id {
690                    c.out.push(Outbound::TeleportConfirm(id));
691                }
692                c.out.push(Outbound::Move(MovePacket::PositionLook {
693                    pos: c.pose.pos,
694                    yaw: c.pose.yaw,
695                    pitch: c.pose.pitch,
696                    on_ground: false,
697                }));
698                // The server now has this pose; vanilla's lastReported* are not touched.
699                c.sent = Pose {
700                    on_ground: false,
701                    ..c.pose
702                };
703                if !c.teleported_once {
704                    log::debug!(
705                        "[{}] t{} spawn position {:.3} {:.3} {:.3}",
706                        c.opts.name,
707                        c.tick,
708                        c.pose.pos.x,
709                        c.pose.pos.y,
710                        c.pose.pos.z
711                    );
712                }
713                c.teleported_once = true;
714                c.pending.teleported = true;
715            }
716            Inbound::Chat(m) => {
717                if c.pending.chat.len() < MAX_PENDING_CHAT {
718                    c.pending.chat.push(m);
719                } else {
720                    c.pending.chat_dropped += 1;
721                }
722            }
723            Inbound::Health(h) => {
724                let was_dead = c.dead;
725                c.health = h;
726                sim.food(h.food);
727                c.pending.health_changed = true;
728                if h.health <= 0.0 && !was_dead {
729                    c.dead = true;
730                    c.pending.died = true;
731                    log::info!("[{}] t{} died", c.opts.name, c.tick);
732                }
733            }
734            Inbound::Died { entity_id } => {
735                if entity_id == c.entity_id && !c.respawn_screen {
736                    // 1.16 immediate respawn: the client respawns without a death screen.
737                    c.out.push(Outbound::Respawn);
738                }
739            }
740            Inbound::Abilities(a) => {
741                c.abilities = a;
742                c.pose.flying = a.flying();
743                sim.abilities(a);
744            }
745            Inbound::HeldSlot(s) => {
746                if (0..9).contains(&s) {
747                    // Only the inventory's selection changes; the controller's copy does not,
748                    // so the next sync echoes it (as vanilla does).
749                    if c.held_slot != s as u8 {
750                        c.hands.stop_using();
751                    }
752                    c.held_slot = s as u8;
753                }
754            }
755            Inbound::GameState { reason, value } => match reason {
756                3 => {
757                    c.game_mode = GameMode::from_wire(value as u8);
758                    sim.spectator(c.game_mode == GameMode::Spectator);
759                    c.pending.game_mode_changed = true;
760                }
761                // 1.15+: "immediate respawn" gamerule (1 = no respawn screen).
762                11 if c.opts.version != GameVersion::V1_8_9 => c.respawn_screen = value == 0.0,
763                _ => {}
764            },
765            // Window confirms are answered by the inventory (exactly once).
766            Inbound::Transaction { .. } => {}
767            Inbound::OpenWindow(id) => c.open_window = id,
768            Inbound::CloseWindow(_) => c.open_window = 0,
769            Inbound::Velocity { entity_id, motion } => {
770                if entity_id == c.entity_id {
771                    sim.velocity(motion, false);
772                }
773            }
774            Inbound::ExplosionPush(motion) => sim.velocity(motion, true),
775            Inbound::MovementSpeed {
776                entity_id,
777                base,
778                modifiers,
779            } => {
780                if entity_id == c.entity_id {
781                    log::debug!(
782                        "[{}] t{} movement speed base {base:?} modifiers {modifiers:?}",
783                        c.opts.name,
784                        c.tick
785                    );
786                    let mods: Vec<(u128, f64, u8)> = modifiers
787                        .iter()
788                        .map(|&(id, amount, op)| (id.as_u128(), amount, op as u8))
789                        .collect();
790                    sim.movement_speed(base, &mods);
791                }
792            }
793            Inbound::Effect {
794                entity_id,
795                id,
796                amplifier,
797            } => {
798                if entity_id == c.entity_id {
799                    c.effects.insert(id, amplifier);
800                    sim.status_effects(&c.effects);
801                }
802            }
803            Inbound::EffectRemoved { entity_id, id } => {
804                if entity_id == c.entity_id {
805                    c.effects.remove(&id);
806                    sim.status_effects(&c.effects);
807                }
808            }
809            Inbound::EntityStatus { entity_id, status } => {
810                // 9: the server finished our item use (eating, drinking).
811                if entity_id == c.entity_id && status == 9 {
812                    c.hands.stop_using();
813                }
814            }
815            Inbound::Disconnect(_) | Inbound::SetCompression(_) => {}
816        }
817    }
818
819    /// The interests of the behaviour (the packet filter follows them).
820    pub(crate) fn interests<B: Behavior>(&self, b: &B) -> Interests {
821        if self.state.failed {
822            Interests::NONE
823        } else {
824            b.interests()
825        }
826    }
827
828    /// Ends the behaviour (session over).
829    pub(crate) fn stop<B: Behavior>(&mut self, b: &mut B, reason: &str) {
830        if self.state.started && !self.state.failed {
831            self.state.failed = true;
832            let name = self.core.opts.name.clone();
833            if catch_unwind(AssertUnwindSafe(|| b.stop(reason))).is_err() {
834                log::error!("[{name}] behaviour {} panicked in stop", b.name());
835            }
836        }
837    }
838
839    /// One client tick at `now`.
840    pub(crate) fn tick<B: Behavior>(&mut self, b: &mut B, now: Instant) {
841        let c = &mut self.core;
842        c.tick += 1;
843        // Per-tick packet-order guard and click timeout of the inventory.
844        c.inv.tick();
845        let was_ready = c.ready;
846        c.ready = c.teleported_once && c.column_loaded(&self.sim);
847        if c.ready != was_ready {
848            if c.ready {
849                log::info!(
850                    "[{}] t{} in the world at {:.3} {:.3} {:.3}",
851                    c.opts.name,
852                    c.tick,
853                    c.pose.pos.x,
854                    c.pose.pos.y,
855                    c.pose.pos.z
856                );
857            } else {
858                log::debug!(
859                    "[{}] t{} player column not loaded: not ticking",
860                    c.opts.name,
861                    c.tick
862                );
863            }
864        }
865        if !c.ready {
866            // The client player does not tick outside a loaded column (1.16 vanilla; on 1.8
867            // the bot waits for its column as well).
868            return;
869        }
870
871        let silent_for = c.last_inbound.map(|t| now.saturating_duration_since(t));
872        let silent = silent_for.is_some_and(|d| d > c.opts.silent_hold);
873        if silent != c.silent {
874            c.silent = silent;
875            if silent {
876                log::warn!(
877                    "[{}] t{} server silent for {} ms: behaviour on hold",
878                    c.opts.name,
879                    c.tick,
880                    silent_for.unwrap_or_default().as_millis()
881                );
882            } else {
883                log::info!(
884                    "[{}] t{} server answering again: behaviour resumes",
885                    c.opts.name,
886                    c.tick
887                );
888            }
889        }
890
891        if silent {
892            // Freeze: release the keys, keep only the vanilla movement packet.
893            c.controls = Controls::default();
894            c.aimed = false;
895        } else if !self.state.failed && !self.state.done {
896            std::mem::swap(&mut c.pending, &mut c.current);
897            c.pending.clear();
898            c.current.inventory = c.inv.drain_events();
899            c.current.block_updates_dropped =
900                self.sim.drain_block_updates(&mut c.current.block_updates);
901            if c.current.inventory.player_changed {
902                // Armour enchantments that change movement / digging.
903                let ench = |a: usize, id: i32, name: &str| {
904                    c.inv.armor(a).map_or(0, |s| enchant_level(s, id, name))
905                };
906                let depth = ench(3, 8, "minecraft:depth_strider");
907                let soul = ench(3, -1, "minecraft:soul_speed");
908                c.aqua_affinity = ench(0, 6, "minecraft:aqua_affinity") > 0;
909                self.sim.boots(depth, soul);
910            }
911            let started = self.state.started;
912            self.state.started = true;
913            if !started {
914                c.rotation.reset(c.pose.yaw, c.pose.pitch);
915            }
916            let name = c.opts.name.clone();
917            let result = catch_unwind(AssertUnwindSafe(|| {
918                let held = held_item(&c.inv, &self.sim);
919                let mut ctx = TickContext {
920                    core: c,
921                    world: &self.sim,
922                    held,
923                };
924                if !started {
925                    b.start(&mut ctx);
926                }
927                b.tick(&mut ctx);
928                b.running()
929            }));
930            match result {
931                Ok(true) => {}
932                Ok(false) => {
933                    self.state.done = true;
934                    log::info!("[{name}] behaviour {} finished", b.name());
935                }
936                Err(_) => {
937                    log::error!("[{name}] behaviour {} panicked: stopped", b.name());
938                    self.stop(b, "panic");
939                }
940            }
941        }
942
943        let c = &mut self.core;
944        // ClientPlayerInteractionManager.tick (syncSelectedSlot) runs before the key
945        // processing: a held slot the server changed is echoed before any click.
946        if c.held_slot != c.sent_slot {
947            c.sent_slot = c.held_slot;
948            c.out.push(Outbound::HeldSlot(i16::from(c.sent_slot)));
949        }
950        // MinecraftClient.tick key processing (a GUI takes the mouse: buttons up).
951        {
952            // The screen as the inventory models it (vanilla: a window the client has no
953            // screen for opens none; a window the client closed is gone at once).
954            let gui = c.inv.gui_open();
955            let eye = if c.pose.sneaking {
956                c.opts.version.sneak_eye_height()
957            } else {
958                c.opts.version.eye_height()
959            };
960            let eyes = Vec3::new(c.pose.pos.x, c.pose.pos.y + eye, c.pose.pos.z);
961            let sim = &self.sim;
962            let blocks = |x: i32, y: i32, z: i32| sim.block_query(x, y, z);
963            let look = hands::look_vector(c.pose.yaw, c.pose.pitch);
964            let target = hands::pick(&blocks, eyes, look, hands::REACH);
965            let entity = entity_pick(sim, c.opts.version, eyes, look, target.map(|t| t.distance));
966            let in_water = blocks(
967                eyes.x.floor() as i32,
968                eyes.y.floor() as i32,
969                eyes.z.floor() as i32,
970            )
971            .is_some_and(|b| b.water);
972            let held = held_item(&c.inv, sim);
973            let mut placed = false;
974            let first = c.out.len();
975            let mut io = HandsIo {
976                blocks: &blocks,
977                target,
978                held,
979                held_stack: c.inv.held().cloned(),
980                food: c.health.food,
981                modern: c.opts.version != GameVersion::V1_8_9,
982                off_held: off_held_item(&c.inv, sim),
983                off_stack: c.inv.offhand().cloned(),
984                entity,
985                factors: DigFactors {
986                    on_ground: c.pose.on_ground,
987                    in_water,
988                    aqua_affinity: c.aqua_affinity,
989                    haste: c.effects.get(&3).map_or(0, |a| (*a as u8).wrapping_add(1)),
990                    fatigue: c.effects.get(&4).map_or(0, |a| (*a as u8).wrapping_add(1)),
991                },
992                sneaking: c.pose.sneaking,
993                out: &mut c.out,
994                broken: &mut c.broken_now,
995                placed: &mut placed,
996            };
997            let (left, right) = (c.buttons.left && !gui, c.buttons.right && !gui);
998            c.hands.tick(left, right, &mut io);
999            // Other placements: the server's set_slot brings the new count of the held stack.
1000            let _ = placed;
1001            predict_planting(c, sim, first);
1002        }
1003        for p in c.broken_now.drain(..) {
1004            self.sim.predict_air(p.x, p.y, p.z);
1005        }
1006        // ClientPlayerEntity.tickMovement: a flight toggle is told to the server before the move.
1007        if let Some(f) = c.want_flying.take() {
1008            let first = c.out.len();
1009            self.sim.set_flying(f, &mut c.out);
1010            if c.out.len() > first {
1011                c.pose.flying = f;
1012            } else if f && !c.pose.flying {
1013                log::debug!(
1014                    "[{}] t{} set_flying(true) refused: flight not allowed",
1015                    c.opts.name,
1016                    c.tick
1017                );
1018            }
1019        }
1020        // Rotation runs only on ticks the behaviour aimed (budgets untouched otherwise).
1021        c.turned = false;
1022        #[cfg(test)]
1023        if let Some((yaw, pitch)) = c.forced_look.take() {
1024            c.turned = yaw != c.pose.yaw || pitch != c.pose.pitch;
1025            c.pose.yaw = yaw;
1026            c.pose.pitch = pitch;
1027            c.aimed = false;
1028        }
1029        if c.aimed {
1030            let (yaw, pitch) = c.rotation.step(c.pose.yaw, c.pose.pitch);
1031            if c.opts.hold_turn_on_action && !post_look_ok(c, yaw, pitch) {
1032                // Grim RotationPlace re-checks a dig / place with the look of the movement
1033                // packet that follows it: keep the mouse still on the clicked block this tick.
1034                c.held_turns += 1;
1035            } else {
1036                c.turned = yaw != c.pose.yaw || pitch != c.pose.pitch;
1037                c.pose.yaw = yaw;
1038                c.pose.pitch = pitch;
1039            }
1040            c.aimed = false;
1041        }
1042        let first = c.out.len();
1043        let using = c.hands.using_item();
1044        self.sim
1045            .step(&mut c.pose, c.controls, using, c.entity_id, &mut c.out);
1046        for o in &c.out[first..] {
1047            let Outbound::Move(m) = o else { continue };
1048            match *m {
1049                MovePacket::Position { pos, .. } => c.sent.pos = pos,
1050                MovePacket::Look { yaw, pitch, .. } => {
1051                    c.sent.yaw = yaw;
1052                    c.sent.pitch = pitch;
1053                }
1054                MovePacket::PositionLook {
1055                    pos, yaw, pitch, ..
1056                } => {
1057                    c.sent.pos = pos;
1058                    c.sent.yaw = yaw;
1059                    c.sent.pitch = pitch;
1060                }
1061                MovePacket::Flying { .. } => {}
1062            }
1063            if matches!(
1064                m,
1065                MovePacket::Position { .. } | MovePacket::PositionLook { .. }
1066            ) {
1067                c.sent.motion = c.pose.motion;
1068            }
1069        }
1070        c.sent.on_ground = c.pose.on_ground;
1071        c.sent.sneaking = c.pose.sneaking;
1072        c.sent.sprinting = c.pose.sprinting;
1073        c.sent.flying = c.pose.flying;
1074        c.swings += count_swings(&c.out);
1075
1076        if self.state.started && !self.state.failed {
1077            let name = c.opts.name.clone();
1078            let view = TickView {
1079                core: &self.core,
1080                world: &self.sim,
1081            };
1082            if catch_unwind(AssertUnwindSafe(|| b.after_tick(&view))).is_err() {
1083                log::error!(
1084                    "[{name}] behaviour {} panicked in after_tick: stopped",
1085                    b.name()
1086                );
1087                self.stop(b, "panic");
1088            }
1089        }
1090    }
1091}
1092
1093/// Arm swings (main or off hand) in a packet list.
1094fn count_swings(out: &[Outbound]) -> u64 {
1095    out.iter()
1096        .filter(|o| matches!(o, Outbound::Swing | Outbound::SwingOffHand))
1097        .count() as u64
1098}
1099
1100/// A read-only view after the movement step ([`Behavior::after_tick`]).
1101pub struct TickView<'a> {
1102    core: &'a Core,
1103    world: &'a dyn WorldView,
1104}
1105
1106impl std::fmt::Debug for TickView<'_> {
1107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1108        f.debug_struct("TickView")
1109            .field("tick", &self.core.tick)
1110            .field("sent", &self.core.sent)
1111            .finish_non_exhaustive()
1112    }
1113}
1114
1115impl TickView<'_> {
1116    /// Ticks since the session started.
1117    #[must_use]
1118    pub fn tick(&self) -> u64 {
1119        self.core.tick
1120    }
1121
1122    /// The protocol version.
1123    #[must_use]
1124    pub fn version(&self) -> GameVersion {
1125        self.core.opts.version
1126    }
1127
1128    /// The client pose after this tick's movement step.
1129    #[must_use]
1130    pub fn pose(&self) -> &Pose {
1131        &self.core.pose
1132    }
1133
1134    /// What the server has after this tick's movement packet (position, look, flags).
1135    #[must_use]
1136    pub fn sent(&self) -> &Pose {
1137        &self.core.sent
1138    }
1139
1140    /// The world.
1141    #[must_use]
1142    pub fn world(&self) -> &dyn WorldView {
1143        self.world
1144    }
1145
1146    /// The tracked entities (with [`Interests::ENTITIES`]).
1147    #[must_use]
1148    pub fn entities(&self) -> Option<&ghast_entity::Entities> {
1149        self.world.entities()
1150    }
1151
1152    /// The inventory and the open window.
1153    #[must_use]
1154    pub fn inventory(&self) -> &ghast_inventory::Inventory {
1155        &self.core.inv
1156    }
1157
1158    /// What the behaviour saw this tick (the events of [`TickContext::events`]).
1159    #[must_use]
1160    pub fn events(&self) -> &TickEvents {
1161        &self.core.current
1162    }
1163
1164    /// Our entity id.
1165    #[must_use]
1166    pub fn entity_id(&self) -> i32 {
1167        self.core.entity_id
1168    }
1169
1170    /// Health, food and saturation.
1171    #[must_use]
1172    pub fn health(&self) -> Health {
1173        self.core.health
1174    }
1175
1176    /// Game mode.
1177    #[must_use]
1178    pub fn game_mode(&self) -> GameMode {
1179        self.core.game_mode
1180    }
1181
1182    /// The selected hotbar slot.
1183    #[must_use]
1184    pub fn held_slot(&self) -> u8 {
1185        self.core.held_slot
1186    }
1187
1188    /// The block being broken and its progress 0 … 1.
1189    #[must_use]
1190    pub fn mining(&self) -> Option<(ghast_protocol::BlockPos, f32)> {
1191        self.core.hands.mining()
1192    }
1193
1194    /// Arm swings sent so far (see [`TickContext::swings`]).
1195    #[must_use]
1196    pub fn swings(&self) -> u64 {
1197        self.core.swings
1198    }
1199
1200    /// Blocks broken so far.
1201    #[must_use]
1202    pub fn broken(&self) -> u64 {
1203        self.core.hands.broken
1204    }
1205}
1206
1207#[cfg(test)]
1208impl Core {
1209    /// Test hook: the held slot as both the client and the server have it (no sync packet).
1210    pub(crate) fn test_set_slot(&mut self, slot: u8) {
1211        self.held_slot = slot;
1212        self.sent_slot = slot;
1213        self.inv.select(slot);
1214    }
1215
1216    /// Test hook: the pose and the sent pose.
1217    pub(crate) fn test_poses(&mut self) -> (&mut Pose, &mut Pose) {
1218        (&mut self.pose, &mut self.sent)
1219    }
1220
1221    /// Test hook: turns held back by the post-look check.
1222    pub(crate) fn test_held_turns(&self) -> u64 {
1223        self.held_turns
1224    }
1225}
1226
1227#[cfg(test)]
1228impl TickContext<'_> {
1229    /// Test hook: this tick's look without a rotation step.
1230    pub(crate) fn test_force_look(&mut self, yaw: f32, pitch: f32) {
1231        self.core.forced_look = Some((yaw, pitch));
1232    }
1233
1234    /// Test hook: the rotation controller (to predict a step).
1235    pub(crate) fn test_rotation(&self) -> Rotation {
1236        self.core.rotation
1237    }
1238}
1239
1240/// 1.8 `SeedItem.use` / `CropItem.use` on the client: planting on the top of
1241/// its soil with air above uses one item at once (the server's set_slot follows later; it
1242/// sends none when the count already agrees). Applied to the `block_place` packets written
1243/// from `first` on. Other placements wait for the server's set_slot: predicting them needs
1244/// vanilla's full placement rules, and a wrong prediction would never be corrected.
1245fn predict_planting(c: &mut Core, world: &dyn WorldView, first: usize) {
1246    if c.opts.version != GameVersion::V1_8_9 || c.game_mode == GameMode::Creative {
1247        return;
1248    }
1249    let mut uses = 0u8;
1250    for o in &c.out[first..] {
1251        let Outbound::Place {
1252            pos,
1253            face: crate::common::Face::Up,
1254            held: Some(held),
1255            off_hand: false,
1256            ..
1257        } = o
1258        else {
1259            continue;
1260        };
1261        // Seed item → its soil (wheat, pumpkin, melon seeds, carrot, potato: farmland;
1262        // nether wart: soul sand).
1263        let soil = match held.id {
1264            295 | 361 | 362 | 391 | 392 => 60,
1265            372 => 88,
1266            _ => continue,
1267        };
1268        let id = |y: i32| world.raw_block(pos.x, y, pos.z).map(|s| s >> 4);
1269        if held.count > i32::from(uses) && id(pos.y) == Some(soil) && id(pos.y + 1) == Some(0) {
1270            uses += 1;
1271        }
1272    }
1273    if uses > 0 {
1274        c.inv.consume_held(uses);
1275    }
1276}
1277
1278/// Whether the look `yaw`/`pitch` still points at every block dug or placed this tick (its
1279/// full cube, from the sent and the current feet, standing or sneaking eyes, with the old or the
1280/// new yaw) — the post-look check that keeps Grim's RotationPlace check satisfied.
1281fn post_look_ok(c: &Core, yaw: f32, pitch: f32) -> bool {
1282    let looks = [
1283        hands::look_vector(c.pose.yaw, pitch),
1284        hands::look_vector(yaw, pitch),
1285    ];
1286    let heights = [
1287        c.opts.version.eye_height(),
1288        c.opts.version.sneak_eye_height(),
1289    ];
1290    c.out
1291        .iter()
1292        .filter_map(|o| match o {
1293            Outbound::Dig {
1294                status: crate::common::DigStatus::Start | crate::common::DigStatus::Finish,
1295                pos,
1296                ..
1297            }
1298            | Outbound::Place { pos, .. } => Some(*pos),
1299            _ => None,
1300        })
1301        .all(|t| {
1302            [c.sent.pos, c.pose.pos].iter().all(|o| {
1303                heights.iter().any(|h| {
1304                    let eyes = Vec3::new(o.x, o.y + *h, o.z);
1305                    looks
1306                        .iter()
1307                        .any(|l| hands::ray_hits_cube(eyes, *l, t, ACTION_LOOK_REACH))
1308                })
1309            })
1310        })
1311}
1312
1313/// Reach of the post-look check (6 blocks).
1314const ACTION_LOOK_REACH: f64 = 6.0;
1315
1316/// The behaviour's view of the client during one tick.
1317///
1318/// Reading is free; actions are written to the server in call order before this tick's
1319/// movement packet.
1320pub struct TickContext<'a> {
1321    core: &'a mut Core,
1322    world: &'a dyn WorldView,
1323    held: Option<hands::HeldItem>,
1324}
1325
1326impl std::fmt::Debug for TickContext<'_> {
1327    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1328        f.debug_struct("TickContext")
1329            .field("tick", &self.core.tick)
1330            .field("pose", &self.core.pose)
1331            .finish_non_exhaustive()
1332    }
1333}
1334
1335impl TickContext<'_> {
1336    /// Ticks since the connection entered the play state.
1337    #[must_use]
1338    pub fn tick(&self) -> u64 {
1339        self.core.tick
1340    }
1341
1342    /// The protocol version of this connection.
1343    #[must_use]
1344    pub fn version(&self) -> GameVersion {
1345        self.core.opts.version
1346    }
1347
1348    /// The client pose now (start of the tick, before this tick's rotation and movement).
1349    #[must_use]
1350    pub fn pose(&self) -> &Pose {
1351        &self.core.pose
1352    }
1353
1354    /// The last pose written to the server: what it judges this tick's actions against.
1355    #[must_use]
1356    pub fn sent(&self) -> &Pose {
1357        &self.core.sent
1358    }
1359
1360    /// Eye position of `pose` (standing or sneaking eye height of this version).
1361    #[must_use]
1362    pub fn eyes(&self, pose: &Pose) -> Vec3 {
1363        let v = self.core.opts.version;
1364        pose.eyes(if pose.sneaking {
1365            v.sneak_eye_height()
1366        } else {
1367            v.eye_height()
1368        })
1369    }
1370
1371    /// The world (loaded chunk columns and their blocks).
1372    #[must_use]
1373    pub fn world(&self) -> &dyn WorldView {
1374        self.world
1375    }
1376
1377    /// What happened since the previous tick.
1378    #[must_use]
1379    pub fn events(&self) -> &TickEvents {
1380        &self.core.current
1381    }
1382
1383    /// Our entity id.
1384    #[must_use]
1385    pub fn entity_id(&self) -> i32 {
1386        self.core.entity_id
1387    }
1388
1389    /// Arm swings (main and off hand) written so far in the session: every `arm_animation`,
1390    /// whether from [`TickContext::swing`], an action (place, dig, attack) or the held buttons
1391    /// (digging swings every tick). Includes this tick's written so far.
1392    #[must_use]
1393    pub fn swings(&self) -> u64 {
1394        self.core.swings + count_swings(&self.core.out)
1395    }
1396
1397    /// Health, food and saturation.
1398    #[must_use]
1399    pub fn health(&self) -> Health {
1400        self.core.health
1401    }
1402
1403    /// The player is dead (waiting for [`TickContext::respawn`]).
1404    #[must_use]
1405    pub fn is_dead(&self) -> bool {
1406        self.core.dead
1407    }
1408
1409    /// Game mode.
1410    #[must_use]
1411    pub fn game_mode(&self) -> GameMode {
1412        self.core.game_mode
1413    }
1414
1415    /// Abilities as the server sent them.
1416    #[must_use]
1417    pub fn abilities(&self) -> Abilities {
1418        self.core.abilities
1419    }
1420
1421    /// The server allows flight.
1422    #[must_use]
1423    pub fn can_fly(&self) -> bool {
1424        self.core.abilities.may_fly()
1425    }
1426
1427    /// The selected hotbar slot (0 … 8).
1428    #[must_use]
1429    pub fn held_slot(&self) -> u8 {
1430        self.core.held_slot
1431    }
1432
1433    /// Id of the open container window, if the server opened one.
1434    #[must_use]
1435    pub fn open_window(&self) -> Option<i32> {
1436        (self.core.open_window != 0).then_some(self.core.open_window)
1437    }
1438
1439    /// The current dimension (`"0"` / `"-1"` / `"1"` on 1.8, the world name on 1.16).
1440    #[must_use]
1441    pub fn dimension(&self) -> &str {
1442        &self.core.dimension
1443    }
1444
1445    /// The movement controls recorded for this tick.
1446    #[must_use]
1447    pub fn controls(&self) -> Controls {
1448        self.core.controls
1449    }
1450
1451    /// The rotation target.
1452    #[must_use]
1453    pub fn aim(&self) -> (f32, f32) {
1454        self.core.rotation.target()
1455    }
1456
1457    // ---- actions: written now, before this tick's movement packet ----
1458
1459    /// Sends a chat line or command (truncated to the version's limit, 100 / 256 UTF-16
1460    /// units, like vanilla).
1461    pub fn chat(&mut self, text: impl Into<String>) {
1462        self.core.out.push(Outbound::Chat(text.into()));
1463    }
1464
1465    /// Asks the server to respawn the player. Only sent while dead; returns whether it was.
1466    pub fn respawn(&mut self) -> bool {
1467        if self.core.dead {
1468            self.core.out.push(Outbound::Respawn);
1469            true
1470        } else {
1471            false
1472        }
1473    }
1474
1475    /// Selects a hotbar slot (0 … 8); sends `held_item_slot` when it changes. Out-of-range
1476    /// slots are ignored.
1477    pub fn select_slot(&mut self, slot: u8) {
1478        if slot < 9 {
1479            if self.core.held_slot != slot {
1480                self.core.hands.stop_using();
1481                self.core.hands.ticks_since_swing = 0;
1482            }
1483            self.core.held_slot = slot;
1484            self.core.inv.select(slot);
1485            if self.core.sent_slot != slot {
1486                self.core.sent_slot = slot;
1487                self.core.out.push(Outbound::HeldSlot(i16::from(slot)));
1488            }
1489        }
1490    }
1491
1492    /// Swings the arm (`arm_animation`).
1493    pub fn swing(&mut self) {
1494        self.core.out.push(Outbound::Swing);
1495    }
1496
1497    /// Ends the session after this tick's packets are written.
1498    pub fn disconnect(&mut self, reason: impl Into<String>) {
1499        self.core.quit = Some(reason.into());
1500    }
1501
1502    // ---- intent for this tick's movement step ----
1503
1504    /// Rotation target for this tick: the client turns toward it in whole mouse counts
1505    /// (at most 14° yaw / 10.5° pitch per tick). Call it every tick you want to turn.
1506    pub fn set_aim(&mut self, yaw: f32, pitch: f32) {
1507        self.core.rotation.set_target(yaw, pitch);
1508        self.core.aimed = true;
1509    }
1510
1511    /// Movement keys from now on (kept until changed); the physics turns them into movement
1512    /// like vanilla's `Input` (sneaking scales walking by 0.3).
1513    pub fn set_controls(&mut self, controls: Controls) {
1514        self.core.controls = controls;
1515    }
1516
1517    /// The inventory: player slots, cursor, the open window, the pending click.
1518    #[must_use]
1519    pub fn inventory(&self) -> &ghast_inventory::Inventory {
1520        &self.core.inv
1521    }
1522
1523    /// Clicks in the open screen (`window_click`, written now). Refused (nothing sent) while
1524    /// the player is turning (the look changed in the last movement step or an aim is set for
1525    /// this tick: clicks only while still) or when the inventory refuses it (one click in
1526    /// flight, same-tick packet order, unpredictable result, …). The result arrives in
1527    /// [`TickEvents::inventory`] on a later tick.
1528    pub fn click(&mut self, click: ghast_inventory::Click) -> Result<ClickTicket, ClickRefused> {
1529        if self.core.turned || self.core.aimed {
1530            return Err(ClickRefused::Turning);
1531        }
1532        let o = self
1533            .core
1534            .inv
1535            .click(click)
1536            .map_err(ClickRefused::Inventory)?;
1537        let ticket = match &o {
1538            ghast_inventory::Outgoing::Click(p) => ClickTicket {
1539                window_id: i32::from(p.window_id),
1540                action: p.action,
1541            },
1542            _ => ClickTicket {
1543                window_id: -1,
1544                action: -1,
1545            },
1546        };
1547        self.core.out.push(Outbound::Inventory(o));
1548        Ok(ticket)
1549    }
1550
1551    /// Opens the player inventory GUI (1.8: the "open inventory" client command; 1.16: nothing
1552    /// is sent). Window 0 clicks need it open.
1553    pub fn open_inventory(&mut self) -> Result<(), ClickRefused> {
1554        if let Some(o) = self
1555            .core
1556            .inv
1557            .open_inventory()
1558            .map_err(ClickRefused::Inventory)?
1559        {
1560            self.core.out.push(Outbound::Inventory(o));
1561        }
1562        Ok(())
1563    }
1564
1565    /// Closes the open GUI (`close_window` with its id; 0 for the player inventory).
1566    pub fn close_window(&mut self) -> Result<(), ClickRefused> {
1567        let o = self.core.inv.close().map_err(ClickRefused::Inventory)?;
1568        self.core.out.push(Outbound::Inventory(o));
1569        // Vanilla closeHandledScreen: the container ends on the client too.
1570        self.core.open_window = 0;
1571        Ok(())
1572    }
1573
1574    /// Mouse buttons from now on (kept until changed; released by a join, a respawn or an
1575    /// open window). Left held mines what the crosshair is on, right held uses / places
1576    /// (vanilla's key processing, after the behaviour and before the movement packet).
1577    pub fn set_buttons(&mut self, buttons: Buttons) {
1578        self.core.buttons = buttons;
1579    }
1580
1581    /// The buttons held.
1582    #[must_use]
1583    pub fn buttons(&self) -> Buttons {
1584        self.core.buttons
1585    }
1586
1587    /// What the crosshair is on as the server judges it: the first block within reach 4.5
1588    /// along the **sent** look from the sent eyes.
1589    #[must_use]
1590    pub fn target(&self) -> Option<BlockHit> {
1591        let s = &self.core.sent;
1592        let v = self.core.opts.version;
1593        let eye = if s.sneaking {
1594            v.sneak_eye_height()
1595        } else {
1596            v.eye_height()
1597        };
1598        let world = self.world;
1599        let blocks = |x: i32, y: i32, z: i32| world.block_query(x, y, z);
1600        hands::pick(
1601            &blocks,
1602            Vec3::new(s.pos.x, s.pos.y + eye, s.pos.z),
1603            hands::look_vector(s.yaw, s.pitch),
1604            hands::REACH,
1605        )
1606    }
1607
1608    /// The tracked entities (behaviours that ask for [`Interests::ENTITIES`]).
1609    #[must_use]
1610    pub fn entities(&self) -> Option<&ghast_entity::Entities> {
1611        self.world.entities()
1612    }
1613
1614    /// The entity the crosshair is on as the server judges it (from the sent pose; vanilla's
1615    /// entity pick: reach 3, 1.8 boxes grown by 0.1, closer than the block hit).
1616    #[must_use]
1617    pub fn target_entity(&self) -> Option<hands::EntityHit> {
1618        let s = &self.core.sent;
1619        let v = self.core.opts.version;
1620        let eye = if s.sneaking {
1621            v.sneak_eye_height()
1622        } else {
1623            v.eye_height()
1624        };
1625        let eyes = Vec3::new(s.pos.x, s.pos.y + eye, s.pos.z);
1626        let look = hands::look_vector(s.yaw, s.pitch);
1627        let block = self.target().map(|t| t.distance);
1628        entity_pick(self.world, v, eyes, look, block)
1629    }
1630
1631    /// Attacks `entity` (`use_entity` attack and the arm swing in the version's order; 1.16
1632    /// resets the attack cooldown). Refused (`false`, nothing sent) unless the sent crosshair is
1633    /// on that entity.
1634    pub fn attack(&mut self, entity: i32) -> bool {
1635        if self.target_entity().is_none_or(|e| e.id != entity) {
1636            return false;
1637        }
1638        let world = self.world;
1639        let blocks = |x: i32, y: i32, z: i32| world.block_query(x, y, z);
1640        let c = &mut *self.core;
1641        let modern = c.opts.version != GameVersion::V1_8_9;
1642        let mut placed = false;
1643        let mut io = HandsIo {
1644            blocks: &blocks,
1645            target: None,
1646            held: self.held,
1647            held_stack: None,
1648            food: c.health.food,
1649            modern,
1650            off_held: None,
1651            off_stack: None,
1652            entity: None,
1653            factors: DigFactors::default(),
1654            sneaking: c.pose.sneaking,
1655            out: &mut c.out,
1656            broken: &mut c.broken_now,
1657            placed: &mut placed,
1658        };
1659        if !modern {
1660            io.out.push(Outbound::Swing);
1661        }
1662        c.hands.attack(entity, &mut io);
1663        if modern {
1664            c.out.push(Outbound::Swing);
1665        }
1666        true
1667    }
1668
1669    /// 1.16 attack strength 0 … 1 for an attack speed (4.0 for an empty hand, swords 1.6,
1670    /// axes 0.8–1.0); 1.0 on 1.8 (no cooldown).
1671    #[must_use]
1672    pub fn attack_strength(&self, attack_speed: f64) -> f32 {
1673        if self.core.opts.version == GameVersion::V1_8_9 {
1674            1.0
1675        } else {
1676            self.core.hands.attack_strength(attack_speed)
1677        }
1678    }
1679
1680    /// The block being broken with the left button and its progress 0 … 1.
1681    #[must_use]
1682    pub fn mining(&self) -> Option<(ghast_protocol::BlockPos, f32)> {
1683        self.core.hands.mining()
1684    }
1685
1686    /// Blocks broken so far (finish sent).
1687    #[must_use]
1688    pub fn broken(&self) -> u64 {
1689        self.core.hands.broken
1690    }
1691
1692    /// Using the held item (eating, blocking, drawing a bow): movement input is slowed.
1693    #[must_use]
1694    pub fn using_item(&self) -> bool {
1695        self.core.hands.using_item()
1696    }
1697
1698    /// Starts digging `pos` on `face` (`block_dig` start, then an arm swing), breaking it at
1699    /// once when it is instant for the held item. Refused (nothing sent, `false`) unless the
1700    /// crosshair from the sent pose is on exactly that block face.
1701    pub fn dig(&mut self, pos: ghast_protocol::BlockPos, face: crate::common::Face) -> bool {
1702        if !self
1703            .target()
1704            .is_some_and(|t| t.pos == pos && t.face == face)
1705        {
1706            return false;
1707        }
1708        let world = self.world;
1709        let blocks = |x: i32, y: i32, z: i32| world.block_query(x, y, z);
1710        let c = &mut *self.core;
1711        let mut placed = false;
1712        let mut io = HandsIo {
1713            blocks: &blocks,
1714            target: None,
1715            held: self.held,
1716            held_stack: c.inv.held().cloned(),
1717            food: c.health.food,
1718            modern: c.opts.version != GameVersion::V1_8_9,
1719            off_held: None,
1720            off_stack: None,
1721            entity: None,
1722            factors: DigFactors {
1723                on_ground: c.pose.on_ground,
1724                in_water: false,
1725                aqua_affinity: c.aqua_affinity,
1726                haste: c.effects.get(&3).map_or(0, |a| (*a as u8).wrapping_add(1)),
1727                fatigue: c.effects.get(&4).map_or(0, |a| (*a as u8).wrapping_add(1)),
1728            },
1729            sneaking: c.pose.sneaking,
1730            out: &mut c.out,
1731            broken: &mut c.broken_now,
1732            placed: &mut placed,
1733        };
1734        c.hands.click_block(pos, face, &mut io);
1735        c.out.push(Outbound::Swing);
1736        true
1737    }
1738
1739    /// Right-clicks `support`'s `face` at the absolute point `hit` (`block_place`, then an arm
1740    /// swing): places the held block or activates the block. Refused (nothing sent, `false`)
1741    /// unless the crosshair from the sent pose is on exactly that block face.
1742    pub fn place(
1743        &mut self,
1744        support: ghast_protocol::BlockPos,
1745        face: crate::common::Face,
1746        hit: Vec3,
1747    ) -> bool {
1748        let Some(t) = self.target().filter(|t| t.pos == support && t.face == face) else {
1749            return false;
1750        };
1751        let world = self.world;
1752        let Some(b) = world.block_query(support.x, support.y, support.z) else {
1753            return false;
1754        };
1755        let blocks = |x: i32, y: i32, z: i32| world.block_query(x, y, z);
1756        let c = &mut *self.core;
1757        let mut placed = false;
1758        let first = c.out.len();
1759        let mut io = HandsIo {
1760            blocks: &blocks,
1761            target: None,
1762            held: self.held,
1763            held_stack: c.inv.held().cloned(),
1764            food: c.health.food,
1765            modern: c.opts.version != GameVersion::V1_8_9,
1766            off_held: None,
1767            off_stack: None,
1768            entity: None,
1769            factors: DigFactors::default(),
1770            sneaking: c.pose.sneaking,
1771            out: &mut c.out,
1772            broken: &mut c.broken_now,
1773            placed: &mut placed,
1774        };
1775        c.hands
1776            .right_click_block(BlockHit { hit, ..t }, &b, &mut io);
1777        let _ = placed;
1778        predict_planting(c, world, first);
1779        c.out.push(Outbound::Swing);
1780        true
1781    }
1782
1783    /// When the last inbound bytes arrived (a gap before a setback means lag).
1784    #[must_use]
1785    pub fn last_inbound(&self) -> Option<Instant> {
1786        self.core.last_inbound
1787    }
1788
1789    /// The yaw / pitch this tick's movement step will use, from this tick's
1790    /// [`TickContext::set_aim`] and the digs / places written so far (incl. the turn held by
1791    /// [`crate::Client::hold_turn_on_action`]). Computed on a copy: the rotation's carried
1792    /// budget does not change. Clicks of held buttons, which run after the behaviour, are not
1793    /// seen. Without an aim this tick: the current look.
1794    #[must_use]
1795    pub fn planned_look(&self) -> (f32, f32) {
1796        let c = &*self.core;
1797        let now = (c.pose.yaw, c.pose.pitch);
1798        #[cfg(test)]
1799        if let Some(l) = c.forced_look {
1800            return l;
1801        }
1802        if !c.aimed {
1803            return now;
1804        }
1805        let mut r = c.rotation;
1806        let (yaw, pitch) = r.step(c.pose.yaw, c.pose.pitch);
1807        if c.opts.hold_turn_on_action && !post_look_ok(c, yaw, pitch) {
1808            now
1809        } else {
1810            (yaw, pitch)
1811        }
1812    }
1813
1814    /// Starts or stops creative-style flight: the `abilities` packet goes out before this
1815    /// tick's movement step, like a double-tapped jump (but without the jump). Starting needs
1816    /// the server's "may fly" ability; asking for the current state sends nothing.
1817    pub fn set_flying(&mut self, flying: bool) {
1818        self.core.want_flying = Some(flying);
1819    }
1820
1821    /// Turns held back so far because they would have taken the crosshair off a block dug or
1822    /// placed on the same tick (see [`crate::Client::hold_turn_on_action`]).
1823    #[must_use]
1824    pub fn held_turns(&self) -> u64 {
1825        self.core.held_turns
1826    }
1827
1828    /// Behaviour status line (logged when it changes).
1829    pub fn status(&mut self, text: &str) {
1830        if self.core.status != text {
1831            text.clone_into(&mut self.core.status);
1832            log::info!(
1833                "[{}] t{} status: {text}",
1834                self.core.opts.name,
1835                self.core.tick
1836            );
1837        }
1838    }
1839}
1840
1841#[cfg(test)]
1842mod tests {
1843    use super::*;
1844    use crate::common::Teleport;
1845    use crate::common::{ChatPosition, EntityAction, JoinGame};
1846    use ghast_protocol::RawPacket;
1847
1848    fn opts(version: GameVersion) -> CoreOptions {
1849        CoreOptions {
1850            name: "test".into(),
1851            version,
1852            settings: ClientSettings::default(),
1853            brand: Some("vanilla".into()),
1854            sensitivity: 0.5,
1855            silent_hold: Duration::from_millis(1500),
1856            hold_turn_on_action: true,
1857            inventory: match version {
1858                GameVersion::V1_8_9 => ghast_inventory::Version::V1_8,
1859                _ => ghast_inventory::Version::V1_16_5,
1860            },
1861        }
1862    }
1863
1864    /// A 1.8.9 engine (physics) or a 1.16.5 one (stand-still mover).
1865    fn engine(version: GameVersion) -> Engine<Box<dyn Sim>> {
1866        let sim: Box<dyn Sim> = match version {
1867            GameVersion::V1_8_9 => Box::new(crate::sim::Sim18::new()),
1868            _ => Box::new(crate::sim::Sim16::new()),
1869        };
1870        Engine::new(opts(version), sim)
1871    }
1872
1873    /// Loads the column of chunk (x, z) the way the connection does.
1874    fn load(e: &mut Engine<Box<dyn Sim>>, x: i32, z: i32) {
1875        let raw = match e.core.opts.version {
1876            GameVersion::V1_8_9 => crate::sim::test_chunks::v1_8(x, z),
1877            _ => crate::sim::test_chunks::v1_16_5(x, z),
1878        };
1879        e.sim.world_packet(&raw).unwrap();
1880    }
1881
1882    fn join(e: &mut Engine<Box<dyn Sim>>) {
1883        e.handle(Inbound::JoinGame(JoinGame {
1884            entity_id: 7,
1885            game_mode: GameMode::Survival,
1886            dimension: "0".into(),
1887            hardcore: false,
1888            dimension_nbt: None,
1889            respawn_screen: true,
1890        }));
1891        load(e, 0, 0);
1892        e.handle(Inbound::Teleport(Teleport {
1893            x: 8.5,
1894            y: 64.0,
1895            z: 8.5,
1896            yaw: 90.0,
1897            pitch: 0.0,
1898            flags: 0,
1899            teleport_id: None,
1900        }));
1901        e.outbound().clear();
1902    }
1903
1904    /// Records the tick's packets as names.
1905    fn names(out: &mut Vec<Outbound>) -> Vec<&'static str> {
1906        let v = out
1907            .iter()
1908            .map(|o| match o {
1909                Outbound::Move(m) => m.trace_id(),
1910                Outbound::Chat(_) => "chat",
1911                Outbound::HeldSlot(_) => "held_slot",
1912                Outbound::Swing => "swing",
1913                Outbound::EntityAction { .. } => "entity_action",
1914                Outbound::KeepAlive(_) => "keep_alive",
1915                Outbound::Transaction { .. } => "transaction",
1916                Outbound::TeleportConfirm(_) => "teleport_confirm",
1917                Outbound::Respawn => "respawn",
1918                Outbound::Settings(_) => "settings",
1919                Outbound::Brand(_) => "brand",
1920                Outbound::Abilities { .. } => "abilities",
1921                Outbound::Dig { .. } => "block_dig",
1922                Outbound::Place { .. } | Outbound::UseItem { .. } => "block_place",
1923                Outbound::SwingOffHand => "swing_off",
1924                Outbound::UseEntity { .. } => "use_entity",
1925                Outbound::Inventory(_) => "inventory",
1926            })
1927            .collect();
1928        out.clear();
1929        v
1930    }
1931
1932    struct Script<F: FnMut(&mut TickContext<'_>)>(F);
1933    impl<F: FnMut(&mut TickContext<'_>)> Behavior for Script<F> {
1934        fn tick(&mut self, ctx: &mut TickContext<'_>) {
1935            (self.0)(ctx);
1936        }
1937    }
1938
1939    #[test]
1940    fn join_sends_settings_then_brand() {
1941        let mut e = engine(GameVersion::V1_8_9);
1942        e.handle(Inbound::JoinGame(JoinGame {
1943            entity_id: 1,
1944            game_mode: GameMode::Creative,
1945            dimension: "0".into(),
1946            hardcore: false,
1947            dimension_nbt: None,
1948            respawn_screen: true,
1949        }));
1950        assert_eq!(names(e.outbound()), ["settings", "brand"]);
1951    }
1952
1953    #[test]
1954    fn nothing_before_the_first_teleport() {
1955        let mut e = engine(GameVersion::V1_8_9);
1956        let mut b = Idle;
1957        let t0 = Instant::now();
1958        load(&mut e, 0, 0);
1959        e.outbound().clear();
1960        for i in 0..5 {
1961            e.tick(&mut b, t0 + Duration::from_millis(50 * i));
1962        }
1963        assert!(e.outbound().is_empty());
1964    }
1965
1966    #[test]
1967    fn teleport_reply_per_version() {
1968        let mut e = engine(GameVersion::V1_16_5);
1969        e.handle(Inbound::Teleport(Teleport {
1970            x: 1.0,
1971            y: 2.0,
1972            z: 3.0,
1973            yaw: 450.0,
1974            pitch: 100.0,
1975            flags: 0,
1976            teleport_id: Some(5),
1977        }));
1978        let out = std::mem::take(e.outbound());
1979        assert_eq!(out[0], Outbound::TeleportConfirm(5));
1980        match out[1] {
1981            Outbound::Move(MovePacket::PositionLook {
1982                yaw,
1983                pitch,
1984                on_ground,
1985                ..
1986            }) => {
1987                assert_eq!((yaw, pitch, on_ground), (90.0, 90.0, false));
1988            }
1989            ref o => panic!("unexpected {o:?}"),
1990        }
1991        let mut e = engine(GameVersion::V1_8_9);
1992        e.handle(Inbound::Teleport(Teleport {
1993            x: 1.0,
1994            y: 2.0,
1995            z: 3.0,
1996            yaw: 450.0,
1997            pitch: 100.0,
1998            flags: 0,
1999            teleport_id: None,
2000        }));
2001        let out = std::mem::take(e.outbound());
2002        assert_eq!(out.len(), 1);
2003        // 1.8 does not clamp the pitch.
2004        assert!(matches!(
2005            out[0],
2006            Outbound::Move(MovePacket::PositionLook {
2007                yaw: 90.0,
2008                pitch: 100.0,
2009                ..
2010            })
2011        ));
2012    }
2013
2014    #[test]
2015    fn actions_precede_the_single_movement_packet() {
2016        let mut e = engine(GameVersion::V1_8_9);
2017        join(&mut e);
2018        let t0 = Instant::now();
2019        e.note_inbound(t0);
2020        let mut b = Script(|ctx: &mut TickContext<'_>| {
2021            if ctx.tick() == 2 {
2022                ctx.select_slot(3);
2023                ctx.chat("hello");
2024                ctx.swing();
2025                ctx.set_controls(Controls {
2026                    sneak: true,
2027                    ..Controls::default()
2028                });
2029            }
2030        });
2031        e.tick(&mut b, t0);
2032        assert_eq!(names(e.outbound()), ["C06"]);
2033        e.tick(&mut b, t0);
2034        assert_eq!(
2035            names(e.outbound()),
2036            ["held_slot", "chat", "swing", "entity_action", "C03"]
2037        );
2038        for _ in 0..100 {
2039            e.tick(&mut b, t0);
2040            let n = names(e.outbound());
2041            assert_eq!(n.len(), 1, "one movement packet per tick: {n:?}");
2042        }
2043    }
2044
2045    #[test]
2046    fn standing_bot_packets() {
2047        let mut e = engine(GameVersion::V1_8_9);
2048        join(&mut e);
2049        let mut b = Idle;
2050        let t0 = Instant::now();
2051        e.note_inbound(t0);
2052        // First tick: the position differs from the fresh player's lastReported (origin).
2053        e.tick(&mut b, t0);
2054        let out = std::mem::take(e.outbound());
2055        assert!(matches!(
2056            out[..],
2057            [Outbound::Move(MovePacket::PositionLook {
2058                on_ground: false,
2059                ..
2060            })]
2061        ));
2062        // Then flying (on ground) with a position refresh every 21st tick.
2063        let mut kinds = Vec::new();
2064        for _ in 0..42 {
2065            e.tick(&mut b, t0);
2066            kinds.extend(names(e.outbound()));
2067        }
2068        assert_eq!(kinds.iter().filter(|k| **k == "C04").count(), 2);
2069        assert_eq!(kinds.iter().filter(|k| **k == "C03").count(), 40);
2070        assert_eq!((kinds[20], kinds[41]), ("C04", "C04"));
2071        assert!(e.core.sent.on_ground);
2072    }
2073
2074    #[test]
2075    fn silent_server_freezes_behaviour() {
2076        let mut e = engine(GameVersion::V1_8_9);
2077        join(&mut e);
2078        let t0 = Instant::now();
2079        e.note_inbound(t0);
2080        let mut ticks = 0;
2081        let mut b = Script(|ctx: &mut TickContext<'_>| {
2082            ticks += 1;
2083            ctx.set_controls(Controls {
2084                sneak: true,
2085                ..Controls::default()
2086            });
2087        });
2088        e.tick(&mut b, t0 + Duration::from_millis(100));
2089        e.tick(&mut b, t0 + Duration::from_millis(1600));
2090        // Frozen: keys released, so the sneak stops; still one movement packet.
2091        let n = names(e.outbound());
2092        assert_eq!(n, ["entity_action", "C06", "entity_action", "C03"]);
2093        e.note_inbound(t0 + Duration::from_millis(1700));
2094        e.tick(&mut b, t0 + Duration::from_millis(1700));
2095        let _ = b;
2096        assert_eq!(ticks, 2);
2097    }
2098
2099    #[test]
2100    fn keepalive_answered_transactions_left_to_the_inventory() {
2101        let mut e = engine(GameVersion::V1_8_9);
2102        join(&mut e);
2103        e.handle(Inbound::KeepAlive(42));
2104        // The engine never answers confirms: the inventory does (exactly once), see the
2105        // fake-server test.
2106        e.handle(Inbound::Transaction {
2107            window_id: 0,
2108            action: -5,
2109            accepted: false,
2110        });
2111        assert_eq!(std::mem::take(e.outbound()), [Outbound::KeepAlive(42)]);
2112    }
2113
2114    #[test]
2115    fn panicking_behaviour_does_not_stop_the_tick() {
2116        let mut e = engine(GameVersion::V1_8_9);
2117        join(&mut e);
2118        let t0 = Instant::now();
2119        let mut b = Script(|ctx: &mut TickContext<'_>| {
2120            if ctx.tick() == 2 {
2121                panic!("boom");
2122            }
2123        });
2124        let hook = std::panic::take_hook();
2125        std::panic::set_hook(Box::new(|_| {}));
2126        for _ in 0..5 {
2127            e.tick(&mut b, t0);
2128        }
2129        std::panic::set_hook(hook);
2130        assert!(e.state.failed);
2131        assert_eq!(names(e.outbound()).len(), 5);
2132    }
2133
2134    #[test]
2135    fn chat_events_reach_the_behaviour_once() {
2136        let mut e = engine(GameVersion::V1_16_5);
2137        join(&mut e);
2138        e.handle(Inbound::Chat(ChatMessage {
2139            json: r#"{"text":"hi"}"#.into(),
2140            position: ChatPosition::System,
2141            sender: None,
2142        }));
2143        let mut seen = Vec::new();
2144        let mut b = Script(|ctx: &mut TickContext<'_>| {
2145            seen.extend(ctx.events().chat.iter().map(ChatMessage::plain));
2146        });
2147        let t0 = Instant::now();
2148        e.tick(&mut b, t0);
2149        e.tick(&mut b, t0);
2150        let _ = b;
2151        assert_eq!(seen, ["hi"]);
2152    }
2153
2154    #[test]
2155    fn immediate_respawn_1_16() {
2156        let mut e = engine(GameVersion::V1_16_5);
2157        join(&mut e);
2158        e.handle(Inbound::GameState {
2159            reason: 11,
2160            value: 1.0,
2161        });
2162        e.handle(Inbound::Died { entity_id: 7 });
2163        assert_eq!(e.outbound().as_slice(), [Outbound::Respawn]);
2164    }
2165
2166    /// Left button on the floor: START, swings, FINISH, all before the tick's movement
2167    /// packet; the broken block is predicted air and the player falls into the hole.
2168    #[test]
2169    fn dig_with_the_left_button() {
2170        let mut e = engine(GameVersion::V1_8_9);
2171        join(&mut e);
2172        let t0 = Instant::now();
2173        e.note_inbound(t0);
2174        let mut b = Script(|ctx: &mut TickContext<'_>| {
2175            ctx.set_aim(90.0, 89.5);
2176            let down = ctx.target().is_some_and(|t| {
2177                t.pos == ghast_protocol::BlockPos::new(8, 63, 8) && t.face == crate::Face::Up
2178            });
2179            ctx.set_buttons(Buttons {
2180                left: down,
2181                right: false,
2182            });
2183        });
2184        let mut ticks = Vec::new();
2185        for _ in 0..200 {
2186            e.tick(&mut b, t0);
2187            let n = names(e.outbound());
2188            assert_eq!(n.iter().filter(|x| x.starts_with('C')).count(), 1, "{n:?}");
2189            assert!(n.last().unwrap().starts_with('C'), "movement last: {n:?}");
2190            ticks.push(n);
2191        }
2192        let flat: Vec<&str> = ticks.iter().flatten().copied().collect();
2193        assert_eq!(
2194            flat.iter().filter(|x| **x == "block_dig").count(),
2195            2,
2196            "start + finish"
2197        );
2198        assert!(e.core.hands.broken == 1);
2199        // Stone by hand: not harvestable, 1 / 1.5 / 100 per tick -> 151 damage ticks.
2200        let start = ticks.iter().position(|t| t.contains(&"block_dig")).unwrap();
2201        let finish = ticks
2202            .iter()
2203            .rposition(|t| t.contains(&"block_dig"))
2204            .unwrap();
2205        assert_eq!(finish - start, 150);
2206        assert!(e.sim.block_query(8, 63, 8).unwrap().air);
2207        assert!(
2208            e.core.pose.pos.y < 64.0,
2209            "falls into the hole: {:?}",
2210            e.core.pose.pos
2211        );
2212    }
2213
2214    /// `place` is refused unless the sent look is on the face; then C08 + C0A before the
2215    /// movement packet.
2216    #[test]
2217    fn place_needs_the_sent_look() {
2218        let mut e = engine(GameVersion::V1_8_9);
2219        join(&mut e);
2220        let t0 = Instant::now();
2221        e.note_inbound(t0);
2222        let floor = ghast_protocol::BlockPos::new(8, 63, 8);
2223        let mut results = Vec::new();
2224        let mut b = Script(|ctx: &mut TickContext<'_>| {
2225            ctx.set_aim(90.0, 89.5);
2226            results.push(ctx.place(floor, crate::Face::Up, Vec3::new(8.5, 64.0, 8.5)));
2227        });
2228        // A stack of stone in the selected hotbar slot (window 0 slot 36).
2229        e.core
2230            .inv
2231            .on_set_slot(0, 36, Some(ghast_inventory::Stack::new(1, 64, 0)));
2232        let mut placed_tick = None;
2233        for i in 0..20 {
2234            e.tick(&mut b, t0);
2235            let n = names(e.outbound());
2236            if n.contains(&"block_place") {
2237                assert_eq!(n[..2], ["block_place", "swing"], "{n:?}");
2238                placed_tick.get_or_insert(i);
2239            }
2240        }
2241        let _ = b;
2242        let first = results.iter().position(|r| *r).unwrap();
2243        assert!(first > 3, "refused while still turning");
2244        assert_eq!(placed_tick, Some(first));
2245        assert_eq!(e.core.inv.held().map(|s| s.id), Some(1));
2246    }
2247
2248    #[test]
2249    fn sneak_action_order() {
2250        let mut e = engine(GameVersion::V1_8_9);
2251        join(&mut e);
2252        let mut b = Script(|ctx: &mut TickContext<'_>| {
2253            ctx.set_controls(Controls {
2254                sneak: true,
2255                ..Controls::default()
2256            });
2257        });
2258        e.tick(&mut b, Instant::now());
2259        let out = std::mem::take(e.outbound());
2260        assert_eq!(
2261            out[0],
2262            Outbound::EntityAction {
2263                entity_id: 7,
2264                action: EntityAction::StartSneaking
2265            }
2266        );
2267        assert!(matches!(out[1], Outbound::Move(_)));
2268    }
2269
2270    /// Block changes reach the behaviour once, in arrival order, only when asked for.
2271    #[test]
2272    fn block_updates_per_version() {
2273        use ghast_protocol::BlockPos;
2274        for v in [GameVersion::V1_8_9, GameVersion::V1_16_5] {
2275            let mut e = engine(v);
2276            join(&mut e);
2277            e.sim.collect_block_updates(true);
2278            let t0 = Instant::now();
2279            e.note_inbound(t0);
2280            let raws = match v {
2281                GameVersion::V1_8_9 => {
2282                    use ghast_protocol::v1_8::play::clientbound as cb;
2283                    vec![
2284                        RawPacket::from_packet(&cb::BlockChange {
2285                            location: BlockPos::new(3, 63, 4),
2286                            r#type: 59 << 4 | 7,
2287                        })
2288                        .unwrap(),
2289                        RawPacket::from_packet(&cb::MultiBlockChange {
2290                            chunk_x: 0,
2291                            chunk_z: 0,
2292                            records: vec![cb::MultiBlockChangeRecords {
2293                                horizontal_pos: 5 << 4 | 6,
2294                                y: 62,
2295                                block_id: 60 << 4 | 7,
2296                            }],
2297                        })
2298                        .unwrap(),
2299                    ]
2300                }
2301                _ => {
2302                    use ghast_protocol::v1_16_5::play::clientbound as cb;
2303                    // Section y = -1 (20-bit two's complement), local x 5 z 6 y 14.
2304                    let local = (5 << 8 | 6 << 4 | 14) as i64;
2305                    vec![
2306                        RawPacket::from_packet(&cb::BlockChange {
2307                            location: BlockPos::new(3, 63, 4),
2308                            r#type: 1,
2309                        })
2310                        .unwrap(),
2311                        RawPacket::from_packet(&cb::MultiBlockChange {
2312                            chunk_coordinates: cb::MultiBlockChangeChunkCoordinates {
2313                                x: 0,
2314                                z: 0,
2315                                y: 0xF_FFFF,
2316                            },
2317                            not_trust_edges: false,
2318                            records: vec![9 << 12 | local],
2319                        })
2320                        .unwrap(),
2321                    ]
2322                }
2323            };
2324            for raw in &raws {
2325                e.sim.world_packet(raw).unwrap();
2326            }
2327            let mut seen = Vec::new();
2328            let mut b = Script(|ctx: &mut TickContext<'_>| {
2329                seen.push(ctx.events().block_updates.clone());
2330            });
2331            e.tick(&mut b, t0);
2332            e.tick(&mut b, t0);
2333            let _ = b;
2334            let want = match v {
2335                GameVersion::V1_8_9 => vec![
2336                    (BlockPos::new(3, 63, 4), 59 << 4 | 7),
2337                    (BlockPos::new(5, 62, 6), 60 << 4 | 7),
2338                ],
2339                _ => vec![(BlockPos::new(3, 63, 4), 1), (BlockPos::new(5, -2, 6), 9)],
2340            };
2341            assert_eq!(seen, [want, Vec::new()], "{v:?}");
2342
2343            // Not collected when not asked for.
2344            e.sim.collect_block_updates(false);
2345            e.sim.world_packet(&raws[0]).unwrap();
2346            let mut n = usize::MAX;
2347            let mut b = Script(|ctx: &mut TickContext<'_>| n = ctx.events().block_updates.len());
2348            e.tick(&mut b, t0);
2349            let _ = b;
2350            assert_eq!(n, 0);
2351        }
2352    }
2353
2354    /// `set_flying` sends the abilities packet before the move, only when allowed and changed.
2355    #[test]
2356    fn set_flying_needs_may_fly() {
2357        for v in [GameVersion::V1_8_9, GameVersion::V1_16_5] {
2358            let mut e = engine(v);
2359            join(&mut e);
2360            let t0 = Instant::now();
2361            e.note_inbound(t0);
2362            // In the air (flight ends on landing, like vanilla).
2363            e.handle(Inbound::Teleport(Teleport {
2364                x: 8.5,
2365                y: 70.0,
2366                z: 8.5,
2367                yaw: 90.0,
2368                pitch: 0.0,
2369                flags: 0,
2370                teleport_id: Some(2),
2371            }));
2372            e.outbound().clear();
2373            let mut b = Script(|ctx: &mut TickContext<'_>| ctx.set_flying(true));
2374            e.tick(&mut b, t0);
2375            assert!(!names(e.outbound()).contains(&"abilities"), "{v:?}");
2376            assert!(!e.core.pose.flying);
2377            e.handle(Inbound::Abilities(Abilities {
2378                flags: 4,
2379                ..Abilities::default()
2380            }));
2381            e.tick(&mut b, t0);
2382            let out = std::mem::take(e.outbound());
2383            assert_eq!(
2384                out[0],
2385                Outbound::Abilities {
2386                    flags: 6,
2387                    flying_speed: 0.05,
2388                    walking_speed: 0.1
2389                },
2390                "{v:?}"
2391            );
2392            assert!(matches!(out[1], Outbound::Move(_)), "{out:?}");
2393            assert!(e.core.pose.flying && e.core.sent.flying);
2394            // Already flying: nothing more.
2395            e.tick(&mut b, t0);
2396            assert!(!names(e.outbound()).contains(&"abilities"));
2397            let _ = b;
2398        }
2399    }
2400
2401    /// A behaviour that reports `running() == false` is not ticked again.
2402    #[test]
2403    fn finished_behaviour_is_not_ticked() {
2404        struct Three(std::rc::Rc<std::cell::Cell<u32>>);
2405        impl Behavior for Three {
2406            fn tick(&mut self, _ctx: &mut TickContext<'_>) {
2407                self.0.set(self.0.get() + 1);
2408            }
2409            fn running(&self) -> bool {
2410                self.0.get() < 3
2411            }
2412        }
2413        let ticks = std::rc::Rc::new(std::cell::Cell::new(0));
2414        let mut e = engine(GameVersion::V1_8_9);
2415        join(&mut e);
2416        let t0 = Instant::now();
2417        e.note_inbound(t0);
2418        let mut b: Box<dyn Behavior> = Box::new(Three(ticks.clone()));
2419        for _ in 0..10 {
2420            e.tick(&mut b, t0);
2421            // The player keeps sending its movement packet.
2422            let out = std::mem::take(e.outbound());
2423            assert!(
2424                out.iter().any(|o| matches!(o, Outbound::Move(_))),
2425                "{out:?}"
2426            );
2427        }
2428        assert_eq!(
2429            ticks.get(),
2430            3,
2431            "not ticked after running() == false (through the Box)"
2432        );
2433    }
2434
2435    /// A turn that would take the crosshair off a block placed this tick waits one tick.
2436    #[test]
2437    fn turn_held_on_a_place_tick() {
2438        let mut e = engine(GameVersion::V1_8_9);
2439        join(&mut e);
2440        let t0 = Instant::now();
2441        e.note_inbound(t0);
2442        e.core
2443            .inv
2444            .on_set_slot(0, 36, Some(ghast_inventory::Stack::new(1, 64, 0)));
2445        let floor = ghast_protocol::BlockPos::new(8, 63, 8);
2446        let mut tick = 0;
2447        let mut placed_at = None;
2448        let mut pitches = Vec::new();
2449        let mut b = Script(|ctx: &mut TickContext<'_>| {
2450            tick += 1;
2451            if placed_at.is_none() {
2452                ctx.set_aim(90.0, 89.5);
2453                if ctx.place(floor, crate::Face::Up, Vec3::new(8.5, 64.0, 8.5)) {
2454                    placed_at = Some(tick);
2455                    // Look up on the same tick: held back.
2456                    ctx.set_aim(90.0, -60.0);
2457                }
2458            } else {
2459                ctx.set_aim(90.0, -60.0);
2460            }
2461            pitches.push(ctx.pose().pitch);
2462        });
2463        for _ in 0..30 {
2464            e.tick(&mut b, t0);
2465            e.outbound().clear();
2466        }
2467        let _ = b;
2468        let p = placed_at.expect("placed");
2469        assert_eq!(e.core.held_turns, 1);
2470        // Pose at the start of the tick after the place: still the place look.
2471        assert_eq!(pitches[p], pitches[p - 1], "{pitches:?}");
2472        assert!(pitches[p + 1] < pitches[p], "{pitches:?}");
2473    }
2474
2475    /// `click` returns the ticket its confirm carries.
2476    #[test]
2477    fn click_ticket_matches_the_confirm() {
2478        let mut e = engine(GameVersion::V1_8_9);
2479        join(&mut e);
2480        let t0 = Instant::now();
2481        e.note_inbound(t0);
2482        e.core
2483            .inv
2484            .on_set_slot(0, 36, Some(ghast_inventory::Stack::new(1, 64, 0)));
2485        let mut ticket = None;
2486        let mut n = 0;
2487        let mut b = Script(|ctx: &mut TickContext<'_>| {
2488            n += 1;
2489            if n == 1 {
2490                ctx.open_inventory().unwrap();
2491            } else if ticket.is_none() {
2492                ticket = ctx
2493                    .click(ghast_inventory::Click::Pickup {
2494                        slot: 36,
2495                        right: false,
2496                    })
2497                    .ok();
2498            }
2499        });
2500        for _ in 0..3 {
2501            e.tick(&mut b, t0);
2502        }
2503        let _ = b;
2504        let t = ticket.expect("click sent");
2505        assert_eq!(t.window_id, 0);
2506        assert!(t.matches(&ghast_inventory::ClickResult {
2507            window_id: 0,
2508            action: t.action,
2509            outcome: ghast_inventory::ClickOutcome::Accepted,
2510        }));
2511    }
2512
2513    /// 1.8 planting uses the seed at once (vanilla `SeedItem.use` on the client); a
2514    /// stone block place waits for the server.
2515    #[test]
2516    fn planting_uses_the_seed_at_once() {
2517        use ghast_protocol::v1_8::play::clientbound as cb;
2518        for (soil, item, used) in [
2519            (60 << 4 | 7, 295, true),
2520            (1 << 4, 295, false),
2521            (60 << 4, 1, false),
2522        ] {
2523            let mut e = engine(GameVersion::V1_8_9);
2524            join(&mut e);
2525            let t0 = Instant::now();
2526            e.note_inbound(t0);
2527            let floor = ghast_protocol::BlockPos::new(8, 63, 8);
2528            let raw = RawPacket::from_packet(&cb::BlockChange {
2529                location: floor,
2530                r#type: soil,
2531            })
2532            .unwrap();
2533            e.sim.world_packet(&raw).unwrap();
2534            e.core
2535                .inv
2536                .on_set_slot(0, 36, Some(ghast_inventory::Stack::new(item, 64, 0)));
2537            let mut done = false;
2538            let mut b = Script(|ctx: &mut TickContext<'_>| {
2539                ctx.set_aim(90.0, 89.5);
2540                if !done {
2541                    done = ctx.place(floor, crate::Face::Up, Vec3::new(8.5, 64.0, 8.5));
2542                }
2543            });
2544            let mut sent = None;
2545            for _ in 0..20 {
2546                e.tick(&mut b, t0);
2547                for o in e.outbound().drain(..) {
2548                    if let Outbound::Place { held, .. } = o {
2549                        sent = held.map(|h| h.count);
2550                    }
2551                }
2552            }
2553            let _ = b;
2554            assert_eq!(sent, Some(64), "the packet carries the stack before use");
2555            let left = e.core.inv.held().map(|s| s.count);
2556            assert_eq!(
2557                left,
2558                Some(if used { 63 } else { 64 }),
2559                "soil {soil} item {item}"
2560            );
2561        }
2562    }
2563
2564    /// `planned_look` is the look the movement step then uses: a plain turn, a turn held on a
2565    /// place tick, no aim; and it leaves the rotation's budget alone.
2566    #[test]
2567    fn planned_look_is_the_next_look() {
2568        let mut e = engine(GameVersion::V1_8_9);
2569        join(&mut e);
2570        let t0 = Instant::now();
2571        e.note_inbound(t0);
2572        e.core
2573            .inv
2574            .on_set_slot(0, 36, Some(ghast_inventory::Stack::new(1, 64, 0)));
2575        let floor = ghast_protocol::BlockPos::new(8, 63, 8);
2576        let mut n = 0;
2577        let mut placed = false;
2578        let mut plans: Vec<(f32, f32)> = Vec::new();
2579        let mut b = Script(|ctx: &mut TickContext<'_>| {
2580            n += 1;
2581            if n % 5 == 0 {
2582                // No aim this tick: the current look.
2583            } else if !placed {
2584                ctx.set_aim(90.0, 89.5);
2585                if ctx.place(floor, crate::Face::Up, Vec3::new(8.5, 64.0, 8.5)) {
2586                    placed = true;
2587                    ctx.set_aim(90.0, -60.0);
2588                }
2589            } else {
2590                ctx.set_aim(150.0, -60.0);
2591            }
2592            // Asking twice changes nothing.
2593            let p = ctx.planned_look();
2594            assert_eq!(p, ctx.planned_look());
2595            plans.push(p);
2596        });
2597        let mut looks = Vec::new();
2598        for _ in 0..40 {
2599            e.tick(&mut b, t0);
2600            e.outbound().clear();
2601            looks.push((e.core.pose.yaw, e.core.pose.pitch));
2602        }
2603        let _ = b;
2604        assert!(placed);
2605        assert_eq!(e.core.test_held_turns(), 1);
2606        assert_eq!(plans, looks);
2607    }
2608
2609    /// A teleport event keeps the sent pose from before the overwrite, the raw values and the
2610    /// resolved pose.
2611    #[test]
2612    fn teleport_event_has_the_sent_pose_before() {
2613        for v in [GameVersion::V1_8_9, GameVersion::V1_16_5] {
2614            let mut e = engine(v);
2615            join(&mut e);
2616            let t0 = Instant::now();
2617            e.note_inbound(t0);
2618            let mut seen = Vec::new();
2619            let mut b = Script(|ctx: &mut TickContext<'_>| {
2620                seen.extend(ctx.events().teleports.iter().copied());
2621            });
2622            e.tick(&mut b, t0);
2623            let sent = e.core.sent;
2624            // Relative yaw +30, absolute position.
2625            let tp = Teleport {
2626                x: 10.5,
2627                y: 64.0,
2628                z: 8.5,
2629                yaw: 30.0,
2630                pitch: 0.0,
2631                flags: crate::common::teleport_flags::YAW,
2632                teleport_id: Some(9),
2633            };
2634            e.handle(Inbound::Teleport(tp));
2635            e.tick(&mut b, t0);
2636            e.tick(&mut b, t0);
2637            let _ = b;
2638            // The spawn position first (join), then ours, each once.
2639            assert_eq!(seen.len(), 2, "{v:?}");
2640            let ev = seen[1];
2641            assert_eq!(ev.teleport, tp);
2642            assert_eq!(ev.sent_before, sent);
2643            assert_eq!(ev.after.pos, Vec3::new(10.5, 64.0, 8.5));
2644            assert_eq!(ev.after.yaw, sent.yaw + 30.0, "{v:?}");
2645        }
2646    }
2647
2648    /// A container the server opened and the behaviour closed (`close_window`) no longer
2649    /// holds the mouse: the left button digs again (both versions). While it is open the
2650    /// buttons are up.
2651    #[test]
2652    fn closed_window_frees_the_buttons() {
2653        for v in [GameVersion::V1_8_9, GameVersion::V1_16_5] {
2654            let mut e = engine(v);
2655            join(&mut e);
2656            let t0 = Instant::now();
2657            e.note_inbound(t0);
2658            if v == GameVersion::V1_16_5 {
2659                // The 1.16 test chunk is all air: put a stone floor under the player.
2660                use ghast_protocol::v1_16_5::play::clientbound as cb;
2661                let raw = RawPacket::from_packet(&cb::BlockChange {
2662                    location: ghast_protocol::BlockPos::new(8, 63, 8),
2663                    r#type: 1,
2664                })
2665                .unwrap();
2666                e.sim.world_packet(&raw).unwrap();
2667            }
2668            e.core.inv.on_open_window(
2669                5,
2670                Some(ghast_inventory::WindowKind::Chest { rows: 3 }),
2671                "Vault".into(),
2672            );
2673            e.handle(Inbound::OpenWindow(5));
2674            // Something on the cursor: vanilla's client-side close drops it (the server
2675            // throws it out), so the client forgets it too.
2676            e.core
2677                .inv
2678                .on_set_slot(-1, -1, Some(ghast_inventory::Stack::new(1, 3, 0)));
2679            let mut n = 0;
2680            let mut closed_at = None;
2681            let mut b = Script(|ctx: &mut TickContext<'_>| {
2682                n += 1;
2683                ctx.set_aim(90.0, 89.5);
2684                ctx.set_buttons(Buttons {
2685                    left: true,
2686                    right: false,
2687                });
2688                if n == 15 {
2689                    assert!(ctx.inventory().cursor().is_some());
2690                    ctx.close_window().unwrap();
2691                    assert_eq!(ctx.open_window(), None);
2692                    let inv = ctx.inventory();
2693                    assert!(!inv.gui_open() && inv.window().is_none() && inv.cursor().is_none());
2694                    closed_at = Some(n);
2695                }
2696            });
2697            let mut first_dig = None;
2698            for i in 1..=30 {
2699                e.tick(&mut b, t0);
2700                let out = std::mem::take(e.outbound());
2701                if first_dig.is_none()
2702                    && out.iter().any(|o| {
2703                        matches!(
2704                            o,
2705                            Outbound::Dig {
2706                                status: crate::common::DigStatus::Start,
2707                                ..
2708                            }
2709                        )
2710                    })
2711                {
2712                    first_dig = Some(i);
2713                }
2714            }
2715            let _ = b;
2716            assert_eq!(closed_at, Some(15));
2717            let d = first_dig.unwrap_or_else(|| panic!("{v:?}: no dig after the close"));
2718            // The hands run after the behaviour: the closing tick already digs.
2719            assert!(d >= 15, "{v:?}: dug at tick {d} with the window open");
2720        }
2721    }
2722
2723    /// `swings` counts every arm swing: the behaviour's, an action's and the held button's.
2724    #[test]
2725    fn swings_count_every_arm_swing() {
2726        let mut e = engine(GameVersion::V1_8_9);
2727        join(&mut e);
2728        let t0 = Instant::now();
2729        e.note_inbound(t0);
2730        let mut n = 0;
2731        let mut seen = Vec::new();
2732        let mut b = Script(|ctx: &mut TickContext<'_>| {
2733            n += 1;
2734            ctx.set_aim(90.0, 89.5);
2735            if n == 1 {
2736                ctx.swing();
2737                ctx.swing();
2738            }
2739            if n == 20 {
2740                // Hold the left button on the stone floor: a swing every tick while digging.
2741                ctx.set_buttons(Buttons {
2742                    left: true,
2743                    right: false,
2744                });
2745            }
2746            seen.push(ctx.swings());
2747        });
2748        let mut sent = 0u64;
2749        for _ in 0..30 {
2750            e.tick(&mut b, t0);
2751            sent += count_swings(&std::mem::take(e.outbound()));
2752        }
2753        let _ = b;
2754        assert_eq!(seen[0], 2, "this tick's own swings count at once");
2755        assert!(sent > 10, "the held button swings: {sent}");
2756        // The last reading misses only the hands' swing of the last tick.
2757        assert_eq!(*seen.last().unwrap() + 1, sent);
2758        assert_eq!(e.core.swings, sent);
2759    }
2760
2761    /// `WorldView::column` gives the stored column; light and biomes only when configured.
2762    #[test]
2763    fn column_view_and_world_config() {
2764        for config in [
2765            None,
2766            Some(
2767                ghast_world::WorldConfig::default()
2768                    .with_biomes(true)
2769                    .with_light(true),
2770            ),
2771        ] {
2772            let sim = crate::sim::Sim18::with(&crate::sim::SimOptions {
2773                world: config,
2774                ..crate::sim::SimOptions::default()
2775            });
2776            let mut e = Engine::new(opts(GameVersion::V1_8_9), Box::new(sim) as Box<dyn Sim>);
2777            join(&mut e);
2778            let w: &dyn WorldView = &e.sim;
2779            let col = w.column(0, 0).expect("loaded");
2780            assert_eq!(u32::from(col.raw(8, 63, 8)), w.raw_block(8, 63, 8).unwrap());
2781            assert_eq!(col.raw(8, 63, 8), 1 << 4);
2782            assert!(w.column(5, 5).is_none());
2783            let light = col.sky_light(8, 63, 8);
2784            assert_eq!(light.is_some(), config.is_some(), "{config:?}");
2785            let biomes = !matches!(col.biomes(), ghast_world::Biomes::None);
2786            assert_eq!(biomes, config.is_some(), "{config:?}");
2787        }
2788    }
2789
2790    /// `after_tick` runs after the movement step with the sent pose, also once the behaviour
2791    /// finished; a panic there stops the behaviour only.
2792    #[test]
2793    fn after_tick_sees_the_sent_pose() {
2794        struct Obs {
2795            n: u32,
2796            views: Vec<(u64, Pose, u64)>,
2797        }
2798        impl Behavior for Obs {
2799            fn tick(&mut self, ctx: &mut TickContext<'_>) {
2800                self.n += 1;
2801                ctx.set_aim(150.0, 10.0);
2802                ctx.set_controls(Controls {
2803                    forward: true,
2804                    ..Controls::default()
2805                });
2806            }
2807            fn running(&self) -> bool {
2808                self.n < 5
2809            }
2810            fn after_tick(&mut self, view: &TickView<'_>) {
2811                self.views.push((view.tick(), *view.sent(), view.swings()));
2812                assert!(view.world().is_column_loaded(0, 0));
2813                assert_eq!(view.version(), GameVersion::V1_8_9);
2814            }
2815        }
2816        let mut e = engine(GameVersion::V1_8_9);
2817        join(&mut e);
2818        let t0 = Instant::now();
2819        e.note_inbound(t0);
2820        let mut b = Obs {
2821            n: 0,
2822            views: Vec::new(),
2823        };
2824        let mut sent = Vec::new();
2825        for _ in 0..10 {
2826            e.tick(&mut b, t0);
2827            e.outbound().clear();
2828            sent.push(e.core.sent);
2829        }
2830        assert_eq!(b.n, 5);
2831        assert_eq!(b.views.len(), 10, "also after the behaviour finished");
2832        for (i, (_, p, _)) in b.views.iter().enumerate() {
2833            assert_eq!(*p, sent[i]);
2834        }
2835        assert_ne!(
2836            b.views[0].1.yaw, b.views[4].1.yaw,
2837            "the view follows the turn"
2838        );
2839    }
2840
2841    /// With block entities on, a 1.8 sign update reaches the column (and the packet passes the
2842    /// filter); off by default.
2843    #[test]
2844    fn block_entities_follow_the_world_config() {
2845        use ghast_protocol::v1_8::play::clientbound as cb;
2846        for on in [false, true] {
2847            let sim = crate::sim::Sim18::with(&crate::sim::SimOptions {
2848                world: on.then(|| ghast_world::WorldConfig::default().with_block_entities(true)),
2849                ..crate::sim::SimOptions::default()
2850            });
2851            assert_eq!(sim.world_packet_ids().contains(&cb::id::UPDATE_SIGN), on);
2852            let mut e = Engine::new(opts(GameVersion::V1_8_9), Box::new(sim) as Box<dyn Sim>);
2853            join(&mut e);
2854            let raw = RawPacket::from_packet(&cb::UpdateSign {
2855                location: ghast_protocol::BlockPos::new(3, 64, 4),
2856                text1: "\"Hello\"".into(),
2857                text2: "\"\"".into(),
2858                text3: "\"\"".into(),
2859                text4: "\"\"".into(),
2860            })
2861            .unwrap();
2862            e.sim.world_packet(&raw).unwrap();
2863            let w: &dyn WorldView = &e.sim;
2864            let be = w.column(0, 0).unwrap().block_entity(3, 64, 4);
2865            assert_eq!(be.is_some(), on, "{be:?}");
2866        }
2867    }
2868}