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