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": "头像图片文件",
|
"Avatar image file": "头像图片文件",
|
||||||
"That file could not be read as an image.": "无法将该文件读取为图片。",
|
"That file could not be read as an image.": "无法将该文件读取为图片。",
|
||||||
"Conversation messages": "会话消息",
|
"Conversation messages": "会话消息",
|
||||||
"Whip": "鞭子",
|
|
||||||
"Office tools": "办公室工具",
|
"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": "头像",
|
||||||
"Avatar emoji": "头像表情",
|
"Avatar emoji": "头像表情",
|
||||||
"Avatar color": "头像颜色",
|
"Avatar color": "头像颜色",
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ export function OfficePage({ snapshot }: PageProps) {
|
|||||||
const selectedIdRef = useRef<string | null>(null);
|
const selectedIdRef = useRef<string | null>(null);
|
||||||
const cardBoxRef = useRef<HTMLDivElement>(null);
|
const cardBoxRef = useRef<HTMLDivElement>(null);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(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);
|
const simRef = useRef<OfficeSimulation | null>(null);
|
||||||
if (simRef.current === null) simRef.current = new OfficeSimulation();
|
if (simRef.current === null) simRef.current = new OfficeSimulation();
|
||||||
|
|
||||||
@@ -64,15 +62,10 @@ export function OfficePage({ snapshot }: PageProps) {
|
|||||||
selectedIdRef.current = selectedId;
|
selectedIdRef.current = selectedId;
|
||||||
}, [selectedId]);
|
}, [selectedId]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
toolRef.current = tool;
|
|
||||||
}, [tool]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onEscape = (event: KeyboardEvent) => {
|
const onEscape = (event: KeyboardEvent) => {
|
||||||
if (event.key !== "Escape") return;
|
if (event.key !== "Escape") return;
|
||||||
if (toolRef.current === "whip") setTool("select");
|
if (selectedIdRef.current) setSelectedId(null);
|
||||||
else if (selectedIdRef.current) setSelectedId(null);
|
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onEscape);
|
window.addEventListener("keydown", onEscape);
|
||||||
return () => window.removeEventListener("keydown", onEscape);
|
return () => window.removeEventListener("keydown", onEscape);
|
||||||
@@ -158,7 +151,6 @@ export function OfficePage({ snapshot }: PageProps) {
|
|||||||
facing: agent.facing,
|
facing: agent.facing,
|
||||||
walkPhase: agent.walkPhase,
|
walkPhase: agent.walkPhase,
|
||||||
carrying: agent.carrying,
|
carrying: agent.carrying,
|
||||||
hitReactMs: agent.hitReactMs,
|
|
||||||
}));
|
}));
|
||||||
if (sim.player) {
|
if (sim.player) {
|
||||||
people.push({
|
people.push({
|
||||||
@@ -175,10 +167,9 @@ export function OfficePage({ snapshot }: PageProps) {
|
|||||||
facing: sim.player.facing,
|
facing: sim.player.facing,
|
||||||
walkPhase: sim.player.walkPhase,
|
walkPhase: sim.player.walkPhase,
|
||||||
carrying: null,
|
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.
|
// The detail card rides beside its agent, even while they walk.
|
||||||
const cardBox = cardBoxRef.current;
|
const cardBox = cardBoxRef.current;
|
||||||
@@ -214,10 +205,6 @@ export function OfficePage({ snapshot }: PageProps) {
|
|||||||
const point = worldPointFrom(event);
|
const point = worldPointFrom(event);
|
||||||
if (!point) return;
|
if (!point) return;
|
||||||
const hit = simRef.current!.hitTest(point);
|
const hit = simRef.current!.hitTest(point);
|
||||||
if (tool === "whip") {
|
|
||||||
if (hit) simRef.current!.whip(hit.input.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedId(hit ? hit.input.id : null);
|
setSelectedId(hit ? hit.input.id : null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -225,10 +212,6 @@ export function OfficePage({ snapshot }: PageProps) {
|
|||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const point = worldPointFrom(event);
|
const point = worldPointFrom(event);
|
||||||
if (!canvas || !point) return;
|
if (!canvas || !point) return;
|
||||||
if (tool === "whip") {
|
|
||||||
canvas.style.cursor = "crosshair";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
canvas.style.cursor = simRef.current!.hitTest(point) ? "pointer" : "default";
|
canvas.style.cursor = simRef.current!.hitTest(point) ? "pointer" : "default";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -252,27 +235,13 @@ export function OfficePage({ snapshot }: PageProps) {
|
|||||||
className="input"
|
className="input"
|
||||||
aria-label={t("Show a person")}
|
aria-label={t("Show a person")}
|
||||||
value={selectedId ?? ""}
|
value={selectedId ?? ""}
|
||||||
onChange={(event) => {
|
onChange={(event) => setSelectedId(event.target.value || null)}
|
||||||
setTool("select");
|
|
||||||
setSelectedId(event.target.value || null);
|
|
||||||
}}
|
|
||||||
style={{ width: 200, height: 30 }}
|
style={{ width: 200, height: 30 }}
|
||||||
>
|
>
|
||||||
<option value="">{t("Show a person…")}</option>
|
<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>)}
|
{inputs.map((input) => <option key={input.id} value={input.id}>{t("{name}, {activity}", { name: input.name, activity: officeActivityLabel(input.activity) })}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<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>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</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 { deskScreenRect, drawWorld } from "./scene-renderer";
|
||||||
import { skyScene } from "./sky";
|
import { skyScene } from "./sky";
|
||||||
import { drawPerson, type Carrying, type Facing, type SpriteAppearance } from "./sprites";
|
import { drawPerson, type Carrying, type Facing, type SpriteAppearance } from "./sprites";
|
||||||
@@ -17,8 +17,6 @@ export interface RenderPerson {
|
|||||||
facing: Facing;
|
facing: Facing;
|
||||||
walkPhase: number;
|
walkPhase: number;
|
||||||
carrying: Carrying;
|
carrying: Carrying;
|
||||||
/** Remaining flinch time after a whip strike; 0 when unbothered. */
|
|
||||||
hitReactMs: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTIVITY_DOT: Partial<Record<AgentActivity, string>> = {
|
const ACTIVITY_DOT: Partial<Record<AgentActivity, string>> = {
|
||||||
@@ -47,7 +45,6 @@ export function renderOffice(
|
|||||||
timeMs: number,
|
timeMs: number,
|
||||||
clock: Date,
|
clock: Date,
|
||||||
selectedId: string | null,
|
selectedId: string | null,
|
||||||
effects: WhipCrackEffect[] = [],
|
|
||||||
): void {
|
): void {
|
||||||
ctx.fillStyle = "#14161c";
|
ctx.fillStyle = "#14161c";
|
||||||
ctx.fillRect(0, 0, viewW, viewH);
|
ctx.fillRect(0, 0, viewW, viewH);
|
||||||
@@ -80,10 +77,7 @@ export function renderOffice(
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
const working = (person.activity === "active" || person.activity === "thinking") && person.walkPhase === 0;
|
const working = (person.activity === "active" || person.activity === "thinking") && person.walkPhase === 0;
|
||||||
// A struck agent shakes for the flinch duration, hardest right after the hit.
|
drawPerson(ctx, person.x, person.y, person.appearance, {
|
||||||
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, {
|
|
||||||
facing: person.facing,
|
facing: person.facing,
|
||||||
walkPhase: person.walkPhase,
|
walkPhase: person.walkPhase,
|
||||||
working,
|
working,
|
||||||
@@ -91,7 +85,6 @@ export function renderOffice(
|
|||||||
timeMs,
|
timeMs,
|
||||||
animSeed: person.animSeed,
|
animSeed: person.animSeed,
|
||||||
}, person.isCeo);
|
}, person.isCeo);
|
||||||
if (flinchStrength > 0) drawPainMarks(ctx, person.x + shakeX, person.y, flinchStrength);
|
|
||||||
const dot = ACTIVITY_DOT[person.activity];
|
const dot = ACTIVITY_DOT[person.activity];
|
||||||
if (dot) {
|
if (dot) {
|
||||||
ctx.fillStyle = dot;
|
ctx.fillStyle = dot;
|
||||||
@@ -101,72 +94,10 @@ export function renderOffice(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const person of ordered) drawNameLabel(ctx, person);
|
for (const person of ordered) drawNameLabel(ctx, person);
|
||||||
for (const effect of effects) drawWhipCrack(ctx, effect);
|
|
||||||
|
|
||||||
ctx.restore();
|
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 {
|
function drawNameLabel(ctx: CanvasRenderingContext2D, person: RenderPerson): void {
|
||||||
ctx.font = "600 9px -apple-system, 'Segoe UI', sans-serif";
|
ctx.font = "600 9px -apple-system, 'Segoe UI', sans-serif";
|
||||||
const width = Math.ceil(ctx.measureText(person.name).width) + 10;
|
const width = Math.ceil(ctx.measureText(person.name).width) + 10;
|
||||||
|
|||||||
@@ -61,18 +61,6 @@ export interface FloorPlan {
|
|||||||
entrance: Vec;
|
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";
|
export type AgentActivity = "active" | "thinking" | "busy" | "idle";
|
||||||
|
|
||||||
/** Snapshot-derived facts the office simulation needs about one agent. */
|
/** Snapshot-derived facts the office simulation needs about one agent. */
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { buildFloorPlan, findPath, rectContains } from "./floor-plan";
|
import { buildFloorPlan, findPath } 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 type { AmenitySpot, Camera, FloorPlan, OfficeAgentInput, Rect, Vec } from "./office-types";
|
||||||
import { appearanceFor, type Carrying, type Facing, type SpriteAppearance } from "./sprites";
|
import { appearanceFor, type Carrying, type Facing, type SpriteAppearance } from "./sprites";
|
||||||
|
|
||||||
export type AgentBehavior =
|
export type AgentBehavior =
|
||||||
@@ -21,10 +21,6 @@ export interface AgentSim {
|
|||||||
behavior: AgentBehavior;
|
behavior: AgentBehavior;
|
||||||
carryRemainingMs: number;
|
carryRemainingMs: number;
|
||||||
settledMs: 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 {
|
export interface PlayerSim {
|
||||||
@@ -48,7 +44,7 @@ const PLAYER_SPEED = 0.14;
|
|||||||
const AGENT_SPEED = 0.085;
|
const AGENT_SPEED = 0.085;
|
||||||
const MAX_STEP_MS = 100;
|
const MAX_STEP_MS = 100;
|
||||||
const WALK_CYCLE = 0.02;
|
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;
|
const MEAN_TRIP_INTERVAL_MS = 90_000;
|
||||||
/** Nobody leaves their desk in the first moments after settling in. */
|
/** Nobody leaves their desk in the first moments after settling in. */
|
||||||
const SETTLE_MS = 20_000;
|
const SETTLE_MS = 20_000;
|
||||||
@@ -61,9 +57,6 @@ const MEETING_SPREAD_MS = 12_000;
|
|||||||
const MEAN_MEETING_INTERVAL_MS = 180_000;
|
const MEAN_MEETING_INTERVAL_MS = 180_000;
|
||||||
const CARRY_MIN_MS = 20_000;
|
const CARRY_MIN_MS = 20_000;
|
||||||
const CARRY_SPREAD_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
|
* Holds the living state of the office: who sits where and where everyone
|
||||||
@@ -74,8 +67,6 @@ export class OfficeSimulation {
|
|||||||
plan: FloorPlan;
|
plan: FloorPlan;
|
||||||
agents: AgentSim[] = [];
|
agents: AgentSim[] = [];
|
||||||
player: PlayerSim | null = null;
|
player: PlayerSim | null = null;
|
||||||
/** Transient whip-crack flashes, aged by step() and dropped when spent. */
|
|
||||||
effects: WhipCrackEffect[] = [];
|
|
||||||
private openDeskDemand = -1;
|
private openDeskDemand = -1;
|
||||||
private readonly random: () => number;
|
private readonly random: () => number;
|
||||||
|
|
||||||
@@ -106,26 +97,15 @@ export class OfficeSimulation {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Advances one frame; only idle agents leave their desks, an agent with work stays put. */
|
||||||
* 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 {
|
step(dtMs: number, input: MoveInput): void {
|
||||||
const dt = Math.min(dtMs, MAX_STEP_MS);
|
const dt = Math.min(dtMs, MAX_STEP_MS);
|
||||||
if (this.player) this.stepPlayer(this.player, dt, input);
|
if (this.player) this.stepPlayer(this.player, dt, input);
|
||||||
for (const agent of this.agents) this.stepAgent(agent, dt);
|
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();
|
if (this.random() < dt / MEAN_MEETING_INTERVAL_MS) this.conveneMeeting();
|
||||||
}
|
}
|
||||||
|
|
||||||
private stepAgent(agent: AgentSim, dt: number): void {
|
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) {
|
if (agent.carryRemainingMs > 0) {
|
||||||
agent.carryRemainingMs -= dt;
|
agent.carryRemainingMs -= dt;
|
||||||
if (agent.carryRemainingMs <= 0) {
|
if (agent.carryRemainingMs <= 0) {
|
||||||
@@ -136,7 +116,7 @@ export class OfficeSimulation {
|
|||||||
switch (agent.behavior.kind) {
|
switch (agent.behavior.kind) {
|
||||||
case "seated": {
|
case "seated": {
|
||||||
agent.settledMs += dt;
|
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;
|
agent.settledMs = 0;
|
||||||
this.startTrip(agent);
|
this.startTrip(agent);
|
||||||
}
|
}
|
||||||
@@ -176,15 +156,11 @@ export class OfficeSimulation {
|
|||||||
return options[Math.floor(this.random() * options.length) % options.length];
|
return options[Math.floor(this.random() * options.length) % options.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Gathers two or more settled idle agents around the table; returns how many set off. */
|
||||||
* 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 {
|
conveneMeeting(): number {
|
||||||
const spots = this.plan.amenities.filter((spot) => spot.kind === "meeting");
|
const spots = this.plan.amenities.filter((spot) => spot.kind === "meeting");
|
||||||
const candidates = this.agents.filter(
|
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;
|
if (candidates.length < 2 || spots.length < 2) return 0;
|
||||||
const size = Math.min(spots.length, candidates.length, 2 + Math.floor(this.random() * 3));
|
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 {
|
private walkAlong(agent: AgentSim, dt: number): void {
|
||||||
if (agent.behavior.kind !== "walking") return;
|
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) {
|
while (budget > 0) {
|
||||||
const target = agent.behavior.path[agent.behavior.next];
|
const target = agent.behavior.path[agent.behavior.next];
|
||||||
if (!target) {
|
if (!target) {
|
||||||
@@ -268,34 +244,6 @@ export class OfficeSimulation {
|
|||||||
agent.behavior = { kind: "seated" };
|
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
|
* Returns the agent whose drawn body contains the world point, preferring
|
||||||
* the visually frontmost (largest y). Tests against the same positions the
|
* the visually frontmost (largest y). Tests against the same positions the
|
||||||
@@ -375,10 +323,10 @@ export class OfficeSimulation {
|
|||||||
behavior: prev?.behavior ?? { kind: "seated" },
|
behavior: prev?.behavior ?? { kind: "seated" },
|
||||||
carryRemainingMs: prev?.carryRemainingMs ?? 0,
|
carryRemainingMs: prev?.carryRemainingMs ?? 0,
|
||||||
settledMs: prev?.settledMs ?? 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);
|
this.sendHome(agent);
|
||||||
}
|
}
|
||||||
return agent;
|
return agent;
|
||||||
|
|||||||
@@ -269,21 +269,7 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 8px 24px rgba(16, 16, 24, 0.18);
|
box-shadow: 0 8px 24px rgba(16, 16, 24, 0.18);
|
||||||
}
|
}
|
||||||
.office-tool {
|
.office-legend { align-self: center; padding: 0 6px; font-size: 12px; color: var(--text-2); }
|
||||||
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; }
|
|
||||||
|
|
||||||
/* Floating "jump to latest" pill above the chat composer */
|
/* Floating "jump to latest" pill above the chat composer */
|
||||||
.jump-latest {
|
.jump-latest {
|
||||||
|
|||||||
@@ -89,19 +89,9 @@ test("the office floor opens agent details on click", async () => {
|
|||||||
}
|
}
|
||||||
await expect(canvas).toBeVisible();
|
await expect(canvas).toBeVisible();
|
||||||
|
|
||||||
// The bottom toolbar carries the whip tool; in whip mode a click on an
|
// The office offers no pretend controls over work, and says what a position means.
|
||||||
// at-desk agent disciplines nobody and opens no card.
|
await expect(page.getByRole("button", { name: "Whip" })).toHaveCount(0);
|
||||||
const whip = page.getByRole("button", { name: "Whip" });
|
await expect(page.getByText("Agents with work stay at their desks. Only idle agents walk to coffee, snacks or the meeting table.")).toBeVisible();
|
||||||
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");
|
|
||||||
|
|
||||||
// Hired agents take open-floor desks in hire order and are clickable too.
|
// Hired agents take open-floor desks in hire order and are clickable too.
|
||||||
await hireAgent(page, "Ava Patel", "Designer", "Design");
|
await hireAgent(page, "Ava Patel", "Designer", "Design");
|
||||||
|
|||||||
@@ -121,49 +121,53 @@ describe("office simulation", () => {
|
|||||||
expect(agent.carrying).toBe("coffee");
|
expect(agent.carrying).toBe("coffee");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("working agents (and the CEO) take breaks too", () => {
|
test("agents with a run, thinking or busy never leave their desk", () => {
|
||||||
const sim = new OfficeSimulation(() => 0);
|
for (const activity of ["active", "thinking", "busy"] as const) {
|
||||||
sim.sync([agentInput("ceo", "c_suite", "active")]);
|
const sim = new OfficeSimulation(() => 0);
|
||||||
const agent = sim.agents[0];
|
sim.sync([agentInput("ceo", "c_suite", activity), agentInput("a", "staff", activity)]);
|
||||||
let leftTheDesk = false;
|
for (let i = 0; i < 6000; i++) {
|
||||||
for (let i = 0; i < 3000 && !leftTheDesk; i++) {
|
sim.step(16, IDLE);
|
||||||
sim.step(16, IDLE);
|
for (const agent of sim.agents) expect(agent.behavior.kind).toBe("seated");
|
||||||
if (agent.behavior.kind !== "seated") leftTheDesk = true;
|
}
|
||||||
}
|
}
|
||||||
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);
|
const sim = new OfficeSimulation(() => 0);
|
||||||
sim.sync([agentInput("a")]);
|
sim.sync([agentInput("a")]);
|
||||||
const agent = sim.agents[0];
|
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 < 3000 && agent.behavior.kind === "seated"; i++) sim.step(16, IDLE);
|
||||||
for (let i = 0; i < 30; 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).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")]);
|
sim.sync([agentInput("a", "staff", "active")]);
|
||||||
const refreshed = sim.agents[0];
|
const working = sim.agents[0];
|
||||||
expect(refreshed.behavior.kind).toBe("walking");
|
expect(working.behavior.kind === "walking" && working.behavior.destination).toBe("seat");
|
||||||
let dwelled = false;
|
for (let i = 0; i < 6000 && working.behavior.kind !== "seated"; i++) {
|
||||||
for (let i = 0; i < 6000 && !dwelled; i++) {
|
|
||||||
sim.step(16, IDLE);
|
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);
|
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.
|
// Nobody has settled yet: a meeting cannot be convened.
|
||||||
expect(sim.conveneMeeting()).toBe(0);
|
expect(sim.conveneMeeting()).toBe(0);
|
||||||
for (const agent of sim.agents) agent.settledMs = 30_000;
|
for (const agent of sim.agents) agent.settledMs = 30_000;
|
||||||
|
|
||||||
const attendees = sim.conveneMeeting();
|
const attendees = sim.conveneMeeting();
|
||||||
expect(attendees).toBeGreaterThanOrEqual(2);
|
expect(attendees).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(attendees).toBe(2);
|
||||||
const walkers = sim.agents.filter((agent) => agent.behavior.kind === "walking");
|
const walkers = sim.agents.filter((agent) => agent.behavior.kind === "walking");
|
||||||
expect(walkers).toHaveLength(attendees);
|
expect(walkers).toHaveLength(attendees);
|
||||||
|
expect(sim.agents.find((agent) => agent.input.id === "ceo")!.behavior.kind).toBe("seated");
|
||||||
const spots = new Set(
|
const spots = new Set(
|
||||||
walkers.map((agent) => (agent.behavior.kind === "walking" ? `${agent.behavior.spot?.stand.x}` : "")),
|
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");
|
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", () => {
|
test("clicks hit the agent drawn at that spot, frontmost first", () => {
|
||||||
const sim = new OfficeSimulation();
|
const sim = new OfficeSimulation();
|
||||||
sim.sync([agentInput("a"), agentInput("b")]);
|
sim.sync([agentInput("a"), agentInput("b")]);
|
||||||
|
|||||||
Reference in new issue
Block a user