pixtuoid 0.8.0

Terminal pixel-art office for AI coding agents
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
//! TUI-side pose layer.
//!
//! Re-exports the pure pose-derivation surface from `pixtuoid_core::pose`
//! and adds the binary-side machinery:
//!   * `PoseHistory` — per-agent cache of the last rendered position.
//!   * `derive_with_routing` — the routed variant of `derive` that consults
//!     a `&mut dyn Router` so walking poses follow A*-routed polylines and
//!     so state transitions are smoothed with a snap-back walk instead of
//!     teleporting back to the desk.
//!
//! Keeping the routed code on this side means `pixtuoid-core` does not
//! depend on the pathfinder — the trait lives in the binary because A* is
//! TUI-rendering-adjacent and may differ for non-terminal renderers.

use std::collections::HashMap;
use std::time::{Duration, SystemTime};

use pixtuoid_core::physics::{walk_arrived, walk_profile, walk_progress, WalkIntent};
use pixtuoid_core::state::AgentSlot;
use pixtuoid_core::walkable::OccupancyOverlay;
use pixtuoid_core::AgentId;

use crate::tui::motion::{
    advance_wander, octile_path_len, settle_len, MotionState, WalkLeg, WalkPathSnapshot,
    WanderPhase,
};

pub use pixtuoid_core::pose::{
    aimless_wander_seed, cycle_ms_for, derive, derive_state_only, dwell_ms, est_wander_cycle_ms,
    is_aimless_cycle, personality_for, pick_aimless_dest, seated_dwell_ms, takes_trip,
    waypoint_index_for_cycle, Personality, Pose, ENTRY_ANIMATION_MS, THINKING_WINDOW_SECS,
    TYPING_FRAMES, TYPING_FRAME_MS, WALKING_FRAMES, WALKING_FRAME_MS, WANDER_CYCLE_BASE_MS,
    WANDER_CYCLE_RANGE_MS, WANDER_DWELL_EST_MS, WANDER_WALK_EST_MS,
};

use crate::tui::layout::{desk_walk_anchor, Layout, Point, WaypointKind};
use crate::tui::pathfind::Router;

/// The per-frame routing engine state threaded through pose derivation,
/// character anchoring, hit-testing and label placement. `now`/`layout` stay
/// separate args (frame inputs, not engine state). `overlay` is shared (&) —
/// none of these fns mutate it; router/history/motion are &mut.
pub struct RouteCtx<'a> {
    pub router: &'a mut dyn Router,
    pub overlay: &'a OccupancyOverlay,
    pub history: &'a mut PoseHistory,
    pub motion: &'a mut HashMap<AgentId, MotionState>,
}

/// Per-agent rendered position cache. Updated each frame by
/// `derive_with_routing`, consulted on state transitions so an agent
/// who was mid-walk when their state flipped can complete the walk
/// visually instead of teleporting back to their desk.
#[derive(Debug, Default, Clone)]
pub struct PoseHistory {
    last: std::collections::HashMap<AgentId, (Point, SystemTime)>,
}

impl PoseHistory {
    pub fn new() -> Self {
        Self::default()
    }
    /// Record where an agent was visually placed this frame.
    pub fn record(&mut self, agent_id: AgentId, anchor: Point, now: SystemTime) {
        self.last.insert(agent_id, (anchor, now));
    }
    /// Drop entries for agents no longer in `scene` (mirrors
    /// `FrameCache::evict_missing`). Without this, one `(Point, SystemTime)`
    /// per AgentId ever rendered lived for the process lifetime, per floor.
    pub fn evict_missing(&mut self, scene: &pixtuoid_core::state::SceneState) {
        self.last.retain(|id, _| scene.agents.contains_key(id));
    }
    /// Whether an entry exists for `agent_id` (test seam for eviction checks).
    #[cfg(test)]
    pub fn contains(&self, agent_id: AgentId) -> bool {
        self.last.contains_key(&agent_id)
    }
    /// Latest recorded position if it's at most `max_age_ms` old.
    pub fn recent(&self, agent_id: AgentId, max_age_ms: u64, now: SystemTime) -> Option<Point> {
        let (pt, when) = self.last.get(&agent_id).copied()?;
        let age = now.duration_since(when).ok()?.as_millis() as u64;
        if age <= max_age_ms {
            Some(pt)
        } else {
            None
        }
    }
}

/// Snap-back ARM window (ms): only trigger a snap-back walk if the desk-bound
/// state flip happened within this long. It is NOT a render cap — the walk runs
/// to completion by physics (`walk_arrived`), kept brisk by the snap-back's
/// higher accel (`physics::WALK_ACCEL_SNAPBACK`). Past this window we just show
/// the seated pose directly (too late to bother animating a return).
const SNAP_BACK_MS: u64 = 900;
/// Minimum manhattan distance (px) from current rendered position to
/// the desk before we bother animating the snap-back. Below this the
/// teleport is invisible and animating wastes a frame.
const SNAP_BACK_MIN_DIST: i32 = 8;

