From ace76dabc7f7b7955cc94ad3da16fd27e3d1d9c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 11:43:30 -0400 Subject: [PATCH] Q207: office positions follow real state: only idle agents leave their desks; the pretend whip is gone Co-Authored-By: Claude Opus 5.5 --- src/renderer/src/i18n/zh.ts | 3 +- src/renderer/src/pages/OfficePage.tsx | 39 +----- .../src/pages/office/office-renderer.ts | 73 +---------- src/renderer/src/pages/office/office-types.ts | 12 -- src/renderer/src/pages/office/simulation.ts | 74 ++--------- src/renderer/src/styles/components.css | 16 +-- tests/e2e/office.spec.ts | 16 +-- tests/unit/office-simulation.test.ts | 116 ++++-------------- 8 files changed, 45 insertions(+), 304 deletions(-) diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 74dcb85a..51834e2f 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -342,9 +342,8 @@ export const zh: Record = { "Avatar image file": "头像图片文件", "That file could not be read as an image.": "无法将该文件读取为图片。", "Conversation messages": "会话消息", - "Whip": "鞭子", "Office tools": "办公室工具", - "Crack the whip: slackers in the snack bar or restroom hustle back to their desks. Working or meeting agents are unaffected.": "挥动鞭子:在茶水间或洗手间摸鱼的成员会赶回工位;正在工作或开会的成员不受影响。", + "Agents with work stay at their desks. Only idle agents walk to coffee, snacks or the meeting table.": "有工作的智能体会一直待在工位;只有空闲的智能体才会去咖啡机、零食区或会议桌。", "Avatar": "头像", "Avatar emoji": "头像表情", "Avatar color": "头像颜色", diff --git a/src/renderer/src/pages/OfficePage.tsx b/src/renderer/src/pages/OfficePage.tsx index 01f8b04d..bf6d2394 100644 --- a/src/renderer/src/pages/OfficePage.tsx +++ b/src/renderer/src/pages/OfficePage.tsx @@ -32,8 +32,6 @@ export function OfficePage({ snapshot }: PageProps) { const selectedIdRef = useRef(null); const cardBoxRef = useRef(null); const [selectedId, setSelectedId] = useState(null); - const [tool, setTool] = useState<"select" | "whip">("select"); - const toolRef = useRef(tool); const simRef = useRef(null); if (simRef.current === null) simRef.current = new OfficeSimulation(); @@ -64,15 +62,10 @@ export function OfficePage({ snapshot }: PageProps) { selectedIdRef.current = selectedId; }, [selectedId]); - useEffect(() => { - toolRef.current = tool; - }, [tool]); - useEffect(() => { const onEscape = (event: KeyboardEvent) => { if (event.key !== "Escape") return; - if (toolRef.current === "whip") setTool("select"); - else if (selectedIdRef.current) setSelectedId(null); + if (selectedIdRef.current) setSelectedId(null); }; window.addEventListener("keydown", onEscape); return () => window.removeEventListener("keydown", onEscape); @@ -158,7 +151,6 @@ export function OfficePage({ snapshot }: PageProps) { facing: agent.facing, walkPhase: agent.walkPhase, carrying: agent.carrying, - hitReactMs: agent.hitReactMs, })); if (sim.player) { people.push({ @@ -175,10 +167,9 @@ export function OfficePage({ snapshot }: PageProps) { facing: sim.player.facing, walkPhase: sim.player.walkPhase, carrying: null, - hitReactMs: 0, }); } - renderOffice(ctx, viewW, viewH, sim.plan, camera, people, now, new Date(), selectedIdRef.current, sim.effects); + 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; @@ -214,10 +205,6 @@ export function OfficePage({ snapshot }: PageProps) { const point = worldPointFrom(event); if (!point) return; const hit = simRef.current!.hitTest(point); - if (tool === "whip") { - if (hit) simRef.current!.whip(hit.input.id); - return; - } setSelectedId(hit ? hit.input.id : null); }; @@ -225,10 +212,6 @@ export function OfficePage({ snapshot }: PageProps) { const canvas = canvasRef.current; const point = worldPointFrom(event); if (!canvas || !point) return; - if (tool === "whip") { - canvas.style.cursor = "crosshair"; - return; - } canvas.style.cursor = simRef.current!.hitTest(point) ? "pointer" : "default"; }; @@ -252,27 +235,13 @@ export function OfficePage({ snapshot }: PageProps) { className="input" aria-label={t("Show a person")} value={selectedId ?? ""} - onChange={(event) => { - setTool("select"); - setSelectedId(event.target.value || null); - }} + onChange={(event) => setSelectedId(event.target.value || null)} style={{ width: 200, height: 30 }} > {inputs.map((input) => )} - + {t("Agents with work stay at their desks. Only idle agents walk to coffee, snacks or the meeting table.")} ); diff --git a/src/renderer/src/pages/office/office-renderer.ts b/src/renderer/src/pages/office/office-renderer.ts index 562470f5..183f41d9 100644 --- a/src/renderer/src/pages/office/office-renderer.ts +++ b/src/renderer/src/pages/office/office-renderer.ts @@ -1,4 +1,4 @@ -import { HIT_REACT_MS, WHIP_CRACK_MS, type AgentActivity, type Camera, type FloorPlan, type Rect, type WhipCrackEffect } from "./office-types"; +import type { AgentActivity, Camera, FloorPlan, Rect } from "./office-types"; import { deskScreenRect, drawWorld } from "./scene-renderer"; import { skyScene } from "./sky"; import { drawPerson, type Carrying, type Facing, type SpriteAppearance } from "./sprites"; @@ -17,8 +17,6 @@ export interface RenderPerson { facing: Facing; walkPhase: number; carrying: Carrying; - /** Remaining flinch time after a whip strike; 0 when unbothered. */ - hitReactMs: number; } const ACTIVITY_DOT: Partial> = { @@ -47,7 +45,6 @@ export function renderOffice( timeMs: number, clock: Date, selectedId: string | null, - effects: WhipCrackEffect[] = [], ): void { ctx.fillStyle = "#14161c"; ctx.fillRect(0, 0, viewW, viewH); @@ -80,10 +77,7 @@ export function renderOffice( ctx.stroke(); } const working = (person.activity === "active" || person.activity === "thinking") && person.walkPhase === 0; - // A struck agent shakes for the flinch duration, hardest right after the hit. - const flinchStrength = person.hitReactMs > 0 ? person.hitReactMs / HIT_REACT_MS : 0; - const shakeX = flinchStrength > 0 ? Math.sin(person.hitReactMs * 0.09) * 2.5 * flinchStrength : 0; - drawPerson(ctx, person.x + shakeX, person.y, person.appearance, { + drawPerson(ctx, person.x, person.y, person.appearance, { facing: person.facing, walkPhase: person.walkPhase, working, @@ -91,7 +85,6 @@ export function renderOffice( timeMs, animSeed: person.animSeed, }, person.isCeo); - if (flinchStrength > 0) drawPainMarks(ctx, person.x + shakeX, person.y, flinchStrength); const dot = ACTIVITY_DOT[person.activity]; if (dot) { ctx.fillStyle = dot; @@ -101,72 +94,10 @@ export function renderOffice( } } for (const person of ordered) drawNameLabel(ctx, person); - for (const effect of effects) drawWhipCrack(ctx, effect); ctx.restore(); } -/** Anime-style pain sparks over a flinching agent's head. */ -function drawPainMarks(ctx: CanvasRenderingContext2D, x: number, y: number, strength: number): void { - ctx.save(); - ctx.globalAlpha = Math.min(1, strength * 1.3); - ctx.strokeStyle = "#ff5d5d"; - ctx.lineWidth = 1.5; - const headY = y - 42; - for (const [dx, angle] of [[-9, 2.4], [9, 0.7], [0, 1.55]] as Array<[number, number]>) { - const cx = x + dx; - const cy = headY - Math.abs(dx) * 0.2; - ctx.beginPath(); - ctx.moveTo(cx - Math.cos(angle) * 3, cy - Math.sin(angle) * 3); - ctx.lineTo(cx + Math.cos(angle) * 3, cy + Math.sin(angle) * 3); - ctx.stroke(); - } - ctx.restore(); -} - -/** - * The strike itself: a lash sweeping in from the upper right that collapses - * into an impact starburst with an expanding ring, all gone in under half a - * second. - */ -function drawWhipCrack(ctx: CanvasRenderingContext2D, effect: WhipCrackEffect): void { - const progress = Math.min(1, effect.ageMs / WHIP_CRACK_MS); - ctx.save(); - - if (progress < 0.4) { - // Lash phase: a tapered stroke whipping down toward the target. - const sweep = progress / 0.4; - const startX = effect.x + 34 - sweep * 10; - const startY = effect.y - 34 + sweep * 6; - ctx.strokeStyle = "#ffe9a3"; - ctx.lineWidth = 2.5 - sweep; - ctx.globalAlpha = 0.95; - ctx.beginPath(); - ctx.moveTo(startX, startY); - ctx.quadraticCurveTo(effect.x + 18 - sweep * 6, effect.y - 26, effect.x + sweep * 2, effect.y - sweep * 2); - ctx.stroke(); - } - - // Impact starburst, brightest at contact, shrinking as it fades. - const fade = 1 - progress; - const reach = 5 + progress * 9; - ctx.globalAlpha = fade; - ctx.strokeStyle = progress < 0.25 ? "#ffffff" : "#ffd75e"; - ctx.lineWidth = 1.5; - for (let ray = 0; ray < 6; ray++) { - const angle = (Math.PI / 3) * ray + 0.4; - ctx.beginPath(); - ctx.moveTo(effect.x + Math.cos(angle) * 3, effect.y + Math.sin(angle) * 3); - ctx.lineTo(effect.x + Math.cos(angle) * reach, effect.y + Math.sin(angle) * reach); - ctx.stroke(); - } - ctx.globalAlpha = fade * 0.55; - ctx.beginPath(); - ctx.arc(effect.x, effect.y, 4 + progress * 15, 0, Math.PI * 2); - ctx.stroke(); - ctx.restore(); -} - function drawNameLabel(ctx: CanvasRenderingContext2D, person: RenderPerson): void { ctx.font = "600 9px -apple-system, 'Segoe UI', sans-serif"; const width = Math.ceil(ctx.measureText(person.name).width) + 10; diff --git a/src/renderer/src/pages/office/office-types.ts b/src/renderer/src/pages/office/office-types.ts index e6cb762d..2fa9d3c4 100644 --- a/src/renderer/src/pages/office/office-types.ts +++ b/src/renderer/src/pages/office/office-types.ts @@ -61,18 +61,6 @@ export interface FloorPlan { entrance: Vec; } -/** A whip crack in flight: drawn at the strike point, fading as it ages. */ -export interface WhipCrackEffect { - x: number; - y: number; - ageMs: number; -} - -/** How long the crack flash stays on screen. */ -export const WHIP_CRACK_MS = 450; -/** How long a struck agent visibly flinches. */ -export const HIT_REACT_MS = 600; - export type AgentActivity = "active" | "thinking" | "busy" | "idle"; /** Snapshot-derived facts the office simulation needs about one agent. */ diff --git a/src/renderer/src/pages/office/simulation.ts b/src/renderer/src/pages/office/simulation.ts index f9ea2440..bb02c79f 100644 --- a/src/renderer/src/pages/office/simulation.ts +++ b/src/renderer/src/pages/office/simulation.ts @@ -1,5 +1,5 @@ -import { buildFloorPlan, findPath, rectContains } from "./floor-plan"; -import { HIT_REACT_MS, WHIP_CRACK_MS, type AmenitySpot, type Camera, type FloorPlan, type OfficeAgentInput, type Rect, type RoomKind, type Vec, type WhipCrackEffect } from "./office-types"; +import { buildFloorPlan, findPath } from "./floor-plan"; +import type { AmenitySpot, Camera, FloorPlan, OfficeAgentInput, Rect, Vec } from "./office-types"; import { appearanceFor, type Carrying, type Facing, type SpriteAppearance } from "./sprites"; export type AgentBehavior = @@ -21,10 +21,6 @@ export interface AgentSim { behavior: AgentBehavior; carryRemainingMs: number; settledMs: number; - /** While positive, the agent hustles (a whip crack sent them home). */ - hustleMs: number; - /** While positive, the agent flinches from a whip strike. */ - hitReactMs: number; } export interface PlayerSim { @@ -48,7 +44,7 @@ const PLAYER_SPEED = 0.14; const AGENT_SPEED = 0.085; const MAX_STEP_MS = 100; const WALK_CYCLE = 0.02; -/** Everyone wanders off roughly every minute and a half, working or not. */ +/** An idle agent wanders off roughly every minute and a half; one with work stays at the desk. */ const MEAN_TRIP_INTERVAL_MS = 90_000; /** Nobody leaves their desk in the first moments after settling in. */ const SETTLE_MS = 20_000; @@ -61,9 +57,6 @@ const MEETING_SPREAD_MS = 12_000; const MEAN_MEETING_INTERVAL_MS = 180_000; const CARRY_MIN_MS = 20_000; const CARRY_SPREAD_MS = 20_000; -/** How long a whipped agent hustles, and how much faster they move. */ -const HUSTLE_MS = 6_000; -const HUSTLE_MULTIPLIER = 1.8; /** * Holds the living state of the office: who sits where and where everyone @@ -74,8 +67,6 @@ export class OfficeSimulation { plan: FloorPlan; agents: AgentSim[] = []; player: PlayerSim | null = null; - /** Transient whip-crack flashes, aged by step() and dropped when spent. */ - effects: WhipCrackEffect[] = []; private openDeskDemand = -1; private readonly random: () => number; @@ -106,26 +97,15 @@ export class OfficeSimulation { }; } - /** - * 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. - */ + /** Advances one frame; only idle agents leave their desks, an agent with work stays put. */ 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.effects.length > 0) { - for (const effect of this.effects) effect.ageMs += dt; - this.effects = this.effects.filter((effect) => effect.ageMs < WHIP_CRACK_MS); - } if (this.random() < dt / MEAN_MEETING_INTERVAL_MS) this.conveneMeeting(); } private stepAgent(agent: AgentSim, dt: number): void { - if (agent.hustleMs > 0) agent.hustleMs = Math.max(0, agent.hustleMs - dt); - if (agent.hitReactMs > 0) agent.hitReactMs = Math.max(0, agent.hitReactMs - dt); if (agent.carryRemainingMs > 0) { agent.carryRemainingMs -= dt; if (agent.carryRemainingMs <= 0) { @@ -136,7 +116,7 @@ export class OfficeSimulation { switch (agent.behavior.kind) { case "seated": { agent.settledMs += dt; - if (agent.settledMs >= SETTLE_MS && this.random() < dt / MEAN_TRIP_INTERVAL_MS) { + if (agent.input.activity === "idle" && agent.settledMs >= SETTLE_MS && this.random() < dt / MEAN_TRIP_INTERVAL_MS) { agent.settledMs = 0; this.startTrip(agent); } @@ -176,15 +156,11 @@ 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. - */ + /** Gathers two or more settled idle agents around the table; returns how many 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, + (agent) => agent.input.activity === "idle" && 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)); @@ -224,7 +200,7 @@ export class OfficeSimulation { private walkAlong(agent: AgentSim, dt: number): void { if (agent.behavior.kind !== "walking") return; - let budget = AGENT_SPEED * (agent.hustleMs > 0 ? HUSTLE_MULTIPLIER : 1) * dt; + let budget = AGENT_SPEED * dt; while (budget > 0) { const target = agent.behavior.path[agent.behavior.next]; if (!target) { @@ -268,34 +244,6 @@ export class OfficeSimulation { agent.behavior = { kind: "seated" }; } - /** - * Cracks the whip at an agent. The crack flash and the target's flinch - * play wherever the strike lands — desk, corridor, anywhere. Only slackers - * in the snack bar or restroom are additionally sent hustling back to - * their desk (snack abandoned); agents at their desk, in the CEO office, - * or in a meeting keep working. Returns true when someone was sent home. - */ - whip(agentId: string): boolean { - const agent = this.agents.find((candidate) => candidate.input.id === agentId); - if (!agent) return false; - this.effects.push({ x: agent.x, y: agent.y - 18, ageMs: 0 }); - agent.hitReactMs = HIT_REACT_MS; - const room = this.roomAt({ x: agent.x, y: agent.y }); - if (room !== "snack" && room !== "restroom") return false; - agent.carrying = null; - agent.carryRemainingMs = 0; - agent.hustleMs = HUSTLE_MS; - this.sendHome(agent); - return true; - } - - private roomAt(point: Vec): RoomKind | null { - for (const room of this.plan.rooms) { - if (rectContains(room.bounds, point)) return room.kind; - } - return null; - } - /** * Returns the agent whose drawn body contains the world point, preferring * the visually frontmost (largest y). Tests against the same positions the @@ -375,10 +323,10 @@ export class OfficeSimulation { behavior: prev?.behavior ?? { kind: "seated" }, carryRemainingMs: prev?.carryRemainingMs ?? 0, settledMs: prev?.settledMs ?? 0, - hustleMs: prev?.hustleMs ?? 0, - hitReactMs: prev?.hitReactMs ?? 0, }; - if (planRebuilt && prev && (agent.x !== seat.x || agent.y !== seat.y)) { + const onBreak = agent.behavior.kind === "dwelling" || (agent.behavior.kind === "walking" && agent.behavior.destination === "amenity"); + const workStarted = input.activity !== "idle" && onBreak; + if (workStarted || (planRebuilt && prev && (agent.x !== seat.x || agent.y !== seat.y))) { this.sendHome(agent); } return agent; diff --git a/src/renderer/src/styles/components.css b/src/renderer/src/styles/components.css index 234ca132..00beea1b 100644 --- a/src/renderer/src/styles/components.css +++ b/src/renderer/src/styles/components.css @@ -269,21 +269,7 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; } border-radius: 10px; box-shadow: 0 8px 24px rgba(16, 16, 24, 0.18); } -.office-tool { - display: inline-flex; - align-items: center; - gap: 6px; - border: none; - background: none; - color: var(--text-2); - font: inherit; - font-size: 13px; - padding: 6px 10px; - border-radius: 7px; - cursor: pointer; -} -.office-tool:hover { background: var(--hover); color: var(--text-1); } -.office-tool.active { background: var(--accent); color: #fff; } +.office-legend { align-self: center; padding: 0 6px; font-size: 12px; color: var(--text-2); } /* Floating "jump to latest" pill above the chat composer */ .jump-latest { diff --git a/tests/e2e/office.spec.ts b/tests/e2e/office.spec.ts index 7c53b9af..ec57d6a1 100644 --- a/tests/e2e/office.spec.ts +++ b/tests/e2e/office.spec.ts @@ -89,19 +89,9 @@ test("the office floor opens agent details on click", async () => { } await expect(canvas).toBeVisible(); - // The bottom toolbar carries the whip tool; in whip mode a click on an - // at-desk agent disciplines nobody and opens no card. - const whip = page.getByRole("button", { name: "Whip" }); - await expect(whip).toBeVisible(); - await whip.click(); - await expect(whip).toHaveAttribute("aria-pressed", "true"); - await page.mouse.click(clickX, clickY); - await expect(page.getByRole("dialog", { name: /Agent / })).not.toBeVisible(); - // Let the crack flash and flinch play out a few frames - a throwing - // effect draw would land in pageErrors and fail the final assertion. - await page.waitForTimeout(700); - await page.keyboard.press("Escape"); - await expect(whip).toHaveAttribute("aria-pressed", "false"); + // The office offers no pretend controls over work, and says what a position means. + await expect(page.getByRole("button", { name: "Whip" })).toHaveCount(0); + await expect(page.getByText("Agents with work stay at their desks. Only idle agents walk to coffee, snacks or the meeting table.")).toBeVisible(); // Hired agents take open-floor desks in hire order and are clickable too. await hireAgent(page, "Ava Patel", "Designer", "Design"); diff --git a/tests/unit/office-simulation.test.ts b/tests/unit/office-simulation.test.ts index d96fb900..65f7e3f7 100644 --- a/tests/unit/office-simulation.test.ts +++ b/tests/unit/office-simulation.test.ts @@ -121,49 +121,53 @@ describe("office simulation", () => { expect(agent.carrying).toBe("coffee"); }); - test("working agents (and the CEO) take breaks too", () => { - const sim = new OfficeSimulation(() => 0); - sim.sync([agentInput("ceo", "c_suite", "active")]); - const agent = sim.agents[0]; - let leftTheDesk = false; - for (let i = 0; i < 3000 && !leftTheDesk; i++) { - sim.step(16, IDLE); - if (agent.behavior.kind !== "seated") leftTheDesk = true; + test("agents with a run, thinking or busy never leave their desk", () => { + for (const activity of ["active", "thinking", "busy"] as const) { + const sim = new OfficeSimulation(() => 0); + sim.sync([agentInput("ceo", "c_suite", activity), agentInput("a", "staff", activity)]); + for (let i = 0; i < 6000; i++) { + sim.step(16, IDLE); + for (const agent of sim.agents) expect(agent.behavior.kind).toBe("seated"); + } } - expect(leftTheDesk).toBe(true); }); - test("a snapshot refresh mid-break never recalls the walker", () => { + test("an idle refresh keeps a break going; a run starting sends the agent straight back to the desk", () => { 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.behavior.kind === "walking" && agent.behavior.destination).toBe("amenity"); + + sim.sync([agentInput("a")]); + expect(sim.agents[0].behavior.kind === "walking" && sim.agents[0].behavior.destination).toBe("amenity"); - // Becoming busy does not cancel the human ritual of a coffee run. sim.sync([agentInput("a", "staff", "active")]); - const refreshed = sim.agents[0]; - expect(refreshed.behavior.kind).toBe("walking"); - let dwelled = false; - for (let i = 0; i < 6000 && !dwelled; i++) { + const working = sim.agents[0]; + expect(working.behavior.kind === "walking" && working.behavior.destination).toBe("seat"); + for (let i = 0; i < 6000 && working.behavior.kind !== "seated"; i++) { sim.step(16, IDLE); - if (refreshed.behavior.kind === "dwelling") dwelled = true; + expect(working.behavior.kind).not.toBe("dwelling"); } - expect(dwelled).toBe(true); + expect(working.x).toBe(working.seat.x); + expect(working.y).toBe(working.seat.y); }); - test("meetings convene at least two attendees around the table, then everyone returns", () => { + test("only idle agents gather around the table, and anyone with a run stays at their desk", () => { const sim = new OfficeSimulation(() => 0); - sim.sync([agentInput("a", "staff", "active"), agentInput("b"), agentInput("ceo", "c_suite", "active")]); + sim.sync([agentInput("a"), 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); + expect(attendees).toBe(2); const walkers = sim.agents.filter((agent) => agent.behavior.kind === "walking"); expect(walkers).toHaveLength(attendees); + expect(sim.agents.find((agent) => agent.input.id === "ceo")!.behavior.kind).toBe("seated"); const spots = new Set( walkers.map((agent) => (agent.behavior.kind === "walking" ? `${agent.behavior.spot?.stand.x}` : "")), ); @@ -189,80 +193,6 @@ describe("office simulation", () => { expect(sim.agents[0].behavior.kind).toBe("seated"); }); - test("the whip sends snack-bar and restroom slackers hustling home, snack abandoned", () => { - const sim = new OfficeSimulation(() => 0); - sim.sync([agentInput("a"), agentInput("b")]); - const snacker = sim.agents[0]; - const snackSpot = sim.plan.amenities.find((spot) => spot.kind === "snack")!; - snacker.x = snackSpot.stand.x; - snacker.y = snackSpot.stand.y; - snacker.carrying = "snack"; - snacker.behavior = { kind: "dwelling", spot: snackSpot, remainingMs: 60_000 }; - - expect(sim.whip("a")).toBe(true); - expect(snacker.behavior.kind).toBe("walking"); - expect(snacker.carrying).toBeNull(); - expect(snacker.hustleMs).toBeGreaterThan(0); - - const restroomSpot = sim.plan.amenities.find((spot) => spot.kind === "restroom")!; - const rester = sim.agents[1]; - rester.x = restroomSpot.stand.x; - rester.y = restroomSpot.stand.y; - rester.behavior = { kind: "dwelling", spot: restroomSpot, remainingMs: 60_000 }; - expect(sim.whip("b")).toBe(true); - expect(rester.behavior.kind).toBe("walking"); - - // The whipped walker makes it back to their desk. - for (let i = 0; i < 4000 && sim.agents[0].behavior.kind !== "seated"; i++) sim.step(16, IDLE); - expect(snacker.x).toBe(snacker.seat.x); - expect(snacker.y).toBe(snacker.seat.y); - }); - - test("the whip does nothing to agents at their desk or in a meeting", () => { - const sim = new OfficeSimulation(() => 0); - sim.sync([agentInput("a"), agentInput("b"), agentInput("ceo", "c_suite")]); - // Seated at the desk: working, untouched. - expect(sim.whip("a")).toBe(false); - expect(sim.agents[0].behavior.kind).toBe("seated"); - - // Dwelling in the meeting room: in a meeting, untouched. - const meetingSpot = sim.plan.amenities.find((spot) => spot.kind === "meeting")!; - const attendee = sim.agents[1]; - attendee.x = meetingSpot.stand.x; - attendee.y = meetingSpot.stand.y; - attendee.behavior = { kind: "dwelling", spot: meetingSpot, remainingMs: 20_000 }; - expect(sim.whip("b")).toBe(false); - expect(attendee.behavior.kind).toBe("dwelling"); - - // The CEO in their own office is working, untouched. - expect(sim.whip("ceo")).toBe(false); - expect(sim.whip("nobody")).toBe(false); - }); - - test("every whip strike plays a crack and a flinch, wherever the target is", () => { - const sim = new OfficeSimulation(() => 0); - sim.sync([agentInput("a")]); - const agent = sim.agents[0]; - - // Struck at the desk: no discipline effect, but the animation still fires. - expect(sim.whip("a")).toBe(false); - expect(agent.behavior.kind).toBe("seated"); - expect(agent.hitReactMs).toBeGreaterThan(0); - expect(sim.effects).toHaveLength(1); - expect(sim.effects[0]).toMatchObject({ x: agent.x, ageMs: 0 }); - - // A second strike stacks another crack; a missing target adds nothing. - sim.whip("a"); - expect(sim.effects).toHaveLength(2); - sim.whip("nobody"); - expect(sim.effects).toHaveLength(2); - - // Both the crack flash and the flinch drain away with time. - for (let i = 0; i < 50; i++) sim.step(16, IDLE); - expect(sim.effects).toHaveLength(0); - expect(agent.hitReactMs).toBe(0); - }); - test("clicks hit the agent drawn at that spot, frontmost first", () => { const sim = new OfficeSimulation(); sim.sync([agentInput("a"), agentInput("b")]);