diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index bee142ee..8f2e1e44 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -124,6 +124,9 @@ export const zh: Record = { "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": "头像颜色", diff --git a/src/renderer/src/pages/OfficePage.tsx b/src/renderer/src/pages/OfficePage.tsx index 8453567c..9d9227de 100644 --- a/src/renderer/src/pages/OfficePage.tsx +++ b/src/renderer/src/pages/OfficePage.tsx @@ -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(null); const cardBoxRef = useRef(null); const [selectedId, setSelectedId] = useState(null); + const [tool, setTool] = useState<"select" | "whip">("select"); + const toolRef = useRef(tool); const simRef = useRef(null); if (simRef.current === null) simRef.current = new OfficeSimulation(); @@ -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) { setSelectedId(null)} /> )} +
+ +
); } diff --git a/src/renderer/src/pages/office/simulation.ts b/src/renderer/src/pages/office/simulation.ts index 9a1741e6..91ebee16 100644 --- a/src/renderer/src/pages/office/simulation.ts +++ b/src/renderer/src/pages/office/simulation.ts @@ -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); diff --git a/src/renderer/src/styles/components.css b/src/renderer/src/styles/components.css index f02e8e07..f0cbb717 100644 --- a/src/renderer/src/styles/components.css +++ b/src/renderer/src/styles/components.css @@ -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; } diff --git a/tests/e2e/office.spec.ts b/tests/e2e/office.spec.ts index 4b39e649..79849a5a 100644 --- a/tests/e2e/office.spec.ts +++ b/tests/e2e/office.spec.ts @@ -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"); diff --git a/tests/unit/office-simulation.test.ts b/tests/unit/office-simulation.test.ts index 7fed5490..e73dbcfb 100644 --- a/tests/unit/office-simulation.test.ts +++ b/tests/unit/office-simulation.test.ts @@ -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")]);