diff --git a/src/renderer/src/pages/OfficePage.tsx b/src/renderer/src/pages/OfficePage.tsx index 0af0b06a..8453567c 100644 --- a/src/renderer/src/pages/OfficePage.tsx +++ b/src/renderer/src/pages/OfficePage.tsx @@ -4,6 +4,7 @@ import { activeOrgId, requireActiveEmployee } from "./types"; import { hashString, renderOffice, type RenderPerson } from "./office/office-renderer"; import { OfficeSimulation, cameraFor, type MoveInput } from "./office/simulation"; import { AgentDetailCard } from "./office/AgentDetailCard"; +import { cardPositionFor, CARD_FALLBACK_H } from "./office/agent-card-position"; import type { AgentActivity, Camera, OfficeAgentInput, Vec } from "./office/office-types"; const FPS_INTERVAL = 1000 / 24; @@ -26,6 +27,7 @@ export function OfficePage({ snapshot }: PageProps) { const keysRef = useRef({ up: false, down: false, left: false, right: false }); const cameraRef = useRef({ x: 0, y: 0 }); const selectedIdRef = useRef(null); + const cardBoxRef = useRef(null); const [selectedId, setSelectedId] = useState(null); const simRef = useRef(null); if (simRef.current === null) simRef.current = new OfficeSimulation(); @@ -164,6 +166,18 @@ export function OfficePage({ snapshot }: PageProps) { }); } renderOffice(ctx, viewW, viewH, sim.plan, camera, people, now, new Date(), selectedIdRef.current); + + // The detail card rides beside its agent, even while they walk. + const cardBox = cardBoxRef.current; + const selected = selectedIdRef.current + ? sim.agents.find((agent) => agent.input.id === selectedIdRef.current) ?? null + : null; + if (cardBox && selected) { + const cardH = cardBox.offsetHeight || CARD_FALLBACK_H; + const position = cardPositionFor({ x: selected.x, y: selected.y }, camera, viewW, viewH, cardH); + cardBox.style.transform = `translate(${position.left}px, ${position.top}px)`; + cardBox.style.visibility = "visible"; + } }; raf = requestAnimationFrame(tick); @@ -205,7 +219,11 @@ export function OfficePage({ snapshot }: PageProps) { onMouseMove={onCanvasMouseMove} style={{ width: "100%", height: "100%", display: "block" }} /> - {selectedAgent && setSelectedId(null)} />} + {selectedAgent && ( +
+ setSelectedId(null)} /> +
+ )} ); } diff --git a/src/renderer/src/pages/office/AgentDetailCard.tsx b/src/renderer/src/pages/office/AgentDetailCard.tsx index 012a3088..833d0d23 100644 --- a/src/renderer/src/pages/office/AgentDetailCard.tsx +++ b/src/renderer/src/pages/office/AgentDetailCard.tsx @@ -17,16 +17,16 @@ const ACTIVITY_TONE: Record void }) { return (
viewW - MARGIN) left = screenX - CARD_W - GAP; + left = clamp(left, MARGIN, Math.max(MARGIN, viewW - CARD_W - MARGIN)); + const top = clamp(screenY - cardH / 2, MARGIN, Math.max(MARGIN, viewH - cardH - MARGIN)); + return { left, top }; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} diff --git a/src/renderer/src/pages/office/floor-plan.ts b/src/renderer/src/pages/office/floor-plan.ts index e1d3e252..6b37686d 100644 --- a/src/renderer/src/pages/office/floor-plan.ts +++ b/src/renderer/src/pages/office/floor-plan.ts @@ -58,6 +58,12 @@ const AMENITIES: AmenitySpot[] = [ { kind: "restroom", room: "restroom", stand: { x: 1084, y: 104 } }, { kind: "restroom", room: "restroom", stand: { x: 1124, y: 104 } }, { kind: "restroom", room: "restroom", stand: { x: 1164, y: 104 } }, + // Standing spots along the south edge of the meeting table; meetings are + // group events the simulation convenes with at least two attendees. + { kind: "meeting", room: "meeting", stand: { x: 412, y: 198 } }, + { kind: "meeting", room: "meeting", stand: { x: 452, y: 198 } }, + { kind: "meeting", room: "meeting", stand: { x: 492, y: 198 } }, + { kind: "meeting", room: "meeting", stand: { x: 532, y: 198 } }, ]; interface Segment { diff --git a/src/renderer/src/pages/office/office-types.ts b/src/renderer/src/pages/office/office-types.ts index a4ffe1db..2fa9d3c4 100644 --- a/src/renderer/src/pages/office/office-types.ts +++ b/src/renderer/src/pages/office/office-types.ts @@ -30,7 +30,7 @@ export interface DeskSlot { seat: Vec; } -export type AmenityKind = "coffee" | "snack" | "restroom"; +export type AmenityKind = "coffee" | "snack" | "restroom" | "meeting"; export interface AmenitySpot { kind: AmenityKind; diff --git a/src/renderer/src/pages/office/simulation.ts b/src/renderer/src/pages/office/simulation.ts index 05202e30..9a1741e6 100644 --- a/src/renderer/src/pages/office/simulation.ts +++ b/src/renderer/src/pages/office/simulation.ts @@ -44,12 +44,17 @@ const PLAYER_SPEED = 0.14; const AGENT_SPEED = 0.085; const MAX_STEP_MS = 100; const WALK_CYCLE = 0.02; -/** An idle agent wanders off roughly every minute and a half. */ +/** Everyone wanders off roughly every minute and a half, working or not. */ const MEAN_TRIP_INTERVAL_MS = 90_000; /** Nobody leaves their desk in the first moments after settling in. */ const SETTLE_MS = 20_000; const DWELL_MIN_MS = 6_000; const DWELL_SPREAD_MS = 8_000; +/** Meetings run longer than a snack stop so attendees visibly overlap. */ +const MEETING_MIN_MS = 18_000; +const MEETING_SPREAD_MS = 12_000; +/** A meeting is convened somewhere in the office every few minutes. */ +const MEAN_MEETING_INTERVAL_MS = 180_000; const CARRY_MIN_MS = 20_000; const CARRY_SPREAD_MS = 20_000; @@ -93,14 +98,16 @@ export class OfficeSimulation { } /** - * Advances the world by one frame: board-member movement plus agent life - - * idle agents wander to the restroom, snack bar, or coffee machine, dwell, - * and carry their coffee back; working agents head straight to their desk. + * Advances the world by one frame: board-member movement plus agent life. + * Everyone — the CEO and working agents included — periodically wanders to + * the restroom, snack bar, or coffee machine, and every few minutes the + * office convenes a meeting of at least two attendees around the table. */ step(dtMs: number, input: MoveInput): void { const dt = Math.min(dtMs, MAX_STEP_MS); if (this.player) this.stepPlayer(this.player, dt, input); for (const agent of this.agents) this.stepAgent(agent, dt); + if (this.random() < dt / MEAN_MEETING_INTERVAL_MS) this.conveneMeeting(); } private stepAgent(agent: AgentSim, dt: number): void { @@ -111,13 +118,8 @@ export class OfficeSimulation { agent.carrying = null; } } - const idle = agent.input.activity === "idle"; switch (agent.behavior.kind) { case "seated": { - if (!idle) { - agent.settledMs = 0; - return; - } agent.settledMs += dt; if (agent.settledMs >= SETTLE_MS && this.random() < dt / MEAN_TRIP_INTERVAL_MS) { agent.settledMs = 0; @@ -126,18 +128,10 @@ export class OfficeSimulation { return; } case "walking": { - if (!idle && agent.behavior.destination === "amenity") { - this.sendHome(agent); - return; - } this.walkAlong(agent, dt); return; } case "dwelling": { - if (!idle) { - this.finishDwell(agent); - return; - } agent.behavior.remainingMs -= dt; if (agent.behavior.remainingMs <= 0) this.finishDwell(agent); return; @@ -146,7 +140,10 @@ export class OfficeSimulation { } private startTrip(agent: AgentSim): void { - const spot = this.pickAmenity(); + this.walkTo(agent, this.pickAmenity()); + } + + private walkTo(agent: AgentSim, spot: AmenitySpot): void { agent.behavior = { kind: "walking", path: findPath(this.plan, { x: agent.x, y: agent.y }, spot.stand), @@ -156,6 +153,7 @@ export class OfficeSimulation { }; } + /** Solo breaks pick a personal amenity; meetings are convened separately. */ private pickAmenity(): AmenitySpot { const roll = this.random(); const kind = roll < 0.4 ? "coffee" : roll < 0.65 ? "snack" : "restroom"; @@ -163,6 +161,31 @@ export class OfficeSimulation { return options[Math.floor(this.random() * options.length) % options.length]; } + /** + * Gathers a group around the meeting table. A meeting needs at least two + * settled attendees; with fewer, nobody goes to sit alone in the room. + * Returns the number of attendees who set off. + */ + conveneMeeting(): number { + const spots = this.plan.amenities.filter((spot) => spot.kind === "meeting"); + const candidates = this.agents.filter( + (agent) => agent.behavior.kind === "seated" && agent.settledMs >= SETTLE_MS, + ); + if (candidates.length < 2 || spots.length < 2) return 0; + const size = Math.min(spots.length, candidates.length, 2 + Math.floor(this.random() * 3)); + const pool = [...candidates]; + const chosen: AgentSim[] = []; + while (chosen.length < size && pool.length > 0) { + const index = Math.floor(this.random() * pool.length) % pool.length; + chosen.push(pool.splice(index, 1)[0]); + } + chosen.forEach((agent, position) => { + agent.settledMs = 0; + this.walkTo(agent, spots[position]); + }); + return chosen.length; + } + private finishDwell(agent: AgentSim): void { if (agent.behavior.kind === "dwelling") { const kind = agent.behavior.spot.kind; @@ -215,11 +238,14 @@ export class OfficeSimulation { if (agent.behavior.kind !== "walking") return; agent.walkPhase = 0; if (agent.behavior.destination === "amenity" && agent.behavior.spot) { + const meeting = agent.behavior.spot.kind === "meeting"; agent.facing = "north"; agent.behavior = { kind: "dwelling", spot: agent.behavior.spot, - remainingMs: DWELL_MIN_MS + this.random() * DWELL_SPREAD_MS, + remainingMs: meeting + ? MEETING_MIN_MS + this.random() * MEETING_SPREAD_MS + : DWELL_MIN_MS + this.random() * DWELL_SPREAD_MS, }; return; } diff --git a/tests/e2e/office.spec.ts b/tests/e2e/office.spec.ts index 5ac4416e..4b39e649 100644 --- a/tests/e2e/office.spec.ts +++ b/tests/e2e/office.spec.ts @@ -60,6 +60,16 @@ test("the office floor opens agent details on click", async () => { await expect(card.getByText("Chief Executive Officer")).toBeVisible(); await expect(card.getByText("Executive", { exact: true })).toBeVisible(); + // The card sits beside the clicked agent, not pinned to a screen corner. + const cardBox = await card.boundingBox(); + expect(cardBox).not.toBeNull(); + const gapToSprite = Math.min( + Math.abs(cardBox!.x - clickX), + Math.abs(cardBox!.x + cardBox!.width - clickX), + ); + expect(gapToSprite).toBeLessThan(80); + expect(Math.abs(cardBox!.y + cardBox!.height / 2 - clickY)).toBeLessThan(160); + await page.keyboard.press("Escape"); await expect(card).not.toBeVisible(); diff --git a/tests/unit/office-card-position.test.ts b/tests/unit/office-card-position.test.ts new file mode 100644 index 00000000..f462ec2d --- /dev/null +++ b/tests/unit/office-card-position.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "vitest"; +import { cardPositionFor, CARD_W } from "../../src/renderer/src/pages/office/agent-card-position"; + +describe("agent detail card placement", () => { + test("sits to the right of the sprite, vertically centered on it", () => { + const position = cardPositionFor({ x: 400, y: 300 }, { x: 0, y: 0 }, 1200, 800, 170); + expect(position.left).toBe(418); + expect(position.top).toBe(300 - 16 - 85); + }); + + test("flips to the left when the right edge would leave the viewport", () => { + const position = cardPositionFor({ x: 1100, y: 300 }, { x: 0, y: 0 }, 1200, 800, 170); + expect(position.left).toBe(1100 - CARD_W - 18); + expect(position.left + CARD_W).toBeLessThanOrEqual(1200 - 8); + }); + + test("clamps fully on screen near every edge", () => { + for (const agent of [{ x: 5, y: 5 }, { x: 5, y: 795 }, { x: 1195, y: 5 }, { x: 1195, y: 795 }]) { + const position = cardPositionFor(agent, { x: 0, y: 0 }, 1200, 800, 170); + expect(position.left).toBeGreaterThanOrEqual(8); + expect(position.left + CARD_W).toBeLessThanOrEqual(1192); + expect(position.top).toBeGreaterThanOrEqual(8); + expect(position.top + 170).toBeLessThanOrEqual(792); + } + }); + + test("accounts for the camera offset", () => { + const onScreen = cardPositionFor({ x: 700, y: 500 }, { x: 300, y: 200 }, 1200, 800, 170); + const noCamera = cardPositionFor({ x: 400, y: 300 }, { x: 0, y: 0 }, 1200, 800, 170); + expect(onScreen).toEqual(noCamera); + }); +}); diff --git a/tests/unit/office-floor-plan.test.ts b/tests/unit/office-floor-plan.test.ts index fcca49ca..ece6fe73 100644 --- a/tests/unit/office-floor-plan.test.ts +++ b/tests/unit/office-floor-plan.test.ts @@ -30,6 +30,8 @@ describe("office floor plan", () => { expect(plan.amenities.filter((a) => a.kind === "restroom").length).toBeGreaterThanOrEqual(2); expect(plan.amenities.some((a) => a.kind === "coffee")).toBe(true); expect(plan.amenities.some((a) => a.kind === "snack")).toBe(true); + // Meetings need at least two attendees, so the table has spots for them. + expect(plan.amenities.filter((a) => a.kind === "meeting").length).toBeGreaterThanOrEqual(2); }); test("desk field grows by rows so no head-count is ever cut off", () => { diff --git a/tests/unit/office-simulation.test.ts b/tests/unit/office-simulation.test.ts index 79b52c25..7fed5490 100644 --- a/tests/unit/office-simulation.test.ts +++ b/tests/unit/office-simulation.test.ts @@ -121,30 +121,72 @@ describe("office simulation", () => { expect(agent.carrying).toBe("coffee"); }); - test("working agents never leave their desks", () => { + test("working agents (and the CEO) take breaks too", () => { const sim = new OfficeSimulation(() => 0); - sim.sync([agentInput("a", "staff", "active")]); + sim.sync([agentInput("ceo", "c_suite", "active")]); const agent = sim.agents[0]; - for (let i = 0; i < 600; i++) sim.step(16, IDLE); - expect(agent.behavior.kind).toBe("seated"); - expect(agent.x).toBe(agent.seat.x); - expect(agent.y).toBe(agent.seat.y); + let leftTheDesk = false; + for (let i = 0; i < 3000 && !leftTheDesk; i++) { + sim.step(16, IDLE); + if (agent.behavior.kind !== "seated") leftTheDesk = true; + } + expect(leftTheDesk).toBe(true); }); - test("an agent called to work abandons the break and returns to their desk", () => { + test("a snapshot refresh mid-break never recalls the walker", () => { const sim = new OfficeSimulation(() => 0); sim.sync([agentInput("a")]); const agent = sim.agents[0]; for (let i = 0; i < 3000 && agent.behavior.kind === "seated"; i++) sim.step(16, IDLE); for (let i = 0; i < 30; i++) sim.step(16, IDLE); expect(agent.behavior.kind).toBe("walking"); - expect(agent.x === agent.seat.x && agent.y === agent.seat.y).toBe(false); + // Becoming busy does not cancel the human ritual of a coffee run. sim.sync([agentInput("a", "staff", "active")]); - for (let i = 0; i < 6000 && sim.agents[0].behavior.kind !== "seated"; i++) sim.step(16, IDLE); + const refreshed = sim.agents[0]; + expect(refreshed.behavior.kind).toBe("walking"); + let dwelled = false; + for (let i = 0; i < 6000 && !dwelled; i++) { + sim.step(16, IDLE); + if (refreshed.behavior.kind === "dwelling") dwelled = true; + } + expect(dwelled).toBe(true); + }); + + test("meetings convene at least two attendees around the table, then everyone returns", () => { + const sim = new OfficeSimulation(() => 0); + sim.sync([agentInput("a", "staff", "active"), agentInput("b"), agentInput("ceo", "c_suite", "active")]); + // Nobody has settled yet: a meeting cannot be convened. + expect(sim.conveneMeeting()).toBe(0); + for (const agent of sim.agents) agent.settledMs = 30_000; + + const attendees = sim.conveneMeeting(); + expect(attendees).toBeGreaterThanOrEqual(2); + const walkers = sim.agents.filter((agent) => agent.behavior.kind === "walking"); + expect(walkers).toHaveLength(attendees); + const spots = new Set( + walkers.map((agent) => (agent.behavior.kind === "walking" ? `${agent.behavior.spot?.stand.x}` : "")), + ); + expect(spots.size).toBe(attendees); + + // Everyone reaches the meeting room, dwells together, and heads home. + let overlapped = false; + for (let i = 0; i < 12000 && !overlapped; i++) { + sim.step(16, IDLE); + const dwellingInMeeting = sim.agents.filter( + (agent) => agent.behavior.kind === "dwelling" && agent.behavior.spot.kind === "meeting", + ); + if (dwellingInMeeting.length >= 2) overlapped = true; + } + expect(overlapped).toBe(true); + }); + + test("a solo office never convenes a meeting", () => { + const sim = new OfficeSimulation(() => 0); + sim.sync([agentInput("a")]); + sim.agents[0].settledMs = 30_000; + expect(sim.conveneMeeting()).toBe(0); expect(sim.agents[0].behavior.kind).toBe("seated"); - expect(sim.agents[0].x).toBe(sim.agents[0].seat.x); - expect(sim.agents[0].y).toBe(sim.agents[0].seat.y); }); test("clicks hit the agent drawn at that spot, frontmost first", () => {