use std::collections::HashMap;
use std::time::{Duration, SystemTime};
use pixtuoid_core::physics::{walk_arrived, walk_profile, WalkIntent, WalkProfile};
use pixtuoid_core::state::AgentSlot;
use pixtuoid_core::walkable::OccupancyOverlay;
use pixtuoid_core::AgentId;
use crate::tui::layout::{Layout, Point, WaypointKind};
use crate::tui::pathfind::Router;
use crate::tui::pose::{
aimless_wander_seed, cycle_ms_for, dwell_ms, est_wander_cycle_ms, is_aimless_cycle,
pick_aimless_dest, seated_dwell_ms, takes_trip, waypoint_index_for_cycle, WANDER_DWELL_EST_MS,
};
use crate::tui::pose::{desk_leg_endpoint, octile_distance};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WalkPathSnapshot {
pub from: Point,
pub to: Point,
pub path: Vec<Point>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WanderPhase {
Seated,
WalkingOut,
AtWaypoint,
WalkingBack,
}
#[derive(Debug, Clone)]
pub struct WalkLeg {
pub started_at: SystemTime,
pub profile: WalkProfile,
pub from: Point,
}
#[derive(Debug, Clone)]
pub struct MotionState {
pub agent_id: AgentId,
pub entry: Option<(SystemTime, WalkProfile)>,
pub exit: Option<WalkLeg>,
pub snap_back: Option<WalkLeg>,
pub wander_cycle_n: u64,
pub wander_phase: WanderPhase,
pub wander_phase_started_at: SystemTime,
pub wander_profile: Option<WalkProfile>,
pub wander_dest: Point,
pub wander_dest_kind: Option<WaypointKind>,
pub wander_dest_wp_idx: Option<usize>,
pub wander_seat: Option<Point>,
pub last_advanced_at: SystemTime,
pub walk_path: Option<WalkPathSnapshot>,
}
impl MotionState {
pub fn new(agent_id: AgentId) -> Self {
Self {
agent_id,
entry: None,
exit: None,
snap_back: None,
wander_cycle_n: 0,
wander_phase: WanderPhase::Seated,
wander_phase_started_at: SystemTime::UNIX_EPOCH,
wander_profile: None,
wander_dest: Point { x: 0, y: 0 },
wander_dest_kind: None,
wander_dest_wp_idx: None,
wander_seat: None,
last_advanced_at: SystemTime::UNIX_EPOCH,
walk_path: None,
}
}
}
pub fn advance_wander(
slot: &AgentSlot,
now: SystemTime,
layout: &Layout,
router: &mut dyn Router,
overlay: &OccupancyOverlay,
motion: &mut HashMap<AgentId, MotionState>,
) -> (WanderPhase, u16) {
let id = slot.agent_id;
let ms = motion.entry(id).or_insert_with(|| MotionState::new(id));
let is_fresh = ms
.wander_phase_started_at
.checked_add(Duration::from_millis(1))
.map(|t| t <= slot.state_started_at)
.unwrap_or(true);
let is_stale_resume = ms.last_advanced_at != SystemTime::UNIX_EPOCH
&& now
.duration_since(ms.last_advanced_at)
.map(|d| d.as_millis() as u64 > cycle_ms_for(id))
.unwrap_or(false);
if is_fresh || is_stale_resume {
let elapsed_idle = now
.duration_since(slot.state_started_at)
.unwrap_or(Duration::ZERO)
.as_millis() as u64;
let cycle = est_wander_cycle_ms(id);
ms.wander_phase = WanderPhase::Seated;
ms.wander_profile = None;
ms.wander_cycle_n = elapsed_idle / cycle;
ms.wander_phase_started_at = now;
}
let may_transition = now > ms.last_advanced_at;
let elapsed_phase = now
.duration_since(ms.wander_phase_started_at)
.unwrap_or(Duration::ZERO)
.as_millis() as u64;
let seated_dur = seated_dwell_ms(id);
let dwell_dur = ms
.wander_dest_kind
.map_or(WANDER_DWELL_EST_MS, |k| dwell_ms(k, id));
let result = match ms.wander_phase {
WanderPhase::Seated => {
if may_transition && elapsed_phase >= seated_dur {
if !takes_trip(id, ms.wander_cycle_n) || layout.waypoints.is_empty() {
ms.wander_cycle_n += 1;
ms.wander_phase_started_at = ms
.wander_phase_started_at
.checked_add(Duration::from_millis(seated_dur))
.unwrap_or(now);
} else {
let desk_pt = layout.home_desks.get(slot.desk_index).copied();
let origin = desk_pt.unwrap_or(Point { x: 0, y: 0 });
let (dest, dest_kind, wp_idx, seat) =
pick_wander_dest(id, ms.wander_cycle_n, layout, origin);
ms.wander_dest = dest;
ms.wander_dest_kind = dest_kind;
ms.wander_dest_wp_idx = wp_idx;
ms.wander_seat = seat;
let desk = desk_pt.unwrap_or(dest);
let (from, chair_settle) = desk_leg_endpoint(desk, layout);
let path = router.route(&layout.walkable, overlay, from, dest);
let desk_glide = settle_len(from, chair_settle);
let len = (octile_path_len(&path) + desk_glide + settle_len(dest, seat)).max(1);
ms.wander_profile = Some(walk_profile(len, WalkIntent::WanderOut, id));
ms.wander_phase = WanderPhase::WalkingOut;
ms.wander_phase_started_at = ms
.wander_phase_started_at
.checked_add(Duration::from_millis(seated_dur))
.unwrap_or(now);
}
}
(ms.wander_phase, 0)
}
WanderPhase::WalkingOut => {
let profile = match &ms.wander_profile {
Some(p) => p,
None => {
tracing::warn!(
agent_id = ?slot.agent_id,
"wander walk profile missing in WalkingOut — recovering"
);
return (WanderPhase::WalkingOut, 0);
}
};
let t_x1000 = pixtuoid_core::physics::walk_progress(profile, elapsed_phase);
if may_transition && walk_arrived(profile, elapsed_phase) {
let walk_total = profile.duration_ms + profile.pause_ms;
let back = snapshot_back_profile(slot, ms, layout, router, overlay);
ms.wander_phase = WanderPhase::AtWaypoint;
ms.wander_phase_started_at = ms
.wander_phase_started_at
.checked_add(Duration::from_millis(walk_total))
.unwrap_or(now);
ms.wander_profile = Some(back);
(WanderPhase::AtWaypoint, 1000)
} else {
(WanderPhase::WalkingOut, t_x1000)
}
}
WanderPhase::AtWaypoint => {
if may_transition && elapsed_phase >= dwell_dur {
if ms.wander_profile.is_none() {
let back = snapshot_back_profile(slot, ms, layout, router, overlay);
ms.wander_profile = Some(back);
}
ms.wander_phase = WanderPhase::WalkingBack;
ms.wander_phase_started_at = ms
.wander_phase_started_at
.checked_add(Duration::from_millis(dwell_dur))
.unwrap_or(now);
}
(ms.wander_phase, 0)
}
WanderPhase::WalkingBack => {
let profile = match &ms.wander_profile {
Some(p) => p,
None => {
tracing::warn!(
agent_id = ?slot.agent_id,
"wander walk profile missing in WalkingBack — recovering"
);
return (WanderPhase::WalkingBack, 0);
}
};
let t_x1000 = pixtuoid_core::physics::walk_progress(profile, elapsed_phase);
if may_transition && walk_arrived(profile, elapsed_phase) {
let walk_total = profile.duration_ms + profile.pause_ms;
ms.wander_cycle_n += 1;
ms.wander_profile = None;
ms.wander_dest_kind = None;
ms.wander_dest_wp_idx = None;
ms.wander_seat = None;
ms.wander_phase = WanderPhase::Seated;
ms.wander_phase_started_at = ms
.wander_phase_started_at
.checked_add(Duration::from_millis(walk_total))
.unwrap_or(now);
(WanderPhase::Seated, 0)
} else {
(WanderPhase::WalkingBack, t_x1000)
}
}
};
if may_transition {
ms.last_advanced_at = now;
}
result
}
fn pick_wander_dest(
id: AgentId,
cycle_n: u64,
layout: &Layout,
origin: Point,
) -> (Point, Option<WaypointKind>, Option<usize>, Option<Point>) {
if is_aimless_cycle(id, cycle_n) {
let seed = aimless_wander_seed(id, cycle_n);
let p = pick_aimless_dest(layout, seed);
(p, None, None, None)
} else {
let wp_idx = waypoint_index_for_cycle(id, cycle_n, layout.waypoints.len());
let wp = layout.waypoints[wp_idx];
let dest = pixtuoid_core::layout::approach_point(
wp.kind.furniture(),
wp.pos,
wp.facing,
layout.pantry_counter_size,
&layout.walkable,
origin,
&layout.reachable,
);
if dest == wp.pos {
let seed = aimless_wander_seed(id, cycle_n);
return (pick_aimless_dest(layout, seed), None, None, None);
}
let seat = pixtuoid_core::layout::seated_foot_cell(wp.kind.furniture(), wp.pos);
(dest, Some(wp.kind), Some(wp_idx), seat)
}
}
fn snapshot_back_profile(
slot: &AgentSlot,
ms: &MotionState,
layout: &Layout,
router: &mut dyn Router,
overlay: &OccupancyOverlay,
) -> WalkProfile {
let desk = layout
.home_desks
.get(slot.desk_index)
.copied()
.unwrap_or(ms.wander_dest);
let (snap_to, chair_settle) = desk_leg_endpoint(desk, layout);
let back_path = router.route(&layout.walkable, overlay, ms.wander_dest, snap_to);
let desk_glide = settle_len(snap_to, chair_settle);
let back_len =
(octile_path_len(&back_path) + settle_len(ms.wander_dest, ms.wander_seat) + desk_glide)
.max(1);
walk_profile(back_len, WalkIntent::WanderBack, slot.agent_id)
}
pub fn octile_path_len(path: &[Point]) -> u32 {
if path.len() < 2 {
return 0;
}
path.windows(2).map(|w| octile_distance(w[0], w[1])).sum()
}
pub(in crate::tui) fn settle_len(approach: Point, seat: Option<Point>) -> u32 {
seat.map_or(0, |s| octile_distance(approach, s))
}
#[cfg(test)]
mod tests;