Q207: office positions follow real state: only idle agents leave their desks; the pretend whip is gone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 11:43:30 -04:00
1 parent 412fdceb3c
commit ace76dabc7
8 files changed
+45 -304

No files matched your search

+1 -2
View File
@@ -342,9 +342,8 @@ export const zh: Record<string, string> = {
"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": "头像颜色",
+4 -35
View File
@@ -32,8 +32,6 @@ export function OfficePage({ snapshot }: PageProps) {
const selectedIdRef = useRef<string | null>(null);
const cardBoxRef = useRef<HTMLDivElement>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [tool, setTool] = useState<"select" | "whip">("select");
const toolRef = useRef(tool);
const simRef = useRef<OfficeSimulation | null>(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 }}
>
<option value="">{t("Show a person…")}</option>
{inputs.map((input) => <option key={input.id} value={input.id}>{t("{name}, {activity}", { name: input.name, activity: officeActivityLabel(input.activity) })}</option>)}
</select>
<button
type="button"
className={`office-tool ${tool === "whip" ? "active" : ""}`.trim()}
aria-pressed={tool === "whip"}
title={t("Crack the whip: slackers in the snack bar or restroom hustle back to their desks. Working or meeting agents are unaffected.")}
onClick={() => {
setSelectedId(null);
setTool(tool === "whip" ? "select" : "whip");
}}
>
<span aria-hidden>⚡</span> {t("Whip")}
</button>
<span className="office-legend">{t("Agents with work stay at their desks. Only idle agents walk to coffee, snacks or the meeting table.")}</span>
</div>
</div>
);
@@ -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<Record<AgentActivity, string>> = {
@@ -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;
@@ -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. */
+11 -63
View File
@@ -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;
+1 -15
View File
@@ -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 {
+3 -13
View File
@@ -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");
+23 -93
View File
@@ -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")]);