/// The home desk's ARRIVAL target: a reachable cell on an ALLOWED side
/// (`DESK_APPROACH` = N/E/W, excluding the south front) via the SAME
/// `approach_point` the wander seats use — so an arriving agent walks AROUND to
/// sit behind the desk instead of straight through its front. The chair
/// (`desk_walk_anchor`) is inside the blocked desk footprint, so targeting it
/// directly made A\* fall back to a straight `door→chair` line THROUGH the desk
/// body (the "walk through the table" bug). `None` only in a degenerate layout
/// where every allowed side is walled off — the caller then falls back to the
/// old direct target. The chair is the SETTLE endpoint, appended after this.
///
/// Scans from the CHAIR, not the desk's top-left origin: the footprint is
/// anchored top-left, so a scan from the corner is lopsided and can't clear the
/// 16px-wide body to the EAST (the east side would read as walled-off). From the
/// chair (≈ footprint centre) all three allowed sides are within reach, so the
/// approach cell sits directly off the seat and the settle glide is a short
/// straight hop onto the chair.
pub(in crate::tui) fn desk_approach_cell(desk: Point, layout: &Layout) -> Option<Point> {
    use pixtuoid_core::layout::{approach_point, desk_walk_anchor, Facing, Furniture};
    let chair = desk_walk_anchor(desk);
    let cell = approach_point(
        Furniture::Desk,
        chair,
        // The desk sitter faces the camera (South); DESK_APPROACH then allows
        // N/E/W (the south front is excluded — that is the bug-prone side).
        Facing::South,
        layout.pantry_counter_size,
        &layout.walkable,
        chair,
        &layout.reachable,
    );
    // approach_point returns the scanned `pos` (== chair) as the "no valid
    // approach" sentinel when no allowed+reachable side exists.
    (cell != chair).then_some(cell)
}

/// The desk-side endpoint of a desk-bound walk leg, resolved the ONE unified way
/// so no leg can regress to aiming A\* at the blocked chair. Used by EVERY leg
/// that arrives at or departs from the chair — entry, wander-out, wander-back,
/// the exit DEPARTURE, AND snap-back — so "approach via an allowed side, then
/// settle onto the chair" is defined exactly once.
///
/// Returns `(routing_endpoint, chair_settle)`:
///   * `routing_endpoint` — the cell to hand A\* as the leg's desk-side `from`/`to`
///     (a reachable N/E/W [`desk_approach_cell`], NEVER the chair: aiming A\* at
///     the blocked chair makes `find_path` snap to the *nearest* walkable cell —
///     the SOUTH front for a south-facing chair — so the agent arrives through
///     the desk front).
///   * `chair_settle` — `Some(chair)` to prepend/append via [`Settle`] (the short
///     glide on/off the seat the router never plans), or `None` in the degenerate
///     boxed-in layout where every allowed side is walled off and the leg reverts
///     to the direct chair target (resolved by `find_path`'s `snap_to_walkable`).
///
/// NOTE: snap-back is now a caller too (the urgent Idle→Active return routes via
/// the approach cell + settle like the rest, run by pure physics with a brisk
/// profile — no fixed-time compression). The ONLY non-caller is a mid-wander EXIT:
/// there the agent departs from its live wander position, not the chair.
pub(in crate::tui) fn desk_leg_endpoint(desk: Point, layout: &Layout) -> (Point, Option<Point>) {
    let chair = pixtuoid_core::layout::desk_walk_anchor(desk);
    match desk_approach_cell(desk, layout) {
        Some(approach) => (approach, Some(chair)),
        None => (chair, None),
    }
}

