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    /// Starts digging `pos` on `face` (`block_dig` start, then an arm swing), breaking it at
1731    /// once when it is instant for the held item. Refused (nothing sent, `false`) unless the
1732    /// crosshair from the sent pose is on exactly that block face.
1733    pub fn dig(&mut self, pos: ghast_protocol::BlockPos, face: crate::common::Face) -> bool {
1734        if !self
1735            .target()
1736            .is_some_and(|t| t.pos == pos && t.face == face)
1737        {
1738            return false;
1739        }
1740        let world = self.world;
1741        let blocks = |x: i32, y: i32, z: i32| world.block_query(x, y, z);
1742        let c = &mut *self.core;
1743        let mut placed = false;
1744        let mut io = HandsIo {
1745            blocks: &blocks,
1746            target: None,
1747            held: self.held,
1748            held_stack: c.inv.held().cloned(),
1749            food: c.health.food,
1750            modern: c.opts.version != GameVersion::V1_8_9,
1751            off_held: None,
1752            off_stack: None,
1753            entity: None,
1754            factors: DigFactors {
1755                on_ground: c.pose.on_ground,
1756                in_water: false,
1757                aqua_affinity: c.aqua_affinity,
1758                haste: c.effects.get(&3).map_or(0, |a| (*a as u8).wrapping_add(1)),
1759                fatigue: c.effects.get(&4).map_or(0, |a| (*a as u8).wrapping_add(1)),
1760            },
1761            sneaking: c.pose.sneaking,
1762            out: &mut c.out,
1763            broken: &mut c.broken_now,
1764            placed: &mut placed,
1765        };
1766        c.hands.click_block(pos, face, &mut io);
1767        c.out.push(Outbound::Swing);
1768        true
1769    }
1770
1771    /// Right-clicks `support`'s `face` at the absolute point `hit` (`block_place`, then an arm
1772    /// swing): places the held block or activates the block. Refused (nothing sent, `false`)
1773    /// unless the crosshair from the sent pose is on exactly that block face.
1774    pub fn place(
1775        &mut self,
1776        support: ghast_protocol::BlockPos,
1777        face: crate::common::Face,
1778        hit: Vec3,
1779    ) -> bool {
1780        let Some(t) = self.target().filter(|t| t.pos == support && t.face == face) else {
1781            return false;
1782        };
1783        let world = self.world;
1784        let Some(b) = world.block_query(support.x, support.y, support.z) else {
1785            return false;
1786        };
1787        let blocks = |x: i32, y: i32, z: i32| world.block_query(x, y, z);
1788        let c = &mut *self.core;
1789        let mut placed = false;
1790        let first = c.out.len();
1791        let mut io = HandsIo {
1792            blocks: &blocks,
1793            target: None,
1794            held: self.held,
1795            held_stack: c.inv.held().cloned(),
1796            food: c.health.food,
1797            modern: c.opts.version != GameVersion::V1_8_9,
1798            off_held: None,
1799            off_stack: None,
1800            entity: None,
1801            factors: DigFactors::default(),
1802            sneaking: c.pose.sneaking,
1803            out: &mut c.out,
1804            broken: &mut c.broken_now,
1805            placed: &mut placed,
1806        };
1807        c.hands
1808            .right_click_block(BlockHit { hit, ..t }, &b, &mut io);
1809        let _ = placed;
1810        predict_planting(c, world, first);
1811        c.out.push(Outbound::Swing);
1812        true
1813    }
1814
1815    /// When the last inbound bytes arrived (a gap before a setback means lag).
1816    #[must_use]
1817    pub fn last_inbound(&self) -> Option<Instant> {
1818        self.core.last_inbound
1819    }
1820
1821    /// The yaw / pitch this tick's movement step will use, from this tick's
1822    /// [`TickContext::set_aim`] and the digs / places written so far (incl. the turn held by
1823    /// [`crate::Client::hold_turn_on_action`]). Computed on a copy: the rotation's carried
1824    /// budget does not change. Clicks of held buttons, which run after the behaviour, are not
1825    /// seen. Without an aim this tick: the current look.
1826    #[must_use]
1827    pub fn planned_look(&self) -> (f32, f32) {
1828        let c = &*self.core;
1829        let now = (c.pose.yaw, c.pose.pitch);
1830        #[cfg(test)]
1831        if let Some(l) = c.forced_look {
1832            return l;
1833        }
1834        if !c.aimed {
1835            return now;
1836        }
1837        let mut r = c.rotation;
1838        let (yaw, pitch) = r.step(c.pose.yaw, c.pose.pitch);
1839        if c.opts.hold_turn_on_action && !post_look_ok(c, yaw, pitch) {
1840            now
1841        } else {
1842            (yaw, pitch)
1843        }
1844    }
1845
1846    /// Starts or stops creative-style flight: the `abilities` packet goes out before this
1847    /// tick's movement step, like a double-tapped jump (but without the jump). Starting needs
1848    /// the server's "may fly" ability; asking for the current state sends nothing.
1849    pub fn set_flying(&mut self, flying: bool) {
1850        self.core.want_flying = Some(flying);
1851    }
1852
1853    /// Turns held back so far because they would have taken the crosshair off a block dug or
1854    /// placed on the same tick (see [`crate::Client::hold_turn_on_action`]).
1855    #[must_use]
1856    pub fn held_turns(&self) -> u64 {
1857        self.core.held_turns
1858    }
1859
1860    /// Behaviour status line (logged when it changes).
1861    pub fn status(&mut self, text: &str) {
1862        if self.core.status != text {
1863            text.clone_into(&mut self.core.status);
1864            log::info!(
1865                "[{}] t{} status: {text}",
1866                self.core.opts.name,
1867                self.core.tick
1868            );
1869        }
1870    }
1871}
1872
1873#[cfg(test)]
1874mod tests {
1875    use super::*;
1876    use crate::common::Teleport;
1877    use crate::common::{ChatPosition, EntityAction, JoinGame};
1878    use ghast_protocol::RawPacket;
1879
1880    fn opts(version: GameVersion) -> CoreOptions {
1881        CoreOptions {
1882            name: "test".into(),
1883            version,
1884            settings: ClientSettings::default(),
1885            brand: Some("vanilla".into()),
1886            sensitivity: 0.5,
1887            silent_hold: Duration::from_millis(1500),
1888            hold_turn_on_action: true,
1889            inventory: match version {
1890                GameVersion::V1_8_9 => ghast_inventory::Version::V1_8,
1891                _ => ghast_inventory::Version::V1_16_5,
1892            },
1893        }
1894    }
1895
1896    /// A 1.8.9 engine (physics) or a 1.16.5 one (stand-still mover).
1897    fn engine(version: GameVersion) -> Engine<Box<dyn Sim>> {
1898        let sim: Box<dyn Sim> = match version {
1899            GameVersion::V1_8_9 => Box::new(crate::sim::Sim18::new()),
1900            _ => Box::new(crate::sim::Sim16::new()),
1901        };
1902        Engine::new(opts(version), sim)
1903    }
1904
1905    /// Loads the column of chunk (x, z) the way the connection does.
1906    fn load(e: &mut Engine<Box<dyn Sim>>, x: i32, z: i32) {
1907        let raw = match e.core.opts.version {
1908            GameVersion::V1_8_9 => crate::sim::test_chunks::v1_8(x, z),
1909            _ => crate::sim::test_chunks::v1_16_5(x, z),
1910        };
1911        e.sim.world_packet(&raw).unwrap();
1912    }
1913
1914    fn join(e: &mut Engine<Box<dyn Sim>>) {
1915        e.handle(Inbound::JoinGame(JoinGame {
1916            entity_id: 7,
1917            game_mode: GameMode::Survival,
1918            dimension: "0".into(),
1919            hardcore: false,
1920            dimension_nbt: None,
1921            respawn_screen: true,
1922        }));
1923        load(e, 0, 0);
1924        e.handle(Inbound::Teleport(Teleport {
1925            x: 8.5,
1926            y: 64.0,
1927            z: 8.5,
1928            yaw: 90.0,
1929            pitch: 0.0,
1930            flags: 0,
1931            teleport_id: None,
1932        }));
1933        e.outbound().clear();
1934    }
1935
1936    /// Records the tick's packets as names.
1937    fn names(out: &mut Vec<Outbound>) -> Vec<&'static str> {
1938        let v = out
1939            .iter()
1940            .map(|o| match o {
1941                Outbound::Move(m) => m.trace_id(),
1942                Outbound::Chat(_) => "chat",
1943                Outbound::HeldSlot(_) => "held_slot",
1944                Outbound::Swing => "swing",
1945                Outbound::EntityAction { .. } => "entity_action",
1946                Outbound::KeepAlive(_) => "keep_alive",
1947                Outbound::Transaction { .. } => "transaction",
1948                Outbound::TeleportConfirm(_) => "teleport_confirm",
1949                Outbound::Respawn => "respawn",
1950                Outbound::Settings(_) => "settings",
1951                Outbound::Brand(_) => "brand",
1952                Outbound::Abilities { .. } => "abilities",
1953                Outbound::Dig { .. } => "block_dig",
1954                Outbound::Place { .. } | Outbound::UseItem { .. } => "block_place",
1955                Outbound::SwingOffHand => "swing_off",
1956                Outbound::UseEntity { .. } => "use_entity",
1957                Outbound::Inventory(_) => "inventory",
1958            })
1959            .collect();
1960        out.clear();
1961        v
1962    }
1963
1964    struct Script<F: FnMut(&mut TickContext<'_>)>(F);
1965    impl<F: FnMut(&mut TickContext<'_>)> Behavior for Script<F> {
1966        fn tick(&mut self, ctx: &mut TickContext<'_>) {
1967            (self.0)(ctx);
1968        }
1969    }
1970
1971    #[test]
1972    fn join_sends_settings_then_brand() {
1973        let mut e = engine(GameVersion::V1_8_9);
1974        e.handle(Inbound::JoinGame(JoinGame {
1975            entity_id: 1,
1976            game_mode: GameMode::Creative,
1977            dimension: "0".into(),
1978            hardcore: false,
1979            dimension_nbt: None,
1980            respawn_screen: true,
1981        }));
1982        assert_eq!(names(e.outbound()), ["settings", "brand"]);
1983    }
1984
1985    #[test]
1986    fn nothing_before_the_first_teleport() {
1987        let mut e = engine(GameVersion::V1_8_9);
1988        let mut b = Idle;
1989        let t0 = Instant::now();
1990        load(&mut e, 0, 0);
1991        e.outbound().clear();
1992        for i in 0..5 {
1993            e.tick(&mut b, t0 + Duration::from_millis(50 * i));
1994        }
1995        assert!(e.outbound().is_empty());
1996    }
1997
1998    #[test]
1999    fn teleport_reply_per_version() {
2000        let mut e = engine(GameVersion::V1_16_5);
2001        e.handle(Inbound::Teleport(Teleport {
2002            x: 1.0,
2003            y: 2.0,
2004            z: 3.0,
2005            yaw: 450.0,
2006            pitch: 100.0,
2007            flags: 0,
2008            teleport_id: Some(5),
2009        }));
2010        let out = std::mem::take(e.outbound());
2011        assert_eq!(out[0], Outbound::TeleportConfirm(5));
2012        match out[1] {
2013            Outbound::Move(MovePacket::PositionLook {
2014                yaw,
2015                pitch,
2016                on_ground,
2017                ..
2018            }) => {
2019                assert_eq!((yaw, pitch, on_ground), (90.0, 90.0, false));
2020            }
2021            ref o => panic!("unexpected {o:?}"),
2022        }
2023        let mut e = engine(GameVersion::V1_8_9);
2024        e.handle(Inbound::Teleport(Teleport {
2025            x: 1.0,
2026            y: 2.0,
2027            z: 3.0,
2028            yaw: 450.0,
2029            pitch: 100.0,
2030            flags: 0,
2031            teleport_id: None,
2032        }));
2033        let out = std::mem::take(e.outbound());
2034        assert_eq!(out.len(), 1);
2035        // 1.8 does not clamp the pitch.
2036        assert!(matches!(
2037            out[0],
2038            Outbound::Move(MovePacket::PositionLook {
2039                yaw: 90.0,
2040                pitch: 100.0,
2041                ..
2042            })
2043        ));
2044    }
2045
2046    #[test]
2047    fn actions_precede_the_single_movement_packet() {
2048        let mut e = engine(GameVersion::V1_8_9);
2049        join(&mut e);
2050        let t0 = Instant::now();
2051        e.note_inbound(t0);
2052        let mut b = Script(|ctx: &mut TickContext<'_>| {
2053            if ctx.tick() == 2 {
2054                ctx.select_slot(3);
2055                ctx.chat("hello");
2056                ctx.swing();
2057                ctx.set_controls(Controls {
2058                    sneak: true,
2059                    ..Controls::default()
2060                });
2061            }
2062        });
2063        e.tick(&mut b, t0);
2064        assert_eq!(names(e.outbound()), ["C06"]);
2065        e.tick(&mut b, t0);
2066        assert_eq!(
2067            names(e.outbound()),
2068            ["held_slot", "chat", "swing", "entity_action", "C03"]
2069        );
2070        for _ in 0..100 {
2071            e.tick(&mut b, t0);
2072            let n = names(e.outbound());
2073            assert_eq!(n.len(), 1, "one movement packet per tick: {n:?}");
2074        }
2075    }
2076
2077    #[test]
2078    fn standing_bot_packets() {
2079        let mut e = engine(GameVersion::V1_8_9);
2080        join(&mut e);
2081        let mut b = Idle;
2082        let t0 = Instant::now();
2083        e.note_inbound(t0);
2084        // First tick: the position differs from the fresh player's lastReported (origin).
2085        e.tick(&mut b, t0);
2086        let out = std::mem::take(e.outbound());
2087        assert!(matches!(
2088            out[..],
2089            [Outbound::Move(MovePacket::PositionLook {
2090                on_ground: false,
2091                ..
2092            })]
2093        ));
2094        // Then flying (on ground) with a position refresh every 21st tick.
2095        let mut kinds = Vec::new();
2096        for _ in 0..42 {
2097            e.tick(&mut b, t0);
2098            kinds.extend(names(e.outbound()));
2099        }
2100        assert_eq!(kinds.iter().filter(|k| **k == "C04").count(), 2);
2101        assert_eq!(kinds.iter().filter(|k| **k == "C03").count(), 40);
2102        assert_eq!((kinds[20], kinds[41]), ("C04", "C04"));
2103        assert!(e.core.sent.on_ground);
2104    }
2105
2106    #[test]
2107    fn silent_server_freezes_behaviour() {
2108        let mut e = engine(GameVersion::V1_8_9);
2109        join(&mut e);
2110        let t0 = Instant::now();
2111        e.note_inbound(t0);
2112        let mut ticks = 0;
2113        let mut b = Script(|ctx: &mut TickContext<'_>| {
2114            ticks += 1;
2115            ctx.set_controls(Controls {
2116                sneak: true,
2117                ..Controls::default()
2118            });
2119        });
2120        e.tick(&mut b, t0 + Duration::from_millis(100));
2121        e.tick(&mut b, t0 + Duration::from_millis(1600));
2122        // Frozen: keys released, so the sneak stops; still one movement packet.
2123        let n = names(e.outbound());
2124        assert_eq!(n, ["entity_action", "C06", "entity_action", "C03"]);
2125        e.note_inbound(t0 + Duration::from_millis(1700));
2126        e.tick(&mut b, t0 + Duration::from_millis(1700));
2127        let _ = b;
2128        assert_eq!(ticks, 2);
2129    }
2130
2131    #[test]
2132    fn keepalive_answered_transactions_left_to_the_inventory() {
2133        let mut e = engine(GameVersion::V1_8_9);
2134        join(&mut e);
2135        e.handle(Inbound::KeepAlive(42));
2136        // The engine never answers confirms: the inventory does (exactly once), see the
2137        // fake-server test.
2138        e.handle(Inbound::Transaction {
2139            window_id: 0,
2140            action: -5,
2141            accepted: false,
2142        });
2143        assert_eq!(std::mem::take(e.outbound()), [Outbound::KeepAlive(42)]);
2144    }
2145
2146    #[test]
2147    fn panicking_behaviour_does_not_stop_the_tick() {
2148        let mut e = engine(GameVersion::V1_8_9);
2149        join(&mut e);
2150        let t0 = Instant::now();
2151        let mut b = Script(|ctx: &mut TickContext<'_>| {
2152            if ctx.tick() == 2 {
2153                panic!("boom");
2154            }
2155        });
2156        let hook = std::panic::take_hook();
2157        std::panic::set_hook(Box::new(|_| {}));
2158        for _ in 0..5 {
2159            e.tick(&mut b, t0);
2160        }
2161        std::panic::set_hook(hook);
2162        assert!(e.state.failed);
2163        assert_eq!(names(e.outbound()).len(), 5);
2164    }
2165
2166    #[test]
2167    fn chat_events_reach_the_behaviour_once() {
2168        let mut e = engine(GameVersion::V1_16_5);
2169        join(&mut e);
2170        e.handle(Inbound::Chat(ChatMessage {
2171            json: r#"{"text":"hi"}"#.into(),
2172            position: ChatPosition::System,
2173            sender: None,
2174        }));
2175        let mut seen = Vec::new();
2176        let mut b = Script(|ctx: &mut TickContext<'_>| {
2177            seen.extend(ctx.events().chat.iter().map(ChatMessage::plain));
2178        });
2179        let t0 = Instant::now();
2180        e.tick(&mut b, t0);
2181        e.tick(&mut b, t0);
2182        let _ = b;
2183        assert_eq!(seen, ["hi"]);
2184    }
2185
2186    #[test]
2187    fn immediate_respawn_1_16() {
2188        let mut e = engine(GameVersion::V1_16_5);
2189        join(&mut e);
2190        e.handle(Inbound::GameState {
2191            reason: 11,
2192            value: 1.0,
2193        });
2194        e.handle(Inbound::Died { entity_id: 7 });
2195        assert_eq!(e.outbound().as_slice(), [Outbound::Respawn]);
2196    }
2197
2198    /// Left button on the floor: START, swings, FINISH, all before the tick's movement
2199    /// packet; the broken block is predicted air and the player falls into the hole.
2200    #[test]
2201    fn dig_with_the_left_button() {
2202        let mut e = engine(GameVersion::V1_8_9);
2203        join(&mut e);
2204        let t0 = Instant::now();
2205        e.note_inbound(t0);
2206        let mut b = Script(|ctx: &mut TickContext<'_>| {
2207            ctx.set_aim(90.0, 89.5);
2208            let down = ctx.target().is_some_and(|t| {
2209                t.pos == ghast_protocol::BlockPos::new(8, 63, 8) && t.face == crate::Face::Up
2210            });
2211            ctx.set_buttons(Buttons {
2212                left: down,
2213                right: false,
2214            });
2215        });
2216        let mut ticks = Vec::new();
2217        for _ in 0..200 {
2218            e.tick(&mut b, t0);
2219            let n = names(e.outbound());
2220            assert_eq!(n.iter().filter(|x| x.starts_with('C')).count(), 1, "{n:?}");
2221            assert!(n.last().unwrap().starts_with('C'), "movement last: {n:?}");
2222            ticks.push(n);
2223        }
2224        let flat: Vec<&str> = ticks.iter().flatten().copied().collect();
2225        assert_eq!(
2226            flat.iter().filter(|x| **x == "block_dig").count(),
2227            2,
2228            "start + finish"
2229        );
2230        assert!(e.core.hands.broken == 1);
2231        // Stone by hand: not harvestable, 1 / 1.5 / 100 per tick -> 151 damage ticks.
2232        let start = ticks.iter().position(|t| t.contains(&"block_dig")).unwrap();
2233        let finish = ticks
2234            .iter()
2235            .rposition(|t| t.contains(&"block_dig"))
2236            .unwrap();
2237        assert_eq!(finish - start, 150);
2238        assert!(e.sim.block_query(8, 63, 8).unwrap().air);
2239        assert!(
2240            e.core.pose.pos.y < 64.0,
2241            "falls into the hole: {:?}",
2242            e.core.pose.pos
2243        );
2244    }
2245
2246    /// `place` is refused unless the sent look is on the face; then C08 + C0A before the
2247    /// movement packet.
2248    #[test]
2249    fn place_needs_the_sent_look() {
2250        let mut e = engine(GameVersion::V1_8_9);
2251        join(&mut e);
2252        let t0 = Instant::now();
2253        e.note_inbound(t0);
2254        let floor = ghast_protocol::BlockPos::new(8, 63, 8);
2255        let mut results = Vec::new();
2256        let mut b = Script(|ctx: &mut TickContext<'_>| {
2257            ctx.set_aim(90.0, 89.5);
2258            results.push(ctx.place(floor, crate::Face::Up, Vec3::new(8.5, 64.0, 8.5)));
2259        });
2260        // A stack of stone in the selected hotbar slot (window 0 slot 36).
2261        e.core
2262            .inv
2263            .on_set_slot(0, 36, Some(ghast_inventory::Stack::new(1, 64, 0)));
2264        let mut placed_tick = None;
2265        for i in 0..20 {
2266            e.tick(&mut b, t0);
2267            let n = names(e.outbound());
2268            if n.contains(&"block_place") {
2269                assert_eq!(n[..2], ["block_place", "swing"], "{n:?}");
2270                placed_tick.get_or_insert(i);
2271            }
2272        }
2273        let _ = b;
2274        let first = results.iter().position(|r| *r).unwrap();
2275        assert!(first > 3, "refused while still turning");
2276        assert_eq!(placed_tick, Some(first));
2277        assert_eq!(e.core.inv.held().map(|s| s.id), Some(1));
2278    }
2279
2280    #[test]
2281    fn sneak_action_order() {
2282        let mut e = engine(GameVersion::V1_8_9);
2283        join(&mut e);
2284        let mut b = Script(|ctx: &mut TickContext<'_>| {
2285            ctx.set_controls(Controls {
2286                sneak: true,
2287                ..Controls::default()
2288            });
2289        });
2290        e.tick(&mut b, Instant::now());
2291        let out = std::mem::take(e.outbound());
2292        assert_eq!(
2293            out[0],
2294            Outbound::EntityAction {
2295                entity_id: 7,
2296                action: EntityAction::StartSneaking
2297            }
2298        );
2299        assert!(matches!(out[1], Outbound::Move(_)));
2300    }
2301
2302    /// Block changes reach the behaviour once, in arrival order, only when asked for.
2303    #[test]
2304    fn block_updates_per_version() {
2305        use ghast_protocol::BlockPos;
2306        for v in [GameVersion::V1_8_9, GameVersion::V1_16_5] {
2307            let mut e = engine(v);
2308            join(&mut e);
2309            e.sim.collect_block_updates(true);
2310            let t0 = Instant::now();
2311            e.note_inbound(t0);
2312            let raws = match v {
2313                GameVersion::V1_8_9 => {
2314                    use ghast_protocol::v1_8::play::clientbound as cb;
2315                    vec![
2316                        RawPacket::from_packet(&cb::BlockChange {
2317                            location: BlockPos::new(3, 63, 4),
2318                            r#type: 59 << 4 | 7,
2319                        })
2320                        .unwrap(),
2321                        RawPacket::from_packet(&cb::MultiBlockChange {
2322                            chunk_x: 0,
2323                            chunk_z: 0,
2324                            records: vec![cb::MultiBlockChangeRecords {
2325                                horizontal_pos: 5 << 4 | 6,
2326                                y: 62,
2327                                block_id: 60 << 4 | 7,
2328                            }],
2329                        })
2330                        .unwrap(),
2331                    ]
2332                }
2333                _ => {
2334                    use ghast_protocol::v1_16_5::play::clientbound as cb;
2335                    // Section y = -1 (20-bit two's complement), local x 5 z 6 y 14.
2336                    let local = (5 << 8 | 6 << 4 | 14) as i64;
2337                    vec![
2338                        RawPacket::from_packet(&cb::BlockChange {
2339                            location: BlockPos::new(3, 63, 4),
2340                            r#type: 1,
2341                        })
2342                        .unwrap(),
2343                        RawPacket::from_packet(&cb::MultiBlockChange {
2344                            chunk_coordinates: cb::MultiBlockChangeChunkCoordinates {
2345                                x: 0,
2346                                z: 0,
2347                                y: 0xF_FFFF,
2348                            },
2349                            not_trust_edges: false,
2350                            records: vec![9 << 12 | local],
2351                        })
2352                        .unwrap(),
2353                    ]
2354                }
2355            };
2356            for raw in &raws {
2357                e.sim.world_packet(raw).unwrap();
2358            }
2359            let mut seen = Vec::new();
2360            let mut b = Script(|ctx: &mut TickContext<'_>| {
2361                seen.push(ctx.events().block_updates.clone());
2362            });
2363            e.tick(&mut b, t0);
2364            e.tick(&mut b, t0);
2365            let _ = b;
2366            let want = match v {
2367                GameVersion::V1_8_9 => vec![
2368                    (BlockPos::new(3, 63, 4), 59 << 4 | 7),
2369                    (BlockPos::new(5, 62, 6), 60 << 4 | 7),
2370                ],
2371                _ => vec![(BlockPos::new(3, 63, 4), 1), (BlockPos::new(5, -2, 6), 9)],
2372            };
2373            assert_eq!(seen, [want, Vec::new()], "{v:?}");
2374
2375            // Not collected when not asked for.
2376            e.sim.collect_block_updates(false);
2377            e.sim.world_packet(&raws[0]).unwrap();
2378            let mut n = usize::MAX;
2379            let mut b = Script(|ctx: &mut TickContext<'_>| n = ctx.events().block_updates.len());
2380            e.tick(&mut b, t0);
2381            let _ = b;
2382            assert_eq!(n, 0);
2383        }
2384    }
2385
2386    /// `set_flying` sends the abilities packet before the move, only when allowed and changed.
2387    #[test]
2388    fn set_flying_needs_may_fly() {
2389        for v in [GameVersion::V1_8_9, GameVersion::V1_16_5] {
2390            let mut e = engine(v);
2391            join(&mut e);
2392            let t0 = Instant::now();
2393            e.note_inbound(t0);
2394            // In the air (flight ends on landing, like vanilla).
2395            e.handle(Inbound::Teleport(Teleport {
2396                x: 8.5,
2397                y: 70.0,
2398                z: 8.5,
2399                yaw: 90.0,
2400                pitch: 0.0,
2401                flags: 0,
2402                teleport_id: Some(2),
2403            }));
2404            e.outbound().clear();
2405            let mut b = Script(|ctx: &mut TickContext<'_>| ctx.set_flying(true));
2406            e.tick(&mut b, t0);
2407            assert!(!names(e.outbound()).contains(&"abilities"), "{v:?}");
2408            assert!(!e.core.pose.flying);
2409            e.handle(Inbound::Abilities(Abilities {
2410                flags: 4,
2411                ..Abilities::default()
2412            }));
2413            e.tick(&mut b, t0);
2414            let out = std::mem::take(e.outbound());
2415            assert_eq!(
2416                out[0],
2417                Outbound::Abilities {
2418                    flags: 6,
2419                    flying_speed: 0.05,
2420                    walking_speed: 0.1
2421                },
2422                "{v:?}"
2423            );
2424            assert!(matches!(out[1], Outbound::Move(_)), "{out:?}");
2425            assert!(e.core.pose.flying && e.core.sent.flying);
2426            // Already flying: nothing more.
2427            e.tick(&mut b, t0);
2428            assert!(!names(e.outbound()).contains(&"abilities"));
2429            let _ = b;
2430        }
2431    }
2432
2433    /// A behaviour that reports `running() == false` is not ticked again.
2434    #[test]
2435    fn finished_behaviour_is_not_ticked() {
2436        struct Three(std::rc::Rc<std::cell::Cell<u32>>);
2437        impl Behavior for Three {
2438            fn tick(&mut self, _ctx: &mut TickContext<'_>) {
2439                self.0.set(self.0.get() + 1);
2440            }
2441            fn running(&self) -> bool {
2442                self.0.get() < 3
2443            }
2444        }
2445        let ticks = std::rc::Rc::new(std::cell::Cell::new(0));
2446        let mut e = engine(GameVersion::V1_8_9);
2447        join(&mut e);
2448        let t0 = Instant::now();
2449        e.note_inbound(t0);
2450        let mut b: Box<dyn Behavior> = Box::new(Three(ticks.clone()));
2451        for _ in 0..10 {
2452            e.tick(&mut b, t0);
2453            // The player keeps sending its movement packet.
2454            let out = std::mem::take(e.outbound());
2455            assert!(
2456                out.iter().any(|o| matches!(o, Outbound::Move(_))),
2457                "{out:?}"
2458            );
2459        }
2460        assert_eq!(
2461            ticks.get(),
2462            3,
2463            "not ticked after running() == false (through the Box)"
2464        );
2465    }
2466
2467    /// A turn that would take the crosshair off a block placed this tick waits one tick.
2468    #[test]
2469    fn turn_held_on_a_place_tick() {
2470        let mut e = engine(GameVersion::V1_8_9);
2471        join(&mut e);
2472        let t0 = Instant::now();
2473        e.note_inbound(t0);
2474        e.core
2475            .inv
2476            .on_set_slot(0, 36, Some(ghast_inventory::Stack::new(1, 64, 0)));
2477        let floor = ghast_protocol::BlockPos::new(8, 63, 8);
2478        let mut tick = 0;
2479        let mut placed_at = None;
2480        let mut pitches = Vec::new();
2481        let mut b = Script(|ctx: &mut TickContext<'_>| {
2482            tick += 1;
2483            if placed_at.is_none() {
2484                ctx.set_aim(90.0, 89.5);
2485                if ctx.place(floor, crate::Face::Up, Vec3::new(8.5, 64.0, 8.5)) {
2486                    placed_at = Some(tick);
2487                    // Look up on the same tick: held back.
2488                    ctx.set_aim(90.0, -60.0);
2489                }
2490            } else {
2491                ctx.set_aim(90.0, -60.0);
2492            }
2493            pitches.push(ctx.pose().pitch);
2494        });
2495        for _ in 0..30 {
2496            e.tick(&mut b, t0);
2497            e.outbound().clear();
2498        }
2499        let _ = b;
2500        let p = placed_at.expect("placed");
2501        assert_eq!(e.core.held_turns, 1);
2502        // Pose at the start of the tick after the place: still the place look.
2503        assert_eq!(pitches[p], pitches[p - 1], "{pitches:?}");
2504        assert!(pitches[p + 1] < pitches[p], "{pitches:?}");
2505    }
2506
2507    /// `click` returns the ticket its confirm carries.
2508    #[test]
2509    fn click_ticket_matches_the_confirm() {
2510        let mut e = engine(GameVersion::V1_8_9);
2511        join(&mut e);
2512        let t0 = Instant::now();
2513        e.note_inbound(t0);
2514        e.core
2515            .inv
2516            .on_set_slot(0, 36, Some(ghast_inventory::Stack::new(1, 64, 0)));
2517        let mut ticket = None;
2518        let mut n = 0;
2519        let mut b = Script(|ctx: &mut TickContext<'_>| {
2520            n += 1;
2521            if n == 1 {
2522                ctx.open_inventory().unwrap();
2523            } else if ticket.is_none() {
2524                ticket = ctx
2525                    .click(ghast_inventory::Click::Pickup {
2526                        slot: 36,
2527                        right: false,
2528                    })
2529                    .ok();
2530            }
2531        });
2532        for _ in 0..3 {
2533            e.tick(&mut b, t0);
2534        }
2535        let _ = b;
2536        let t = ticket.expect("click sent");
2537        assert_eq!(t.window_id, 0);
2538        assert!(t.matches(&ghast_inventory::ClickResult {
2539            window_id: 0,
2540            action: t.action,
2541            outcome: ghast_inventory::ClickOutcome::Accepted,
2542        }));
2543    }
2544
2545    /// 1.8 planting uses the seed at once (vanilla `SeedItem.use` on the client); a
2546    /// stone block place waits for the server.
2547    #[test]
2548    fn planting_uses_the_seed_at_once() {
2549        use ghast_protocol::v1_8::play::clientbound as cb;
2550        for (soil, item, used) in [
2551            (60 << 4 | 7, 295, true),
2552            (1 << 4, 295, false),
2553            (60 << 4, 1, false),
2554        ] {
2555            let mut e = engine(GameVersion::V1_8_9);
2556            join(&mut e);
2557            let t0 = Instant::now();
2558            e.note_inbound(t0);
2559            let floor = ghast_protocol::BlockPos::new(8, 63, 8);
2560            let raw = RawPacket::from_packet(&cb::BlockChange {
2561                location: floor,
2562                r#type: soil,
2563            })
2564            .unwrap();
2565            e.sim.world_packet(&raw).unwrap();
2566            e.core
2567                .inv
2568                .on_set_slot(0, 36, Some(ghast_inventory::Stack::new(item, 64, 0)));
2569            let mut done = false;
2570            let mut b = Script(|ctx: &mut TickContext<'_>| {
2571                ctx.set_aim(90.0, 89.5);
2572                if !done {
2573                    done = ctx.place(floor, crate::Face::Up, Vec3::new(8.5, 64.0, 8.5));
2574                }
2575            });
2576            let mut sent = None;
2577            for _ in 0..20 {
2578                e.tick(&mut b, t0);
2579                for o in e.outbound().drain(..) {
2580                    if let Outbound::Place { held, .. } = o {
2581                        sent = held.map(|h| h.count);
2582                    }
2583                }
2584            }
2585            let _ = b;
2586            assert_eq!(sent, Some(64), "the packet carries the stack before use");
2587            let left = e.core.inv.held().map(|s| s.count);
2588            assert_eq!(
2589                left,
2590                Some(if used { 63 } else { 64 }),
2591                "soil {soil} item {item}"
2592            );
2593        }
2594    }
2595
2596    /// `planned_look` is the look the movement step then uses: a plain turn, a turn held on a
2597    /// place tick, no aim; and it leaves the rotation's budget alone.
2598    #[test]
2599    fn planned_look_is_the_next_look() {
2600        let mut e = engine(GameVersion::V1_8_9);
2601        join(&mut e);
2602        let t0 = Instant::now();
2603        e.note_inbound(t0);
2604        e.core
2605            .inv
2606            .on_set_slot(0, 36, Some(ghast_inventory::Stack::new(1, 64, 0)));
2607        let floor = ghast_protocol::BlockPos::new(8, 63, 8);
2608        let mut n = 0;
2609        let mut placed = false;
2610        let mut plans: Vec<(f32, f32)> = Vec::new();
2611        let mut b = Script(|ctx: &mut TickContext<'_>| {
2612            n += 1;
2613            if n % 5 == 0 {
2614                // No aim this tick: the current look.
2615            } else if !placed {
2616                ctx.set_aim(90.0, 89.5);
2617                if ctx.place(floor, crate::Face::Up, Vec3::new(8.5, 64.0, 8.5)) {
2618                    placed = true;
2619                    ctx.set_aim(90.0, -60.0);
2620                }
2621            } else {
2622                ctx.set_aim(150.0, -60.0);
2623            }
2624            // Asking twice changes nothing.
2625            let p = ctx.planned_look();
2626            assert_eq!(p, ctx.planned_look());
2627            plans.push(p);
2628        });
2629        let mut looks = Vec::new();
2630        for _ in 0..40 {
2631            e.tick(&mut b, t0);
2632            e.outbound().clear();
2633            looks.push((e.core.pose.yaw, e.core.pose.pitch));
2634        }
2635        let _ = b;
2636        assert!(placed);
2637        assert_eq!(e.core.test_held_turns(), 1);
2638        assert_eq!(plans, looks);
2639    }
2640
2641    /// A teleport event keeps the sent pose from before the overwrite, the raw values and the
2642    /// resolved pose.
2643    #[test]
2644    fn teleport_event_has_the_sent_pose_before() {
2645        for v in [GameVersion::V1_8_9, GameVersion::V1_16_5] {
2646            let mut e = engine(v);
2647            join(&mut e);
2648            let t0 = Instant::now();
2649            e.note_inbound(t0);
2650            let mut seen = Vec::new();
2651            let mut b = Script(|ctx: &mut TickContext<'_>| {
2652                seen.extend(ctx.events().teleports.iter().copied());
2653            });
2654            e.tick(&mut b, t0);
2655            let sent = e.core.sent;
2656            // Relative yaw +30, absolute position.
2657            let tp = Teleport {
2658                x: 10.5,
2659                y: 64.0,
2660                z: 8.5,
2661                yaw: 30.0,
2662                pitch: 0.0,
2663                flags: crate::common::teleport_flags::YAW,
2664                teleport_id: Some(9),
2665            };
2666            e.handle(Inbound::Teleport(tp));
2667            e.tick(&mut b, t0);
2668            e.tick(&mut b, t0);
2669            let _ = b;
2670            // The spawn position first (join), then ours, each once.
2671            assert_eq!(seen.len(), 2, "{v:?}");
2672            let ev = seen[1];
2673            assert_eq!(ev.teleport, tp);
2674            assert_eq!(ev.sent_before, sent);
2675            assert_eq!(ev.after.pos, Vec3::new(10.5, 64.0, 8.5));
2676            assert_eq!(ev.after.yaw, sent.yaw + 30.0, "{v:?}");
2677        }
2678    }
2679
2680    /// A container the server opened and the behaviour closed (`close_window`) no longer
2681    /// holds the mouse: the left button digs again (both versions). While it is open the
2682    /// buttons are up.
2683    #[test]
2684    fn closed_window_frees_the_buttons() {
2685        for v in [GameVersion::V1_8_9, GameVersion::V1_16_5] {
2686            let mut e = engine(v);
2687            join(&mut e);
2688            let t0 = Instant::now();
2689            e.note_inbound(t0);
2690            if v == GameVersion::V1_16_5 {
2691                // The 1.16 test chunk is all air: put a stone floor under the player.
2692                use ghast_protocol::v1_16_5::play::clientbound as cb;
2693                let raw = RawPacket::from_packet(&cb::BlockChange {
2694                    location: ghast_protocol::BlockPos::new(8, 63, 8),
2695                    r#type: 1,
2696                })
2697                .unwrap();
2698                e.sim.world_packet(&raw).unwrap();
2699            }
2700            e.core.inv.on_open_window(
2701                5,
2702                Some(ghast_inventory::WindowKind::Chest { rows: 3 }),
2703                "Vault".into(),
2704            );
2705            e.handle(Inbound::OpenWindow(5));
2706            // Something on the cursor: vanilla's client-side close drops it (the server
2707            // throws it out), so the client forgets it too.
2708            e.core
2709                .inv
2710                .on_set_slot(-1, -1, Some(ghast_inventory::Stack::new(1, 3, 0)));
2711            let mut n = 0;
2712            let mut closed_at = None;
2713            let mut b = Script(|ctx: &mut TickContext<'_>| {
2714                n += 1;
2715                ctx.set_aim(90.0, 89.5);
2716                ctx.set_buttons(Buttons {
2717                    left: true,
2718                    right: false,
2719                });
2720                if n == 15 {
2721                    assert!(ctx.inventory().cursor().is_some());
2722                    ctx.close_window().unwrap();
2723                    assert_eq!(ctx.open_window(), None);
2724                    let inv = ctx.inventory();
2725                    assert!(!inv.gui_open() && inv.window().is_none() && inv.cursor().is_none());
2726                    closed_at = Some(n);
2727                }
2728            });
2729            let mut first_dig = None;
2730            for i in 1..=30 {
2731                e.tick(&mut b, t0);
2732                let out = std::mem::take(e.outbound());
2733                if first_dig.is_none()
2734                    && out.iter().any(|o| {
2735                        matches!(
2736                            o,
2737                            Outbound::Dig {
2738                                status: crate::common::DigStatus::Start,
2739                                ..
2740                            }
2741                        )
2742                    })
2743                {
2744                    first_dig = Some(i);
2745                }
2746            }
2747            let _ = b;
2748            assert_eq!(closed_at, Some(15));
2749            let d = first_dig.unwrap_or_else(|| panic!("{v:?}: no dig after the close"));
2750            // The hands run after the behaviour: the closing tick already digs.
2751            assert!(d >= 15, "{v:?}: dug at tick {d} with the window open");
2752        }
2753    }
2754
2755    /// `swings` counts every arm swing: the behaviour's, an action's and the held button's.
2756    #[test]
2757    fn swings_count_every_arm_swing() {
2758        let mut e = engine(GameVersion::V1_8_9);
2759        join(&mut e);
2760        let t0 = Instant::now();
2761        e.note_inbound(t0);
2762        let mut n = 0;
2763        let mut seen = Vec::new();
2764        let mut b = Script(|ctx: &mut TickContext<'_>| {
2765            n += 1;
2766            ctx.set_aim(90.0, 89.5);
2767            if n == 1 {
2768                ctx.swing();
2769                ctx.swing();
2770            }
2771            if n == 20 {
2772                // Hold the left button on the stone floor: a swing every tick while digging.
2773                ctx.set_buttons(Buttons {
2774                    left: true,
2775                    right: false,
2776                });
2777            }
2778            seen.push(ctx.swings());
2779        });
2780        let mut sent = 0u64;
2781        for _ in 0..30 {
2782            e.tick(&mut b, t0);
2783            sent += count_swings(&std::mem::take(e.outbound()));
2784        }
2785        let _ = b;
2786        assert_eq!(seen[0], 2, "this tick's own swings count at once");
2787        assert!(sent > 10, "the held button swings: {sent}");
2788        // The last reading misses only the hands' swing of the last tick.
2789        assert_eq!(*seen.last().unwrap() + 1, sent);
2790        assert_eq!(e.core.swings, sent);
2791    }
2792
2793    /// `WorldView::column` gives the stored column; light and biomes only when configured.
2794    #[test]
2795    fn column_view_and_world_config() {
2796        for config in [
2797            None,
2798            Some(
2799                ghast_world::WorldConfig::default()
2800                    .with_biomes(true)
2801                    .with_light(true),
2802            ),
2803        ] {
2804            let sim = crate::sim::Sim18::with(&crate::sim::SimOptions {
2805                world: config,
2806                ..crate::sim::SimOptions::default()
2807            });
2808            let mut e = Engine::new(opts(GameVersion::V1_8_9), Box::new(sim) as Box<dyn Sim>);
2809            join(&mut e);
2810            let w: &dyn WorldView = &e.sim;
2811            let col = w.column(0, 0).expect("loaded");
2812            assert_eq!(u32::from(col.raw(8, 63, 8)), w.raw_block(8, 63, 8).unwrap());
2813            assert_eq!(col.raw(8, 63, 8), 1 << 4);
2814            assert!(w.column(5, 5).is_none());
2815            let light = col.sky_light(8, 63, 8);
2816            assert_eq!(light.is_some(), config.is_some(), "{config:?}");
2817            let biomes = !matches!(col.biomes(), ghast_world::Biomes::None);
2818            assert_eq!(biomes, config.is_some(), "{config:?}");
2819        }
2820    }
2821
2822    /// `after_tick` runs after the movement step with the sent pose, also once the behaviour
2823    /// finished; a panic there stops the behaviour only.
2824    #[test]
2825    fn after_tick_sees_the_sent_pose() {
2826        struct Obs {
2827            n: u32,
2828            views: Vec<(u64, Pose, u64)>,
2829        }
2830        impl Behavior for Obs {
2831            fn tick(&mut self, ctx: &mut TickContext<'_>) {
2832                self.n += 1;
2833                ctx.set_aim(150.0, 10.0);
2834                ctx.set_controls(Controls {
2835                    forward: true,
2836                    ..Controls::default()
2837                });
2838            }
2839            fn running(&self) -> bool {
2840                self.n < 5
2841            }
2842            fn after_tick(&mut self, view: &TickView<'_>) {
2843                self.views.push((view.tick(), *view.sent(), view.swings()));
2844                assert!(view.world().is_column_loaded(0, 0));
2845                assert_eq!(view.version(), GameVersion::V1_8_9);
2846            }
2847        }
2848        let mut e = engine(GameVersion::V1_8_9);
2849        join(&mut e);
2850        let t0 = Instant::now();
2851        e.note_inbound(t0);
2852        let mut b = Obs {
2853            n: 0,
2854            views: Vec::new(),
2855        };
2856        let mut sent = Vec::new();
2857        for _ in 0..10 {
2858            e.tick(&mut b, t0);
2859            e.outbound().clear();
2860            sent.push(e.core.sent);
2861        }
2862        assert_eq!(b.n, 5);
2863        assert_eq!(b.views.len(), 10, "also after the behaviour finished");
2864        for (i, (_, p, _)) in b.views.iter().enumerate() {
2865            assert_eq!(*p, sent[i]);
2866        }
2867        assert_ne!(
2868            b.views[0].1.yaw, b.views[4].1.yaw,
2869            "the view follows the turn"
2870        );
2871    }
2872
2873    /// With block entities on, a 1.8 sign update reaches the column (and the packet passes the
2874    /// filter); off by default.
2875    #[test]
2876    fn block_entities_follow_the_world_config() {
2877        use ghast_protocol::v1_8::play::clientbound as cb;
2878        for on in [false, true] {
2879            let sim = crate::sim::Sim18::with(&crate::sim::SimOptions {
2880                world: on.then(|| ghast_world::WorldConfig::default().with_block_entities(true)),
2881                ..crate::sim::SimOptions::default()
2882            });
2883            assert_eq!(sim.world_packet_ids().contains(&cb::id::UPDATE_SIGN), on);
2884            let mut e = Engine::new(opts(GameVersion::V1_8_9), Box::new(sim) as Box<dyn Sim>);
2885            join(&mut e);
2886            let raw = RawPacket::from_packet(&cb::UpdateSign {
2887                location: ghast_protocol::BlockPos::new(3, 64, 4),
2888                text1: "\"Hello\"".into(),
2889                text2: "\"\"".into(),
2890                text3: "\"\"".into(),
2891                text4: "\"\"".into(),
2892            })
2893            .unwrap();
2894            e.sim.world_packet(&raw).unwrap();
2895            let w: &dyn WorldView = &e.sim;
2896            let be = w.column(0, 0).unwrap().block_entity(3, 64, 4);
2897            assert_eq!(be.is_some(), on, "{be:?}");
2898        }
2899    }
2900}