use std::time::SystemTime;
use pixtuoid_core::{AgentId, SceneState};
use crate::tui::layout::{Layout, Size};
use crate::tui::pet::PetKind;
use crate::tui::pixel_painter::character_anchor;
use crate::tui::pose;
pub(crate) fn hit_test_agent(
scene: &SceneState,
layout: &Layout,
now: SystemTime,
rctx: &mut pose::RouteCtx<'_>,
mx: u16,
my: u16,
) -> Option<AgentId> {
const SPRITE_W_CELLS: u16 = 8;
const SPRITE_H_CELLS: u16 = 6;
for agent in scene.agents.values() {
let Some(anchor) = character_anchor(agent, layout, now, rctx) else {
continue;
};
let cell_x = anchor.x;
let cell_y = anchor.y / 2;
if mx >= cell_x
&& mx < cell_x.saturating_add(SPRITE_W_CELLS)
&& my >= cell_y
&& my < cell_y.saturating_add(SPRITE_H_CELLS)
{
return Some(agent.agent_id);
}
}
None
}
pub fn hit_test_from_tui(scene: &SceneState, layout: &Layout, mx: u16, my: u16) -> Option<AgentId> {
const SPRITE_W: u16 = 8;
const SPRITE_H_CELLS: u16 = 6;
for agent in scene.agents.values() {
let Some(desk) = layout.home_desk(agent.desk_index.single_floor_local()) else {
continue;
};
let ax = desk.x + 1;
let ay = desk.y.saturating_sub(4);
let cell_x = ax;
let cell_y = ay / 2;
if mx >= cell_x
&& mx < cell_x.saturating_add(SPRITE_W)
&& my >= cell_y
&& my < cell_y.saturating_add(SPRITE_H_CELLS)
{
return Some(agent.agent_id);
}
}
None
}
pub fn hit_test_coffee_machine(layout: &Layout, mx: u16, my: u16) -> bool {
let pantry_wp = layout
.waypoints
.iter()
.find(|w| matches!(w.kind, crate::tui::layout::WaypointKind::Pantry));
let Some(wp) = pantry_wp else {
return false;
};
let Size { w: cw, h: ch } = layout.pantry_counter_size;
let sprite_x = wp.pos.x.saturating_sub(cw / 2);
let sprite_y = wp.pos.y.saturating_sub(ch / 2);
let (coffee_x0, coffee_x1) = if cw >= 32 {
(sprite_x + 11, sprite_x + 18)
} else {
(sprite_x + 8, sprite_x + 13)
};
let coffee_y0 = sprite_y;
let coffee_y1 = sprite_y + ch;
let cell_y = my * 2;
mx >= coffee_x0 && mx < coffee_x1 && cell_y >= coffee_y0 && cell_y < coffee_y1
}
pub fn hit_test_furniture(layout: &Layout, mx: u16, my: u16) -> Option<&'static str> {
use crate::tui::layout::{
furniture_def, Furniture, PlantItem, PlantKind, PodDecor, PodDecorItem, WallDecor,
WallDecorItem, WaypointKind, DESK_H, DESK_W, ELEVATOR_H, ELEVATOR_W,
};
let visual = |f| furniture_def(f).visual;
let footprint = |f| furniture_def(f).footprint.unwrap_or(Size { w: 0, h: 0 });
let px = mx;
let py = my * 2;
let hit = |x: u16, y: u16, w: u16, h: u16| -> bool {
px >= x && px < x.saturating_add(w) && py >= y && py < y.saturating_add(h)
};
for desk in &layout.home_desks {
if hit(desk.x, desk.y, DESK_W + 2, DESK_H) {
return Some("Desk");
}
}
if let Some(c) = layout.couch_sprite_center {
if hit(c.x.saturating_sub(10), c.y.saturating_sub(3), 20, 7) {
return Some("Lounge Sofa");
}
}
for wp in &layout.waypoints {
let Size { w, h } = match wp.kind {
WaypointKind::Couch => continue,
WaypointKind::Pantry => layout.pantry_counter_size,
WaypointKind::MeetingSofa | WaypointKind::MeetingStand => continue,
other => match furniture_def(other.furniture()).footprint {
Some(fp) => fp,
None => continue,
},
};
let wx = wp.pos.x.saturating_sub(w / 2);
let wy = wp.pos.y.saturating_sub(h / 2);
if hit(wx, wy, w, h) {
return Some(match wp.kind {
WaypointKind::Pantry => "Pantry Counter",
WaypointKind::PhoneBooth => "Phone Booth",
WaypointKind::StandingDesk => "Standing Desk",
WaypointKind::VendingMachine => "Vending Machine",
WaypointKind::Printer => "Printer",
WaypointKind::Couch | WaypointKind::MeetingSofa | WaypointKind::MeetingStand => {
continue
}
});
}
}
for sofa in &layout.meeting_sofas {
let Size { w, h } = visual(Furniture::MeetingSofaBody); if hit(
sofa.x.saturating_sub(w / 2),
sofa.y.saturating_sub(h / 2),
w,
h,
) {
return Some("Meeting Sofa");
}
}
for t in &layout.meeting_tables {
let Size { w, h } = visual(Furniture::MeetingTable);
if hit(t.x.saturating_sub(w / 2), t.y.saturating_sub(h / 2), w, h) {
return Some("Meeting Table");
}
}
if let Some(t) = layout.pantry_table {
let Size { w, h } = footprint(Furniture::PantryTable);
if hit(t.x.saturating_sub(w / 2), t.y.saturating_sub(h / 2), w, h) {
return Some("Pantry Table");
}
}
for chair in &layout.pantry_chairs {
let Size { w, h } = footprint(Furniture::PantryChair); if hit(chair.x.saturating_sub(2), chair.y.saturating_sub(2), w, h) {
return Some("Chair");
}
}
for &PlantItem { kind, pos } in &layout.plants {
let Size { w, h } = visual(kind.furniture()); if hit(
pos.x.saturating_sub(w / 2),
pos.y.saturating_sub(h / 2),
w,
h,
) {
return Some(match kind {
PlantKind::Ficus => "Ficus",
PlantKind::Tall => "Tall Plant",
PlantKind::Flower => "Flower Pot",
PlantKind::Succulent => "Succulent",
});
}
}
if let Some(lamp) = layout.floor_lamp {
let Size { w, h } = visual(Furniture::FloorLamp); if hit(
lamp.x.saturating_sub(w / 2),
lamp.y.saturating_sub(h / 2),
w,
h,
) {
return Some("Floor Lamp");
}
}
for &WallDecorItem { kind, pos } in &layout.wall_decor {
let Size { w, h } = furniture_def(kind.furniture()).visual;
if hit(pos.x, pos.y, w, h) {
return Some(match kind {
WallDecor::Whiteboard => "Whiteboard",
WallDecor::Bookshelf => "Bookshelf",
WallDecor::BulletinBoard => "Bulletin Board",
WallDecor::ExitSign => "Exit Sign",
WallDecor::MeetingScreen => "Meeting Screen",
});
}
}
for &PodDecorItem { kind, pos } in &layout.pod_decor {
let Size { w, h } = furniture_def(kind.furniture()).visual;
if hit(
pos.x.saturating_sub(w / 2),
pos.y.saturating_sub(h / 2),
w,
h,
) {
return Some(match kind {
PodDecor::PlantTall => "Tall Plant",
PodDecor::Whiteboard => "Whiteboard",
PodDecor::Tv => "TV Stand",
PodDecor::PhoneBooth => "Phone Booth",
PodDecor::StandingDesk => "Standing Desk",
});
}
}
if let Some(t) = layout.lounge_side_table {
if hit(t.x.saturating_sub(3), t.y.saturating_sub(2), 7, 4) {
return Some("Side Table");
}
}
if let Some(mr) = layout.meeting_room {
if mr.width > 20 {
let cx = mr.x + mr.width - 5;
let cy = mr.y + mr.height / 2 - 4;
if hit(cx.saturating_sub(2), cy, 5, 8) {
return Some("Coat Rack");
}
}
if mr.width > 10 {
let mat_x = mr.x + mr.width + 1;
let mat_y = mr.y + mr.height / 2 - 2;
if hit(mat_x, mat_y, 4, 5) {
return Some("Doormat");
}
}
}
if let Some(pr) = layout.pantry_room {
if pr.height > 25 && pr.width > 12 {
let wx = pr.x + pr.width - 6;
let wy = pr.y + 8;
if hit(wx, wy, 3, 6) {
return Some("Water Cooler");
}
}
if pr.height > 20 {
let tx = pr.x + 3;
let ty = pr.y + pr.height - 14;
if hit(tx, ty, 4, 5) {
return Some("Trash Bin");
}
}
}
if let Some(d) = layout.door {
if hit(d.x, d.y, ELEVATOR_W, ELEVATOR_H) {
return Some("Elevator");
}
}
None
}
pub fn hit_test_pet(
kind: PetKind,
pet_pos: crate::tui::layout::Point,
anim_name: &str,
mx: u16,
my: u16,
) -> bool {
let Size { w, h } = kind.hitbox(anim_name);
let tl_x = pet_pos.x.saturating_sub(w / 2);
let tl_y = pet_pos.y.saturating_sub(h / 2);
let cell_y = my * 2;
mx >= tl_x && mx < tl_x.saturating_add(w) && cell_y >= tl_y && cell_y < tl_y.saturating_add(h)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn coffee_machine_hit_test_returns_false_for_origin() {
let layout = Layout::compute(160, 200, 4).expect("layout");
assert!(!hit_test_coffee_machine(&layout, 0, 0));
}
#[test]
fn coffee_machine_hit_test_returns_true_for_machine_area() {
let layout = Layout::compute(160, 200, 4).expect("layout");
let pantry_wp = layout
.waypoints
.iter()
.find(|w| w.kind == crate::tui::layout::WaypointKind::Pantry)
.expect("pantry");
let Size { w: cw, h: ch } = layout.pantry_counter_size;
let sprite_x = pantry_wp.pos.x.saturating_sub(cw / 2);
let sprite_y = pantry_wp.pos.y.saturating_sub(ch / 2);
let mid_x = if cw >= 32 {
sprite_x + 14
} else {
sprite_x + 10
};
let mid_cell_y = (sprite_y + ch / 2) / 2;
assert!(
hit_test_coffee_machine(&layout, mid_x, mid_cell_y),
"expected hit at coffee machine area ({mid_x}, {mid_cell_y})"
);
}
#[test]
fn furniture_hit_test_returns_none_for_empty_space() {
let layout = Layout::compute(160, 200, 4).expect("layout");
let empty = (0..(layout.buf_h / 2))
.flat_map(|cy| (0..layout.buf_w).map(move |cx| (cx, cy)))
.find(|&(cx, cy)| hit_test_furniture(&layout, cx, cy).is_none())
.expect("some open-floor cell must report no furniture");
assert_eq!(hit_test_furniture(&layout, empty.0, empty.1), None);
}
#[test]
fn furniture_hit_test_finds_desk() {
let layout = Layout::compute(160, 200, 4).expect("layout");
let desk = layout.home_desks.first().expect("desk");
let cell_y = (desk.y + 2) / 2;
assert_eq!(
hit_test_furniture(&layout, desk.x + 2, cell_y),
Some("Desk")
);
}
#[test]
fn furniture_hit_test_finds_elevator() {
let layout = Layout::compute(160, 200, 4).expect("layout");
let door = layout.door.expect("door");
let cell_y = (door.y + 7) / 2;
assert_eq!(
hit_test_furniture(&layout, door.x + 8, cell_y),
Some("Elevator")
);
}
#[test]
fn furniture_hit_test_finds_meeting_table() {
let layout = Layout::compute(160, 200, 4).expect("layout");
let table = layout.meeting_tables.first().expect("table");
let cell_y = table.y / 2;
assert_eq!(
hit_test_furniture(&layout, table.x, cell_y),
Some("Meeting Table")
);
}
#[test]
fn furniture_hit_test_respects_floor_seed() {
let layout1 = Layout::compute_with_seed(160, 200, 4, 1).expect("layout");
assert!(layout1.meeting_tables.is_empty());
let layout0 = Layout::compute(160, 200, 4).expect("layout");
if let Some(table) = layout0.meeting_tables.first() {
let cell_y = table.y / 2;
assert_ne!(
hit_test_furniture(&layout1, table.x, cell_y),
Some("Meeting Table"),
);
}
}
#[test]
fn cat_hit_test_inside_sit_sprite() {
use crate::tui::layout::Point;
let pos = Point { x: 50, y: 80 };
assert!(hit_test_pet(PetKind::Cat, pos, "cat_sit", 50, 39));
}
#[test]
fn cat_hit_test_outside_returns_false() {
use crate::tui::layout::Point;
let pos = Point { x: 50, y: 80 };
assert!(!hit_test_pet(PetKind::Cat, pos, "cat_sit", 10, 10));
}
fn scene_with_agent_at_desk(desk_index: usize) -> (SceneState, AgentId) {
use pixtuoid_core::state::{ActivityState, AgentSlot, GlobalDeskIndex};
use std::path::Path;
use std::sync::Arc;
let id = AgentId::from_transcript_path("/pin/0.jsonl");
let slot = AgentSlot {
agent_id: id,
source: Arc::from("cc"),
session_id: Arc::from("s"),
cwd: Arc::from(Path::new("/repo")),
label: Arc::from("a"),
state: ActivityState::Idle,
state_started_at: SystemTime::UNIX_EPOCH,
created_at: SystemTime::UNIX_EPOCH,
last_event_at: SystemTime::UNIX_EPOCH,
exiting_at: None,
pending_idle_at: None,
desk_index: GlobalDeskIndex(desk_index),
floor_idx: 0,
tool_call_count: 0,
active_ms: 0,
unknown_cwd: false,
parent_id: None,
};
let mut scene = SceneState::uniform(16);
scene.agents.insert(id, slot);
(scene, id)
}
#[test]
fn from_tui_hits_agent_at_its_desk_anchor() {
let layout = Layout::compute(160, 200, 4).expect("layout");
let (scene, id) = scene_with_agent_at_desk(0);
let d = layout.home_desks[0];
let cx = d.x + 1;
let cy = d.y.saturating_sub(4) / 2;
assert_eq!(hit_test_from_tui(&scene, &layout, cx, cy), Some(id));
}
#[test]
fn from_tui_misses_empty_space() {
let layout = Layout::compute(160, 200, 4).expect("layout");
let (scene, _id) = scene_with_agent_at_desk(0);
assert_eq!(hit_test_from_tui(&scene, &layout, 0, 0), None);
}
#[test]
fn from_tui_skips_agent_with_out_of_range_desk() {
let layout = Layout::compute(160, 200, 4).expect("layout");
let (scene, _id) = scene_with_agent_at_desk(layout.home_desks.len() + 100);
for &(mx, my) in &[(0u16, 0u16), (40, 20), (80, 40)] {
assert_eq!(hit_test_from_tui(&scene, &layout, mx, my), None);
}
}
#[test]
fn from_tui_oob_desk_at_capacity_boundary_does_not_wrap_to_desk_zero() {
use pixtuoid_core::state::GlobalDeskIndex;
let layout = Layout::compute(160, 200, 4).expect("layout");
let (mut scene, id) = scene_with_agent_at_desk(0);
let cap = scene.floor_capacities[0];
scene.agents.get_mut(&id).expect("slot").desk_index = GlobalDeskIndex(cap);
let desk0 = layout.home_desks[0];
let (ax, ay) = (desk0.x + 1, desk0.y.saturating_sub(4) / 2);
for dx in 0..8u16 {
for dy in 0..6u16 {
assert_eq!(
hit_test_from_tui(&scene, &layout, ax + dx, ay + dy),
None,
"an OOB desk at the capacity boundary must never hit-test"
);
}
}
}
#[test]
fn furniture_hit_test_ficus_via_synthetic_plant() {
use crate::tui::layout::Point;
let mut layout = Layout::compute(160, 200, 4).expect("layout");
let pos = Point { x: 40, y: 40 };
layout.plants.push(crate::tui::layout::PlantItem {
kind: crate::tui::layout::PlantKind::Ficus,
pos,
});
assert_eq!(hit_test_furniture(&layout, pos.x, pos.y / 2), Some("Ficus"));
}
#[test]
fn furniture_hit_test_bulletin_board_via_synthetic_wall_decor() {
use crate::tui::layout::Point;
let mut layout = Layout::compute(160, 200, 4).expect("layout");
let pos = Point { x: 60, y: 30 };
layout.wall_decor.push(crate::tui::layout::WallDecorItem {
kind: crate::tui::layout::WallDecor::BulletinBoard,
pos,
});
assert_eq!(
hit_test_furniture(&layout, pos.x, pos.y / 2),
Some("Bulletin Board")
);
}
#[test]
fn cat_hit_test_sleep_smaller_box() {
use crate::tui::layout::Point;
let pos = Point { x: 50, y: 80 };
assert!(!hit_test_pet(PetKind::Cat, pos, "cat_sleep", 50, 41));
assert!(hit_test_pet(PetKind::Cat, pos, "cat_sleep", 50, 40));
}
}