/// Routed variant of `derive`. For Walking poses, asks `router` for an
/// A*-routed polyline (composed against the layout's static mask + the
/// per-frame `overlay`) and converts the global t (0..1000) into a
/// per-segment Walking pose so the character traces the path
/// corner-by-corner instead of cutting through obstacles or other agents.
///
/// `motion` drives entry/exit physics: on first sighting an entering or
/// exiting agent the A* path length is snapshotted into a `WalkProfile`
/// (commit-to-route); subsequent frames compute `t_x1000` from
/// `walk_progress` against the frozen profile.
///
/// `history` is consulted on state transitions: if the agent's pose
/// flipped from a wander walk (or from AtWaypoint) to a desk-bound
/// pose (SeatedTyping / SeatedIdle / StandingAtDesk), we override the
/// instant teleport with a brief walk from the recorded previous
/// position to the desk.
pub fn derive_with_routing(
    slot: &AgentSlot,
    now: SystemTime,
    layout: &Layout,
    rctx: &mut RouteCtx<'_>,
) -> Option<Pose> {
    let router = &mut *rctx.router;
    let overlay = rctx.overlay;
    let history = &mut *rctx.history;
    let motion = &mut *rctx.motion;
    let desk = layout.home_desk(slot.desk_index.single_floor_local())?;

    // ---- EXIT branch -------------------------------------------------------
    // Takes priority over entry and state-driven poses.
    if let Some(exit_time) = slot.exiting_at {
        let Some(door_target) = layout.door_threshold else {
            // No door in this layout (very narrow terminal — Layout can
            // return door_threshold: None). Skip the physics exit walk and
            // let the reducer's grace window GC the slot. Returning None
            // here would make the exiting agent VANISH on its first frame
            // instead of holding at the desk; the old linear exit code
            // handled this gracefully too.
            let raw = derive_state_only(slot, now, layout)?;
            return match raw {
                Pose::Walking { .. } => route_walking_pose(
                    slot,
                    now,
                    layout,
                    &mut RouteCtx {
                        router,
                        overlay,
                        history,
                        motion,
                    },
                    raw,
                    Settle::None,
                ),
                other => Some(other),
            };
        };

        let mstate = motion
            .entry(slot.agent_id)
            .or_insert_with(|| MotionState::new(slot.agent_id));

        // Snapshot the exit profile on first sighting.
        if mstate.exit.is_none() {
            // Start the exit from wherever the agent actually is: its current
            // wander position if it was out on a trip (fresh history), else the
            // desk anchor (the common case — exiting from a seated state).
            // Without this, an agent that's mid-coffee-run when its session
            // ends teleports back to the desk before walking to the door.
            let desk_anchor = desk_walk_anchor(desk);
            let from = history
                .recent(slot.agent_id, 300, now)
                .unwrap_or(desk_anchor);
            // Exit is a desk DEPARTURE: when leaving the seated chair, rise off it
            // via the N/E/W approach cell so the walk to the (NE) door doesn't dip
            // SOUTH first — aiming A* from the blocked chair snaps it to the
            // nearest (south) cell, sending the agent the wrong way around the
            // desk. When already out on a wander trip, start from the live
            // position. The profile covers the chair-glide so duration matches.
            let (route_from, chair_rise) = if from == desk_anchor {
                desk_leg_endpoint(desk, layout)
            } else {
                (from, None)
            };
            let to_jittered = jitter_dest(slot.agent_id, door_target);
            let path = router.route(&layout.walkable, overlay, route_from, to_jittered);
            let glide = settle_len(route_from, chair_rise);
            let path_len = (octile_path_len(&path) + glide).max(1);
            let profile = walk_profile(path_len, WalkIntent::Exit, slot.agent_id);
            // Store the ORIGIN (chair when a desk exit) so the render can detect
            // the desk-departure and re-derive the approach+settle.
            mstate.exit = Some(WalkLeg {
                started_at: exit_time,
                profile,
                from,
            });
        }

        // Destructure without moving the non-Copy profile (Correction L).
        let e = mstate.exit.as_ref()?;
        let started_at = e.started_at;
        let profile = &e.profile;
        let stored_from = e.from;

        let elapsed_ms = now
            .duration_since(started_at)
            .unwrap_or(Duration::ZERO)
            .as_millis() as u64;

        // Compress the exit walk so it REACHES the door before the reducer's
        // EXIT_GRACE_WINDOW reaps the slot. Physics exit duration for far/slow
        // desks can exceed 4500ms; without this the slot is GC'd mid-walk and
        // the sprite vanishes in the corridor instead of reaching the door.
        // (Entry has no such cap — nothing GCs an entering agent.)
        let exit_budget = (pixtuoid_core::state::reducer::EXIT_GRACE_WINDOW.as_millis() as u64)
            .saturating_sub(300);
        let eff_elapsed = if profile.duration_ms.saturating_add(profile.pause_ms) > exit_budget {
            (elapsed_ms.saturating_mul(profile.duration_ms) / exit_budget.max(1)).max(elapsed_ms)
        } else {
            elapsed_ms
        };

        // GC: walk fully done including pause → return None so the slot
        // disappears (same as old ENTRY_ANIMATION_MS gate).
        if walk_arrived(profile, eff_elapsed) {
            return None;
        }

        let t_x1000 = walk_progress(profile, eff_elapsed);
        let frame = ((eff_elapsed / WALKING_FRAME_MS) as usize) % WALKING_FRAMES;

        // Desk departure: when the stored origin is the chair, rise off it via
        // the approach cell (matching the snapshotted profile + every other
        // desk-touching leg). Mid-wander exit starts straight from the live pos.
        let (from, exit_settle) = if stored_from == desk_walk_anchor(desk) {
            let (approach, chair) = desk_leg_endpoint(desk, layout);
            (approach, chair.map_or(Settle::None, Settle::Start))
        } else {
            (stored_from, Settle::None)
        };

        return route_walking_pose(
            slot,
            now,
            layout,
            &mut RouteCtx {
                router,
                overlay,
                history,
                motion,
            },
            Pose::Walking {
                from,
                to: door_target,
                t_x1000,
                frame,
                carrying_coffee: false,
            },
            exit_settle,
        );
    }

    // ---- ENTRY branch ------------------------------------------------------
    // Gate: spawn window check reuses ENTRY_ANIMATION_MS only as a bound on
    // how long we try to route. Physics duration is the real walk time.
    let since_spawn = now
        .duration_since(slot.created_at)
        .unwrap_or(Duration::ZERO)
        .as_millis() as u64;

    if let Some(door) = layout.door_threshold {
        // Unified ARRIVAL: walk to a reachable allowed-side cell (approach_point),
        // then Settle::End glides onto the chair — the SAME path the wander seats
        // use. Degenerate (every N/E/W side walled off): fall back to the old
        // direct target with no settle.
        let (approach, chair_settle) = desk_leg_endpoint(desk, layout);
        let settle = chair_settle.map_or(Settle::None, Settle::End);
        let settle_px = settle_len(approach, chair_settle);

        let mstate = motion
            .entry(slot.agent_id)
            .or_insert_with(|| MotionState::new(slot.agent_id));

        // Snapshot on first sighting if we're within the spawn window.
        if mstate.entry.is_none() && since_spawn < ENTRY_ANIMATION_MS {
            let to_jittered = jitter_dest(slot.agent_id, approach);
            let path = router.route(&layout.walkable, overlay, door, to_jittered);
            // Profile covers door→approach PLUS the short settle glide onto the chair.
            let path_len = (octile_path_len(&path) + settle_px).max(1);
            let profile = walk_profile(path_len, WalkIntent::Entry, slot.agent_id);
            mstate.entry = Some((slot.created_at, profile));
        }

        if let Some((started_at, ref profile)) = mstate.entry.clone() {
            let elapsed_ms = now
                .duration_since(started_at)
                .unwrap_or(Duration::ZERO)
                .as_millis() as u64;

            if !walk_arrived(profile, elapsed_ms) {
                let t_x1000 = walk_progress(profile, elapsed_ms);
                let frame = ((elapsed_ms / WALKING_FRAME_MS) as usize) % WALKING_FRAMES;
                return route_walking_pose(
                    slot,
                    now,
                    layout,
                    &mut RouteCtx {
                        router,
                        overlay,
                        history,
                        motion,
                    },
                    Pose::Walking {
                        from: door,
                        to: approach,
                        t_x1000,
                        frame,
                        carrying_coffee: false,
                    },
                    settle,
                );
            }
            // walk_arrived — fall through to state-driven pose (Correction C).
            // DO NOT call `derive()` here as that would re-fire the linear
            // entry override and cause a double-walk.
        }
    }

    // ---- WANDER DISPATCH (Idle agents whose entry walk is done) ------------
    // Reaching this line means the entry branch above already returned for any
    // in-flight entry walk, so the agent's entry is complete (arrived early for
    // near desks, or never started). Gate on Idle, NOT on `since_spawn >=
    // ENTRY_ANIMATION_MS` — that fixed 4000ms gate made a near-desk agent that
    // physically arrived in ~1s sit in core's fixed-fraction idle_pose until 4s
    // and then snap to physics wander. Drive `advance_wander` right away.
    // SeatedThinking still takes priority so the thinking-pose window is intact.
    let is_idle = matches!(slot.state, pixtuoid_core::state::ActivityState::Idle);
    if is_idle && slot.exiting_at.is_none() {
        // Check thinking-pose seam: if the agent recently finished active
        // work and is within the thinking window, return SeatedThinking now
        // regardless of wander phase.  This keeps the existing thinking-pose
        // behaviour entirely intact (no regression).
        let was_active = slot.last_event_at > slot.created_at;
        let since_last_event = now
            .duration_since(slot.last_event_at)
            .unwrap_or(Duration::ZERO)
            .as_secs();
        if was_active && since_last_event < THINKING_WINDOW_SECS {
            // Thinking window active — return SeatedThinking directly.
            return Some(Pose::SeatedThinking);
        }

        let (wander_phase, t_phys) = advance_wander(slot, now, layout, router, overlay, motion);

        match wander_phase {
            WanderPhase::WalkingOut => {
                let ms = motion.get(&slot.agent_id)?;
                let desk_point = layout.home_desk(slot.desk_index.single_floor_local())?;
                let dest = ms.wander_dest;
                let seat = ms.wander_seat;
                // Leave the desk via the approach cell (a reachable N/E/W side),
                // never straight through the south front: `from` is the approach
                // cell and the chair is PREPENDED via Settle so the sprite first
                // glides off the seat. Mirrors the entry/walk-back legs — all
                // desk-touching legs share `desk_leg_endpoint`. The profile in
                // advance_wander adds the same chair-glide so duration matches.
                let (from, chair_settle) = desk_leg_endpoint(desk_point, layout);
                let settle = match (chair_settle, seat) {
                    (Some(chair), Some(s)) => Settle::Both {
                        start: chair,
                        end: s,
                    },
                    (Some(chair), None) => Settle::Start(chair),
                    (None, Some(s)) => Settle::End(s),
                    (None, None) => Settle::None,
                };
                let elapsed_phase = now
                    .duration_since(ms.wander_phase_started_at)
                    .unwrap_or(Duration::ZERO)
                    .as_millis() as u64;
                let frame = (elapsed_phase / WALKING_FRAME_MS) as usize % WALKING_FRAMES;
                return route_walking_pose(
                    slot,
                    now,
                    layout,
                    &mut RouteCtx {
                        router,
                        overlay,
                        history,
                        motion,
                    },
                    Pose::Walking {
                        from,
                        to: dest,
                        t_x1000: t_phys,
                        frame,
                        carrying_coffee: false,
                    },
                    settle,
                );
            }
            WanderPhase::AtWaypoint => {
                let ms = motion.get(&slot.agent_id)?;
                let pose = if let (Some(wp_idx), Some(kind)) =
                    (ms.wander_dest_wp_idx, ms.wander_dest_kind)
                {
                    Pose::AtWaypoint { wp: wp_idx, kind }
                } else {
                    Pose::AimlessAt {
                        dest: ms.wander_dest,
                    }
                };
                // Record history so snap-back works if state changes now.
                let pt = ms.wander_dest;
                history.record(slot.agent_id, pt, now);
                return Some(pose);
            }
            WanderPhase::WalkingBack => {
                let ms = motion.get(&slot.agent_id)?;
                let desk_point = layout.home_desk(slot.desk_index.single_floor_local())?;
                // Copy the fields off `ms` so the immutable `motion` borrow ends
                // before `route_walking_pose` takes `&mut motion`.
                let wander_dest = ms.wander_dest;
                let wander_phase_started_at = ms.wander_phase_started_at;
                let carrying_coffee = ms.wander_dest_kind == Some(WaypointKind::Pantry);
                let seat = ms.wander_seat;
                // Arrive at the desk via the approach cell (a reachable N/E/W
                // side), never up through the south front: `to` is the approach
                // cell and the chair is APPENDED via Settle so the sprite glides
                // onto the seat. The waypoint seat (if any) is the leg's START
                // settle (stand up off it). Shares `desk_leg_endpoint` with the
                // entry/walk-out legs; advance_wander adds the matching glide len.
                let (snap_target, chair_settle) = desk_leg_endpoint(desk_point, layout);
                let settle = match (seat, chair_settle) {
                    (Some(s), Some(chair)) => Settle::Both {
                        start: s,
                        end: chair,
                    },
                    (Some(s), None) => Settle::Start(s),
                    (None, Some(chair)) => Settle::End(chair),
                    (None, None) => Settle::None,
                };
                let elapsed_phase = now
                    .duration_since(wander_phase_started_at)
                    .unwrap_or(Duration::ZERO)
                    .as_millis() as u64;
                let frame = (elapsed_phase / WALKING_FRAME_MS) as usize % WALKING_FRAMES;
                return route_walking_pose(
                    slot,
                    now,
                    layout,
                    &mut RouteCtx {
                        router,
                        overlay,
                        history,
                        motion,
                    },
                    Pose::Walking {
                        from: wander_dest,
                        to: snap_target,
                        t_x1000: t_phys,
                        frame,
                        carrying_coffee,
                    },
                    settle,
                );
            }
            WanderPhase::Seated => {
                // The tui motion machine is the wander authority: during its
                // Seated phase the agent is at its desk. Render SeatedIdle
                // DIRECTLY rather than falling through to derive_state_only —
                // that re-runs core's *stateless* wander (`idle_pose`), whose
                // independent fixed-fraction timeline can disagree with this
                // machine and return AtWaypoint/AimlessAt at an unrelated
                // location, teleporting the sprite between desk and waypoint
                // as the two clocks drift. (SeatedThinking is already handled
                // above, before advance_wander.)
                return Some(Pose::SeatedIdle);
            }
        }
    }

    // ---- STATE-DRIVEN pose -------------------------------------------------
    // Use derive_state_only (not derive) to avoid re-triggering the linear
    // entry/exit overrides in core's derive() (Correction C — no double-walk).
    let raw = derive_state_only(slot, now, layout)?;

    // Snap-back override: state-driven poses (SeatedTyping etc.) at the
    // desk would teleport the agent if they were mid-wander when state
    // changed. Replace them with a Walking pose from the previous
    // rendered position over SNAP_BACK_MS.
    let desk_pose = matches!(
        raw,
        Pose::SeatedIdle | Pose::SeatedThinking | Pose::SeatedTyping { .. } | Pose::StandingAtDesk
    );
    let since_state = now
        .duration_since(slot.state_started_at)
        .unwrap_or(Duration::ZERO)
        .as_millis() as u64;
    let mut final_settle = Settle::None;
    let pose = if desk_pose {
        let ms_entry = motion
            .entry(slot.agent_id)
            .or_insert_with(|| MotionState::new(slot.agent_id));
        // ARM ONCE per state transition. The distance gate is checked ONLY when not
        // already armed for this `state_started_at`; once armed, the leg renders to
        // completion (by physics — `walk_arrived`) from the FROZEN origin. This is
        // what makes the override idempotent within a frame: `route_walking_pose`
        // records the advancing walker position into history every call, so a second
        // `derive` in the same frame sees a CLOSER `prev` — re-checking the distance
        // gate there would drop the agent back to Seated mid-walk (the K-call
        // desync). Keying the arm on `slot.state_started_at` (not `now`) lets a NEW
        // desk-bound transition re-arm with a fresh clock. `SNAP_BACK_MS` is now just
        // the ARM window (only snap-back for a RECENT flip) — NOT a render cap; the
        // render runs until the physics walk arrives, so it never teleports.
        let already_armed =
            matches!(&ms_entry.snap_back, Some(leg) if leg.started_at == slot.state_started_at);
        if !already_armed {
            // A snap_back here is STALE (a previous transition) — clear it, then arm
            // a fresh leg, but only for a recent flip (the arm window).
            ms_entry.snap_back = None;
            if since_state < SNAP_BACK_MS {
                if let Some(prev) = history.recent(slot.agent_id, 300, now) {
                    // Distance to the CHAIR (where the agent actually sits), NOT the
                    // desk origin: the chair is offset (+6,+4) from the origin, so a
                    // desk-origin gate would re-fire forever once the agent settles ON
                    // the chair (10px from the origin ≥ MIN). Gating on the seat makes
                    // the snap-back stop the instant the walk reaches it.
                    let chair = desk_walk_anchor(desk);
                    let dist = (prev.x as i32 - chair.x as i32).abs()
                        + (prev.y as i32 - chair.y as i32).abs();
                    if dist >= SNAP_BACK_MIN_DIST {
                        // Snap-back joins the unified desk-leg path: route to the N/E/W
                        // approach cell and SETTLE onto the chair, so the correction
                        // arrives from an allowed side instead of the south front
                        // (aiming A* at the blocked chair would snap the goal to the
                        // nearest — south — cell). Route ONCE here at arm time —
                        // mirroring the entry/exit arms, same jittered goal as the
                        // render route — because the leg renders through
                        // route_walking_pose's A* polyline: the profile must measure
                        // that polyline (+ the chair-glide), or a detouring return
                        // traverses a longer path inside a straight-line duration
                        // (too fast). The arm stays once-per-transition; the rendered
                        // leg itself is still frozen (no per-frame re-route). Higher
                        // accel (WalkIntent::SnapBack → WALK_ACCEL_SNAPBACK) keeps the
                        // urgent return brisk under pure physics.
                        let (snap_target, chair_settle) = desk_leg_endpoint(desk, layout);
                        let to_jittered = jitter_dest(slot.agent_id, snap_target);
                        let path = router.route(&layout.walkable, overlay, prev, to_jittered);
                        let len =
                            (octile_path_len(&path) + settle_len(snap_target, chair_settle)).max(1);
                        let p = walk_profile(len, WalkIntent::SnapBack, slot.agent_id);
                        ms_entry.snap_back = Some(WalkLeg {
                            started_at: slot.state_started_at,
                            profile: p,
                            from: prev,
                        });
                    }
                }
            }
        }
        // Render the armed leg (idempotent — NO per-frame gate). A stale snap_back
        // (different `started_at`) fails the guard → `raw`; it is re-armed or cleared
        // on a later frame.
        match ms_entry.snap_back.clone() {
            Some(WalkLeg {
                started_at,
                profile,
                from: snap_prev,
            }) if started_at == slot.state_started_at => {
                let elapsed_ms = now
                    .duration_since(started_at)
                    .unwrap_or(Duration::ZERO)
                    .as_millis() as u64;
                // PURE physics — no time-compression. Snap-back's higher accel
                // (WALK_ACCEL_SNAPBACK) keeps the urgent return brisk on its own, so
                // we render the eased walk to completion by `walk_arrived` rather than
                // compressing it into a fixed 900ms window. It never teleports because
                // physics drives the whole walk (a far snap-back is a real ~1s walk,
                // not a hard-compressed dash).
                if walk_arrived(&profile, elapsed_ms) {
                    // Completed + paused — clear so the next transition re-snapshots.
                    ms_entry.snap_back = None;
                    raw
                } else {
                    let t_x1000 = walk_progress(&profile, elapsed_ms);
                    let frame = ((elapsed_ms / WALKING_FRAME_MS) as usize) % WALKING_FRAMES;
                    // Recompute the desk endpoint (deterministic) so the rendered leg
                    // matches the armed profile: route to the approach cell and SETTLE
                    // onto the chair (`desk_walk_anchor`, == seated_foot_cell(Desk), so
                    // the walk ends on the exact seated anchor — no transition flash),
                    // like every other desk leg. Degenerate (no approach) → direct.
                    let (snap_target, chair_settle) = desk_leg_endpoint(desk, layout);
                    final_settle = chair_settle.map_or(Settle::None, Settle::End);
                    // Walk from the FROZEN origin captured when the leg armed, not the
                    // per-frame `prev`: route_walking_pose re-records the advancing
                    // walker position into history every frame, so reading it back as
                    // the origin would creep `from` toward the desk and break the
                    // walk_path freeze's `wp.from == from` reuse guard. Mirrors exit.
                    Pose::Walking {
                        from: snap_prev,
                        to: snap_target,
                        t_x1000,
                        frame,
                        carrying_coffee: false,
                    }
                }
            }
            _ => raw,
        }
    } else {
        // Hard wall: clear any stale snap-back profile so the next state transition
        // gets a fresh snapshot rather than replaying a previous one.
        if let Some(ms) = motion.get_mut(&slot.agent_id) {
            if ms.snap_back.is_some() {
                ms.snap_back = None;
            }
        }
        raw
    };

    route_walking_pose(
        slot,
        now,
        layout,
        &mut RouteCtx {
            router,
            overlay,
            history,
            motion,
        },
        pose,
        final_settle,
    )
}

