use std::time::{Duration, SystemTime};
use crate::layout::{Bounds, Point, SceneLayout, WaypointKind};
use crate::state::{ActivityState, AgentSlot};
use crate::AgentId;
const THINKING_WINDOW_SECS: u64 = 20;
pub const WANDER_CYCLE_BASE_MS: u64 = 7_000;
pub const WANDER_CYCLE_RANGE_MS: u64 = 6_000;
const PHASE_SEATED_FRAC: u64 = 389; const PHASE_WALK_OUT_FRAC: u64 = 556; const PHASE_AT_WAYPOINT_FRAC: u64 = 833;
pub const TYPING_FRAME_MS: u64 = 140;
pub const WALKING_FRAME_MS: u64 = 220;
pub const TYPING_FRAMES: usize = 2;
pub const WALKING_FRAMES: usize = 2;
pub const ENTRY_ANIMATION_MS: u64 = 4000;
pub fn cycle_ms_for(agent_id: AgentId) -> u64 {
WANDER_CYCLE_BASE_MS + (agent_id.raw() >> 16) % WANDER_CYCLE_RANGE_MS
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Personality {
pub trip_chance_pct: u8,
pub aimless_pref_pct: u8,
}
pub fn personality_for(agent_id: AgentId) -> Personality {
let h = agent_id.raw();
Personality {
trip_chance_pct: (10 + (h % 41)) as u8, aimless_pref_pct: ((h >> 8) % 71) as u8, }
}
pub fn takes_trip(agent_id: AgentId, cycle_n: u64) -> bool {
let p = personality_for(agent_id);
let mix = agent_id.raw() ^ cycle_n.wrapping_mul(0x9e37_79b9_7f4a_7c15);
(mix % 100) < p.trip_chance_pct as u64
}
pub fn is_aimless_cycle(agent_id: AgentId, cycle_n: u64) -> bool {
let p = personality_for(agent_id);
let type_mix = agent_id.raw() ^ cycle_n.wrapping_mul(0xbf58_476d_1ce4_e5b9);
(type_mix % 100) < p.aimless_pref_pct as u64
}
pub fn waypoint_index_for_cycle(agent_id: AgentId, cycle_n: u64, num_waypoints: usize) -> usize {
if num_waypoints == 0 {
return 0;
}
((agent_id.raw() ^ cycle_n) as usize) % num_waypoints
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pose {
SeatedIdle,
SeatedThinking,
SeatedTyping {
frame: usize,
},
StandingAtDesk,
AtWaypoint {
wp: usize,
kind: WaypointKind,
},
Walking {
from: Point,
to: Point,
t_x1000: u16,
frame: usize,
},
AimlessAt {
dest: Point,
},
}
pub fn derive(slot: &AgentSlot, now: SystemTime, layout: &SceneLayout) -> Option<Pose> {
let desk = *layout.home_desks.get(slot.desk_index)?;
if let (Some(exit_time), Some(target)) = (slot.exiting_at, layout.door_threshold) {
let since_exit = now
.duration_since(exit_time)
.unwrap_or(Duration::ZERO)
.as_millis() as u64;
if since_exit < ENTRY_ANIMATION_MS {
let t = (since_exit * 1000 / ENTRY_ANIMATION_MS).min(1000) as u16;
let frame = ((since_exit / WALKING_FRAME_MS) as usize) % WALKING_FRAMES;
return Some(Pose::Walking {
from: Point {
x: desk.x + 6,
y: desk.y + 4,
},
to: target,
t_x1000: t,
frame,
});
}
return None;
}
if let Some(from) = layout.door_threshold {
let since_spawn = now
.duration_since(slot.created_at)
.unwrap_or(Duration::ZERO)
.as_millis() as u64;
if since_spawn < ENTRY_ANIMATION_MS {
let t = (since_spawn * 1000 / ENTRY_ANIMATION_MS).min(1000) as u16;
let frame = ((since_spawn / WALKING_FRAME_MS) as usize) % WALKING_FRAMES;
return Some(Pose::Walking {
from,
to: Point {
x: desk.x + 6,
y: desk.y + 4,
},
t_x1000: t,
frame,
});
}
}
let elapsed = now
.duration_since(slot.state_started_at)
.unwrap_or(Duration::ZERO)
.as_millis() as u64;
match &slot.state {
ActivityState::Active { .. } => {
let frame = ((elapsed / TYPING_FRAME_MS) as usize) % TYPING_FRAMES;
Some(Pose::SeatedTyping { frame })
}
ActivityState::Waiting { .. } => Some(Pose::StandingAtDesk),
ActivityState::Idle => {
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 {
Some(Pose::SeatedThinking)
} else {
Some(idle_pose(slot, desk, layout, elapsed))
}
}
}
}
fn pick_aimless_dest(layout: &SceneLayout, seed: u64) -> Point {
let window_strip = Bounds {
x: layout.cubicle_band.x,
y: layout.top_margin + 1,
width: layout.cubicle_band.width,
height: 10,
};
let zones: [(Bounds, u16); 5] = [
(window_strip, 30),
(layout.pantry_room.unwrap_or(window_strip), 25),
(layout.corridor.unwrap_or(layout.walkway), 20),
(layout.cubicle_band, 15),
(layout.meeting_room.unwrap_or(window_strip), 10),
];
let total: u16 = zones.iter().map(|(_, w)| *w).sum();
let mut roll = ((seed >> 32) as u16) % total.max(1);
let zone = zones
.iter()
.find_map(|(b, w)| {
if roll < *w {
Some(b)
} else {
roll -= w;
None
}
})
.unwrap_or(&zones[0].0);
for i in 0..32u64 {
let h = seed
.wrapping_add(i.wrapping_mul(0x9e37_79b9_7f4a_7c15))
.wrapping_mul(0xc6a4_a793_5bd1_e995);
let x = zone.x + (h as u16) % zone.width.max(1);
let y = zone.y + ((h >> 16) as u16) % zone.height.max(1);
if layout.is_walkable(x, y) {
return Point { x, y };
}
}
let c = layout.corridor.unwrap_or(layout.walkway);
let x_jitter = (seed as u16) % c.width.max(1);
Point {
x: c.x + x_jitter,
y: c.y + c.height / 2,
}
}
fn idle_pose(slot: &AgentSlot, desk: Point, layout: &SceneLayout, elapsed_ms: u64) -> Pose {
let cycle_ms = cycle_ms_for(slot.agent_id);
let cycle_n = elapsed_ms / cycle_ms;
let phase_t = elapsed_ms % cycle_ms;
if !takes_trip(slot.agent_id, cycle_n) || layout.waypoints.is_empty() {
return Pose::SeatedIdle;
}
let aimless = is_aimless_cycle(slot.agent_id, cycle_n);
let seated_end = cycle_ms * PHASE_SEATED_FRAC / 1000;
let walk_out_end = cycle_ms * PHASE_WALK_OUT_FRAC / 1000;
let at_wp_end = cycle_ms * PHASE_AT_WAYPOINT_FRAC / 1000;
let (dest, at_dest_pose): (Point, Pose) = if aimless {
let seed = slot.agent_id.raw() ^ cycle_n.wrapping_mul(0xd1b5_4a32_d192_ed03);
let p = pick_aimless_dest(layout, seed);
(p, Pose::AimlessAt { dest: p })
} else {
let wp_idx = waypoint_index_for_cycle(slot.agent_id, cycle_n, layout.waypoints.len());
let wp = layout.waypoints[wp_idx];
(
wp.pos,
Pose::AtWaypoint {
wp: wp_idx,
kind: wp.kind,
},
)
};
if phase_t < seated_end {
Pose::SeatedIdle
} else if phase_t < walk_out_end {
let span = walk_out_end - seated_end;
let t = ((phase_t - seated_end) * 1000 / span) as u16;
let frame = ((elapsed_ms / WALKING_FRAME_MS) as usize) % WALKING_FRAMES;
Pose::Walking {
from: desk,
to: dest,
t_x1000: t,
frame,
}
} else if phase_t < at_wp_end {
at_dest_pose
} else {
let span = cycle_ms - at_wp_end;
let t = ((phase_t - at_wp_end) * 1000 / span) as u16;
let frame = ((elapsed_ms / WALKING_FRAME_MS) as usize) % WALKING_FRAMES;
Pose::Walking {
from: dest,
to: desk,
t_x1000: t,
frame,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::source::Activity;
use std::path::PathBuf;
use std::time::Duration;
fn slot(state: ActivityState, age_ms: u64) -> (AgentSlot, SystemTime) {
let id = AgentId::from_transcript_path("/p/a.jsonl");
let started = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
let now = started + Duration::from_millis(age_ms);
let created = started - Duration::from_secs(60);
let s = AgentSlot {
agent_id: id,
source: std::sync::Arc::from("claude-code"),
session_id: std::sync::Arc::from("abc"),
cwd: std::sync::Arc::from(PathBuf::from("/repo").as_path()),
label: std::sync::Arc::from("cc"),
state,
state_started_at: started,
created_at: created,
last_event_at: created,
exiting_at: None,
pending_idle_at: None,
desk_index: 0,
tool_call_count: 0,
active_ms: 0,
};
(s, now)
}
fn layout() -> SceneLayout {
SceneLayout::compute(120, 96, 4).expect("fits")
}
fn typing() -> ActivityState {
ActivityState::Active {
activity: Activity::Typing,
tool_use_id: Some("t".into()),
detail: Some("Edit".into()),
}
}
fn phases(agent_id: AgentId) -> (u64, u64, u64, u64) {
let c = cycle_ms_for(agent_id);
(
c * PHASE_SEATED_FRAC / 1000,
c * PHASE_WALK_OUT_FRAC / 1000,
c * PHASE_AT_WAYPOINT_FRAC / 1000,
c,
)
}
fn first_trip_cycle(agent_id: AgentId) -> u64 {
(0u64..1000)
.find(|n| takes_trip(agent_id, *n))
.expect("agent should trip within first 1000 cycles")
}
#[test]
fn active_state_is_seated_typing_with_cycling_frame() {
let (s, now) = slot(typing(), 0);
let l = layout();
assert_eq!(derive(&s, now, &l), Some(Pose::SeatedTyping { frame: 0 }));
let (s, now) = slot(typing(), TYPING_FRAME_MS);
assert_eq!(derive(&s, now, &l), Some(Pose::SeatedTyping { frame: 1 }));
let (s, now) = slot(typing(), TYPING_FRAME_MS * 2);
assert_eq!(derive(&s, now, &l), Some(Pose::SeatedTyping { frame: 0 }));
}
#[test]
fn waiting_state_is_standing_at_desk() {
let (s, now) = slot(
ActivityState::Waiting {
reason: "perm".into(),
},
5_000,
);
let l = layout();
assert_eq!(derive(&s, now, &l), Some(Pose::StandingAtDesk));
}
#[test]
fn idle_phase_0_is_seated_idle() {
let (test_slot, _) = slot(ActivityState::Idle, 0);
let (seated_end, _, _, _) = phases(test_slot.agent_id);
let (s, now) = slot(ActivityState::Idle, seated_end - 1);
let l = layout();
assert_eq!(derive(&s, now, &l), Some(Pose::SeatedIdle));
}
#[test]
fn idle_phase_1_is_walking_out() {
let (test_slot, _) = slot(ActivityState::Idle, 0);
let (seated_end, walk_out_end, _, _) = phases(test_slot.agent_id);
let cycle = cycle_ms_for(test_slot.agent_id);
let trip_n = first_trip_cycle(test_slot.agent_id);
let midpoint = trip_n * cycle + seated_end + (walk_out_end - seated_end) / 2;
let (s, now) = slot(ActivityState::Idle, midpoint);
let l = layout();
match derive(&s, now, &l).expect("pose") {
Pose::Walking { t_x1000, frame, .. } => {
assert!((400..=600).contains(&t_x1000), "t_x1000={t_x1000}");
assert!(frame < WALKING_FRAMES);
}
other => panic!("expected Walking, got {other:?}"),
}
}
#[test]
fn idle_phase_2_is_at_waypoint() {
let (test_slot, _) = slot(ActivityState::Idle, 0);
let (_, walk_out_end, at_wp_end, _) = phases(test_slot.agent_id);
let cycle = cycle_ms_for(test_slot.agent_id);
let trip_n = first_trip_cycle(test_slot.agent_id);
let midpoint = trip_n * cycle + walk_out_end + (at_wp_end - walk_out_end) / 2;
let (s, now) = slot(ActivityState::Idle, midpoint);
let l = layout();
match derive(&s, now, &l).expect("pose") {
Pose::AtWaypoint { wp, .. } => assert!(wp < l.waypoints.len()),
Pose::AimlessAt { .. } => {}
other => panic!("expected AtWaypoint or AimlessAt, got {other:?}"),
}
}
#[test]
fn idle_phase_3_is_walking_back() {
let (test_slot, _) = slot(ActivityState::Idle, 0);
let (_, _, at_wp_end, cycle) = phases(test_slot.agent_id);
let trip_n = first_trip_cycle(test_slot.agent_id);
let midpoint = trip_n * cycle + at_wp_end + (cycle - at_wp_end) / 2;
let (s, now) = slot(ActivityState::Idle, midpoint);
let l = layout();
match derive(&s, now, &l).expect("pose") {
Pose::Walking { t_x1000, .. } => {
assert!((400..=600).contains(&t_x1000));
}
other => panic!("expected Walking, got {other:?}"),
}
}
#[test]
fn takes_trip_fires_roughly_30_percent_of_cycles() {
let id = AgentId::from_transcript_path("/p/sample.jsonl");
let trips = (0u64..1000).filter(|n| takes_trip(id, *n)).count();
assert!(
(50..=550).contains(&trips),
"expected 50..=550 trips out of 1000 (personality-driven), got {trips}"
);
}
#[test]
fn personality_varies_across_agents() {
let ps: Vec<Personality> = (0..20)
.map(|i| personality_for(AgentId::from_transcript_path(&format!("/p/{i}.jsonl"))))
.collect();
let trip_chances: std::collections::HashSet<u8> =
ps.iter().map(|p| p.trip_chance_pct).collect();
assert!(
trip_chances.len() >= 5,
"expected variance in trip_chance_pct"
);
for p in &ps {
assert!((10..=50).contains(&p.trip_chance_pct));
assert!(p.aimless_pref_pct <= 70);
}
}
#[test]
fn non_trip_cycle_is_seated_idle_throughout() {
let (test_slot, _) = slot(ActivityState::Idle, 0);
let id = test_slot.agent_id;
let cycle = cycle_ms_for(id);
let stay_n = (0u64..100)
.find(|n| !takes_trip(id, *n))
.expect("agent should have a non-trip cycle");
for k in 0..10 {
let t = stay_n * cycle + (k * cycle / 10);
let (s, now) = slot(ActivityState::Idle, t);
let l = layout();
assert_eq!(
derive(&s, now, &l),
Some(Pose::SeatedIdle),
"t={t} should be SeatedIdle on non-trip cycle"
);
}
}
#[test]
fn idle_cycle_loops_after_one_cycle() {
let (test_slot, _) = slot(ActivityState::Idle, 0);
let cycle = cycle_ms_for(test_slot.agent_id);
let (s_early, now_early) = slot(ActivityState::Idle, 1_000);
let (s_loop, now_loop) = slot(ActivityState::Idle, 1_000 + cycle);
let l = layout();
let e = derive(&s_early, now_early, &l).expect("e");
let lp = derive(&s_loop, now_loop, &l).expect("loop");
assert!(
matches!((e, lp), (Pose::SeatedIdle, Pose::SeatedIdle)),
"1s into any cycle should be SeatedIdle. got early={e:?} loop={lp:?}"
);
}
#[test]
fn entry_animation_overrides_normal_pose_for_first_4s() {
let id = AgentId::from_transcript_path("/p/entry.jsonl");
let now0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
let s = AgentSlot {
agent_id: id,
source: std::sync::Arc::from("claude-code"),
session_id: std::sync::Arc::from("abc"),
cwd: std::sync::Arc::from(PathBuf::from("/repo").as_path()),
label: std::sync::Arc::from("cc"),
state: ActivityState::Idle,
state_started_at: now0,
created_at: now0,
last_event_at: now0,
exiting_at: None,
pending_idle_at: None,
desk_index: 0,
tool_call_count: 0,
active_ms: 0,
};
let probe = now0 + Duration::from_millis(1500);
let l = layout();
match derive(&s, probe, &l).expect("pose") {
Pose::Walking { t_x1000, .. } => {
assert!((300..=450).contains(&t_x1000), "t_x1000={t_x1000}");
}
other => panic!("expected Walking entry, got {other:?}"),
}
}
#[test]
fn derive_returns_none_when_desk_index_out_of_range() {
let (mut s, now) = slot(ActivityState::Idle, 0);
s.desk_index = 999;
assert!(derive(&s, now, &layout()).is_none());
}
#[test]
fn cycle_ms_for_varies_across_agents() {
let ids: Vec<AgentId> = (0..10)
.map(|i| AgentId::from_transcript_path(&format!("/p/{i}.jsonl")))
.collect();
let cycles: std::collections::HashSet<u64> =
ids.iter().map(|id| cycle_ms_for(*id)).collect();
assert!(
cycles.len() >= 3,
"expected multiple distinct cycle lengths, got {cycles:?}"
);
for c in &cycles {
assert!(
*c >= WANDER_CYCLE_BASE_MS && *c < WANDER_CYCLE_BASE_MS + WANDER_CYCLE_RANGE_MS
);
}
}
#[test]
fn waypoint_choice_changes_across_cycles_for_same_agent() {
let l = layout();
let (test_slot, _) = slot(ActivityState::Idle, 0);
let cycle = cycle_ms_for(test_slot.agent_id);
let (_, walk_out_end, at_wp_end, _) = phases(test_slot.agent_id);
let mid_at_wp = walk_out_end + (at_wp_end - walk_out_end) / 2;
let mut dest_xs = std::collections::HashSet::new();
for n in 0..50u64 {
let t = n * cycle + mid_at_wp;
let (s, now) = slot(ActivityState::Idle, t);
match derive(&s, now, &l) {
Some(Pose::AtWaypoint { wp, .. }) => {
dest_xs.insert(l.waypoints[wp].pos.x);
}
Some(Pose::AimlessAt { dest }) => {
dest_xs.insert(dest.x);
}
_ => {}
}
}
assert!(
dest_xs.len() >= 2,
"destination should vary across cycles, got {dest_xs:?}"
);
}
#[test]
fn idle_within_thinking_window_returns_seated_thinking() {
let (mut s, now) = slot(ActivityState::Idle, 5_000);
s.last_event_at = now - Duration::from_secs(5);
let l = layout();
let p = derive(&s, now, &l).unwrap();
assert_eq!(p, Pose::SeatedThinking);
}
#[test]
fn idle_past_thinking_window_returns_idle_pose() {
let (mut s, now) = slot(ActivityState::Idle, 25_000);
s.last_event_at = now - Duration::from_secs(25);
let l = layout();
let p = derive(&s, now, &l).unwrap();
assert_ne!(p, Pose::SeatedThinking);
}
#[test]
fn freshly_spawned_idle_skips_thinking() {
let (s, now) = slot(ActivityState::Idle, 5_000);
assert_eq!(s.last_event_at, s.created_at);
let l = layout();
let p = derive(&s, now, &l).unwrap();
assert_ne!(p, Pose::SeatedThinking);
}
}