Add the whip to the office toolbar: slackers hustle back to their desks

- New bottom toolbar on the pixel office floor with a Whip tool;
  toggling it turns the cursor to a crosshair, clicks whip instead of
  opening the detail card, Escape puts the whip away
- Simulation.whip(): an agent inside the snack bar or restroom drops
  whatever they are carrying and hustles back to their desk at 1.8x
  speed for a few seconds; agents at their desks, in the CEO office,
  or in the meeting room are working and stay untouched
- Room membership decides the outcome (rectContains against the same
  floor-plan rooms the renderer draws), so the rule matches what the
  player sees
- Unit tests cover the whip matrix; e2e drives the toolbar toggle in
  the live shell
This commit is contained in:
ZacharyZhang-NY committed 2026-07-08 21:51:47 -04:00
1 parent 2bb2acd635
commit 4ab20e679c
6 files changed
+161 -4

No files matched your search

+3
View File
@@ -124,6 +124,9 @@ export const zh: Record<string, string> = {
"Edit task": "编辑任务",
"Edit {name}": "编辑 {name}",
"Edit profile": "编辑个人资料",
"Whip": "鞭子",
"Office tools": "办公室工具",
"Crack the whip: slackers in the snack bar or restroom hustle back to their desks. Working or meeting agents are unaffected.": "挥动鞭子:在茶水间或洗手间摸鱼的成员会赶回工位;正在工作或开会的成员不受影响。",
"Avatar": "头像",
"Avatar emoji": "头像表情",
"Avatar color": "头像颜色",
+32 -1
View File
@@ -6,6 +6,7 @@ import { OfficeSimulation, cameraFor, type MoveInput } from "./office/simulation
import { AgentDetailCard } from "./office/AgentDetailCard";
import { cardPositionFor, CARD_FALLBACK_H } from "./office/agent-card-position";
import type { AgentActivity, Camera, OfficeAgentInput, Vec } from "./office/office-types";
import { t } from "../i18n/i18n";
const FPS_INTERVAL = 1000 / 24;
@@ -29,6 +30,8 @@ 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();
@@ -59,9 +62,15 @@ export function OfficePage({ snapshot }: PageProps) {
selectedIdRef.current = selectedId;
}, [selectedId]);
useEffect(() => {
toolRef.current = tool;
}, [tool]);
useEffect(() => {
const onEscape = (event: KeyboardEvent) => {
if (event.key === "Escape" && selectedIdRef.current) setSelectedId(null);
if (event.key !== "Escape") return;
if (toolRef.current === "whip") setTool("select");
else if (selectedIdRef.current) setSelectedId(null);
};
window.addEventListener("keydown", onEscape);
return () => window.removeEventListener("keydown", onEscape);
@@ -201,6 +210,10 @@ 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);
};
@@ -208,6 +221,10 @@ 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";
};
@@ -224,6 +241,20 @@ export function OfficePage({ snapshot }: PageProps) {
<AgentDetailCard agent={selectedAgent} onClose={() => setSelectedId(null)} />
</div>
)}
<div className="office-toolbar" role="toolbar" aria-label={t("Office tools")}>
<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>
</div>
</div>
);
}
+35 -3
View File
@@ -1,5 +1,5 @@
import { buildFloorPlan, findPath } from "./floor-plan";
import type { AmenitySpot, Camera, FloorPlan, OfficeAgentInput, Rect, Vec } from "./office-types";
import { buildFloorPlan, findPath, rectContains } from "./floor-plan";
import type { AmenitySpot, Camera, FloorPlan, OfficeAgentInput, Rect, RoomKind, Vec } from "./office-types";
import { appearanceFor, type Carrying, type Facing, type SpriteAppearance } from "./sprites";
export type AgentBehavior =
@@ -21,6 +21,8 @@ export interface AgentSim {
behavior: AgentBehavior;
carryRemainingMs: number;
settledMs: number;
/** While positive, the agent hustles (a whip crack sent them home). */
hustleMs: number;
}
export interface PlayerSim {
@@ -57,6 +59,9 @@ 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
@@ -111,6 +116,7 @@ export class OfficeSimulation {
}
private stepAgent(agent: AgentSim, dt: number): void {
if (agent.hustleMs > 0) agent.hustleMs = Math.max(0, agent.hustleMs - dt);
if (agent.carryRemainingMs > 0) {
agent.carryRemainingMs -= dt;
if (agent.carryRemainingMs <= 0) {
@@ -209,7 +215,7 @@ export class OfficeSimulation {
private walkAlong(agent: AgentSim, dt: number): void {
if (agent.behavior.kind !== "walking") return;
let budget = AGENT_SPEED * dt;
let budget = AGENT_SPEED * (agent.hustleMs > 0 ? HUSTLE_MULTIPLIER : 1) * dt;
while (budget > 0) {
const target = agent.behavior.path[agent.behavior.next];
if (!target) {
@@ -253,6 +259,31 @@ export class OfficeSimulation {
agent.behavior = { kind: "seated" };
}
/**
* Cracks the whip at an agent. Slacking in the snack bar or restroom sends
* them hustling back to their desk (snack abandoned); agents at their desk,
* in the CEO office, or in a meeting are working and stay untouched.
* Returns true when the whip connected.
*/
whip(agentId: string): boolean {
const agent = this.agents.find((candidate) => candidate.input.id === agentId);
if (!agent) return false;
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
@@ -332,6 +363,7 @@ export class OfficeSimulation {
behavior: prev?.behavior ?? { kind: "seated" },
carryRemainingMs: prev?.carryRemainingMs ?? 0,
settledMs: prev?.settledMs ?? 0,
hustleMs: prev?.hustleMs ?? 0,
};
if (planRebuilt && prev && (agent.x !== seat.x || agent.y !== seat.y)) {
this.sendHome(agent);
+30
View File
@@ -251,3 +251,33 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
.sidebar-foot-button { display: flex; align-items: center; gap: 8px; width: 100%; background: none; border: none; padding: 0; cursor: pointer; color: inherit; font: inherit; border-radius: var(--r-sm); }
.sidebar-foot-button:hover .sidebar-foot-name { text-decoration: underline; }
.form-error { color: var(--red); font-size: var(--fs-caption); margin-top: 4px; }
/* Pixel-office bottom toolbar */
.office-toolbar {
position: absolute;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 6px;
padding: 6px;
background: var(--panel);
border: 1px solid var(--border-strong);
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; }
+11
View File
@@ -85,6 +85,17 @@ 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();
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.
await hireAgent(page, "Ava Patel", "Designer", "Design");
await hireAgent(page, "Ken Ito", "Engineer", "Engineering");
+50
View File
@@ -189,6 +189,56 @@ 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("clicks hit the agent drawn at that spot, frontmost first", () => {
const sim = new OfficeSimulation();
sim.sync([agentInput("a"), agentInput("b")]);