/// Apply A*-based polyline routing to a `Pose::Walking`, recording
/// history with `now`. For non-Walking poses, records waypoint/aimless
/// positions to history and returns `Some(pose)`.
///
/// This is the single shared helper for entry, exit, snap-back, and
/// state-driven walks (Correction B). Records history with `now` (not
/// `slot.last_event_at`) so snap-back lookups are fresh.
/// How a walk leg extends its polyline onto a seat — a short terminal motion the
/// A* router never plans (the seat cell may be blocked). `End` = sit down on
/// arrival (append the seat); `Start` = stand up on departure (prepend it);
/// `Both` = a leg that BOTH rises off one seat and glides onto another (a
/// wander-out rises off the desk chair then sits on the waypoint seat; a
/// wander-back rises off the waypoint seat then glides onto the desk chair).
/// Makes walk-end ≡ render-feet so seat arrival/departure don't pop.
#[derive(Clone, Copy)]
enum Settle {
    None,
    End(Point),
    Start(Point),
    Both { start: Point, end: Point },
}

fn route_walking_pose(
    slot: &AgentSlot,
    now: SystemTime,
    layout: &Layout,
    rctx: &mut RouteCtx<'_>,
    pose: Pose,
    settle: Settle,
) -> Option<Pose> {
    let router = &mut *rctx.router;
    let overlay = rctx.overlay;
    let history = &mut *rctx.history;
    let motion = &mut *rctx.motion;
    let Pose::Walking {
        from,
        to,
        t_x1000,
        frame,
        carrying_coffee,
    } = pose
    else {
        // Not walking any more — drop the frozen leg path so the next walk
        // re-snapshots a fresh polyline.
        if let Some(ms) = motion.get_mut(&slot.agent_id) {
            ms.walk_path = None;
        }
        // Record AtWaypoint / AimlessAt positions too — they're a valid
        // "previous position" for a subsequent snap-back walk.
        let pt = match &pose {
            Pose::AtWaypoint { wp, .. } => layout.waypoints.get(*wp).map(|w| w.pos),
            Pose::AimlessAt { dest } => Some(*dest),
            _ => None,
        };
        if let Some(p) = pt {
            history.record(slot.agent_id, p, now);
        }
        return Some(pose);
    };

    // Freeze the leg's polyline: snapshot the A* route the first frame this
    // (from, to) leg appears, then reuse it unchanged until the endpoints
    // change. Without this, per-frame occupancy-overlay churn invalidates the
    // A* cache and re-routes the walker onto a differently-shaped path, making
    // the frozen-profile progress `t` land on a new pixel — the visible
    // "flash"/teleport. Re-routing every frame is also what spikes a frame's
    // A* cost (the periodic stutter). See [`WalkPathSnapshot`].
    //
    // Per-agent path personality: perturb the routing destination by a few
    // pixels hashed from the agent_id so converging agents take visibly
    // different polylines (breaks the "ant trail"). The last polyline point is
    // restored to the true `to` so the walker ends at the canonical
    // destination, not the jittered approximation.
    let path = {
        let ms = motion
            .entry(slot.agent_id)
            .or_insert_with(|| MotionState::new(slot.agent_id));
        match &ms.walk_path {
            Some(wp) if wp.from == from && wp.to == to => wp.path.clone(),
            _ => {
                let to_jittered = jitter_dest(slot.agent_id, to);
                let mut p = router.route(&layout.walkable, overlay, from, to_jittered);
                if let Some(last) = p.last_mut() {
                    *last = to;
                }
                // Settle: extend the polyline onto/off the seat (terminal "sit
                // down" / "stand up" the router never plans). walk-end ≡ render.
                match settle {
                    Settle::End(s) if p.last() != Some(&s) => p.push(s),
                    Settle::Start(s) if p.first() != Some(&s) => p.insert(0, s),
                    Settle::Both { start, end } => {
                        // Append the end first so the prepend can't shift it.
                        if p.last() != Some(&end) {
                            p.push(end);
                        }
                        if p.first() != Some(&start) {
                            p.insert(0, start);
                        }
                    }
                    _ => {}
                }
                // Only freeze genuinely CORNERED routes (>2 points). A straight
                // 2-point walk has no interior corners to remap `t` onto, so it
                // can't flash — and re-routing it each frame is cheap AND
                // self-healing: if A* transiently fell back to a straight
                // `[from, to]` (find_path returned None this frame), freezing it
                // would stick that "walk through walls" for the whole leg;
                // leaving it unfrozen lets the next frame recover the real route.
                if p.len() > 2 {
                    ms.walk_path = Some(WalkPathSnapshot {
                        from,
                        to,
                        path: p.clone(),
                    });
                } else {
                    ms.walk_path = None;
                }
                p
            }
        }
    };
    if path.len() <= 2 {
        // Straight-line walk — record the interpolated position for next
        // frame's snap-back lookup. Use `now` not `last_event_at`.
        history.record(slot.agent_id, walking_position(from, to, t_x1000), now);
        return Some(Pose::Walking {
            from,
            to,
            t_x1000,
            frame,
            carrying_coffee,
        });
    }
    // Map global t to a (segment_idx, t_within_segment) using cumulative
    // octile distance — same metric A* used to plan the path, so timing
    // stays uniform along diagonals.
    let mut leg_lens: Vec<u32> = Vec::with_capacity(path.len() - 1);
    for w in path.windows(2) {
        leg_lens.push(octile_distance(w[0], w[1]));
    }
    let total: u32 = leg_lens.iter().sum();
    if total == 0 {
        return Some(pose);
    }
    let traveled = (t_x1000 as u32 * total) / 1000;
    let mut acc: u32 = 0;
    for (i, &leg) in leg_lens.iter().enumerate() {
        if acc + leg >= traveled {
            let into_leg = traveled - acc;
            let seg_t = (into_leg * 1000)
                .checked_div(leg)
                .map(|t| t.min(1000) as u16)
                .unwrap_or(1000);
            // Record the walker's current position for the next frame's
            // snap-back lookup. Use `now` not `last_event_at`.
            let cur_pos = walking_position(path[i], path[i + 1], seg_t);
            history.record(slot.agent_id, cur_pos, now);
            return Some(Pose::Walking {
                from: path[i],
                to: path[i + 1],
                t_x1000: seg_t,
                frame,
                carrying_coffee,
            });
        }
        acc += leg;
    }
    // Past the last segment — snap to final.
    let last = path.len() - 1;
    history.record(slot.agent_id, path[last], now);
    Some(Pose::Walking {
        from: path[last - 1],
        to: path[last],
        t_x1000: 1000,
        frame,
        carrying_coffee,
    })
}

