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:
1 parent
412fdceb3c
commit
ace76dabc7
8 files changed
+45
-304
No files matched your search
@@ -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": "头像颜色",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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")]);
|
||||
|
||||
Reference in new issue
Block a user