/// Pure linear interpolation along the segment from `from` to `to`. The
/// rendering side has its own `walking_position` in renderer.rs that
/// also applies vertical breathing; this one is for history-tracking
/// only (we want the deterministic position, not the breath offset).
use crate::tui::pixel_painter::walking_position;

pub(in crate::tui) fn octile_distance(a: Point, b: Point) -> u32 {
    let dx = (a.x as i32 - b.x as i32).unsigned_abs();
    let dy = (a.y as i32 - b.y as i32).unsigned_abs();
    14 * dx.min(dy) + 10 * (dx.max(dy) - dx.min(dy))
}

/// Per-agent ±4px routing-destination jitter, hashed from the agent_id, so
/// converging agents take visibly different polylines (breaks the "ant trail")
/// — the entry/exit walk targets and the wander walk-path freeze all perturb
/// the GOAL the same way. Output must stay bit-identical across the three call
/// sites (same hash, same `saturating_add_signed`).
fn jitter_dest(id: AgentId, p: Point) -> Point {
    let h = id.raw();
    let jx = ((h % 9) as i32 - 4) as i16;
    let jy = (((h >> 16) % 9) as i32 - 4) as i16;
    Point {
        x: p.x.saturating_add_signed(jx),
        y: p.y.saturating_add_signed(jy),
    }
}

#[cfg(test)]
mod tests;