Office life: everyone roams, meetings convene in pairs, card follows agent

- The detail card now rides beside the clicked agent (flipping and
  clamping at viewport edges via a pure cardPositionFor helper) instead
  of pinning to the top-right corner; it follows a walking agent
- Every agent - the CEO and busy agents included - periodically visits
  the coffee machine, snack bar, and restroom; work status no longer
  recalls a walker mid-break
- The office convenes meetings around the meeting table every few
  minutes: at least two settled attendees walk over, dwell together on
  distinct table spots, and head back; a solo office never meets
- Meeting spots ship as lane-wired amenities so pathing stays correct
  by construction; floor-plan invariants (collider-free, reachable)
  cover them automatically
- e2e asserts the card opens beside the sprite, not in a corner
This commit is contained in:
ZacharyZhang-NY committed 2026-07-08 21:40:53 -04:00
1 parent e55b8b85b0
commit c3924af9ac
10 files changed
+206 -36

No files matched your search

+19 -1
View File
@@ -4,6 +4,7 @@ import { activeOrgId, requireActiveEmployee } from "./types";
import { hashString, renderOffice, type RenderPerson } from "./office/office-renderer";
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";
const FPS_INTERVAL = 1000 / 24;
@@ -26,6 +27,7 @@ export function OfficePage({ snapshot }: PageProps) {
const keysRef = useRef<MoveInput>({ up: false, down: false, left: false, right: false });
const cameraRef = useRef<Camera>({ x: 0, y: 0 });
const selectedIdRef = useRef<string | null>(null);
const cardBoxRef = useRef<HTMLDivElement>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const simRef = useRef<OfficeSimulation | null>(null);
if (simRef.current === null) simRef.current = new OfficeSimulation();
@@ -164,6 +166,18 @@ export function OfficePage({ snapshot }: PageProps) {
});
}
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;
const selected = selectedIdRef.current
? sim.agents.find((agent) => agent.input.id === selectedIdRef.current) ?? null
: null;
if (cardBox && selected) {
const cardH = cardBox.offsetHeight || CARD_FALLBACK_H;
const position = cardPositionFor({ x: selected.x, y: selected.y }, camera, viewW, viewH, cardH);
cardBox.style.transform = `translate(${position.left}px, ${position.top}px)`;
cardBox.style.visibility = "visible";
}
};
raf = requestAnimationFrame(tick);
@@ -205,7 +219,11 @@ export function OfficePage({ snapshot }: PageProps) {
onMouseMove={onCanvasMouseMove}
style={{ width: "100%", height: "100%", display: "block" }}
/>
{selectedAgent && <AgentDetailCard agent={selectedAgent} onClose={() => setSelectedId(null)} />}
{selectedAgent && (
<div ref={cardBoxRef} style={{ position: "absolute", left: 0, top: 0, willChange: "transform", visibility: "hidden" }}>
<AgentDetailCard agent={selectedAgent} onClose={() => setSelectedId(null)} />
</div>
)}
</div>
);
}
@@ -17,16 +17,16 @@ const ACTIVITY_TONE: Record<AgentActivity, "green" | "violet" | "amber" | undefi
idle: undefined,
};
/** Floating profile card for the agent selected on the office floor. */
/**
* Floating profile card for the agent selected on the office floor. The page
* owns its placement (it follows the sprite); the card only sizes itself.
*/
export function AgentDetailCard({ agent, onClose }: { agent: OfficeAgentInput; onClose: () => void }) {
return (
<div
role="dialog"
aria-label={t("Agent {name}", { name: agent.name })}
style={{
position: "absolute",
top: 16,
right: 16,
width: 264,
background: "var(--panel)",
border: "1px solid var(--border-strong)",
@@ -0,0 +1,34 @@
import type { Camera, Vec } from "./office-types";
export const CARD_W = 264;
export const CARD_FALLBACK_H = 170;
const GAP = 18;
const MARGIN = 8;
/** Vertical center of a drawn sprite relative to its anchor point. */
const SPRITE_CENTER_Y = -16;
/**
* Screen position for the detail card so it sits beside the clicked agent:
* to the right of the sprite, flipping to the left when the right edge would
* leave the viewport, and always clamped fully on screen. Pure, so tests can
* assert placement with the same math the page uses.
*/
export function cardPositionFor(
agent: Vec,
camera: Camera,
viewW: number,
viewH: number,
cardH: number = CARD_FALLBACK_H,
): { left: number; top: number } {
const screenX = agent.x - camera.x;
const screenY = agent.y - camera.y + SPRITE_CENTER_Y;
let left = screenX + GAP;
if (left + CARD_W > viewW - MARGIN) left = screenX - CARD_W - GAP;
left = clamp(left, MARGIN, Math.max(MARGIN, viewW - CARD_W - MARGIN));
const top = clamp(screenY - cardH / 2, MARGIN, Math.max(MARGIN, viewH - cardH - MARGIN));
return { left, top };
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
@@ -58,6 +58,12 @@ const AMENITIES: AmenitySpot[] = [
{ kind: "restroom", room: "restroom", stand: { x: 1084, y: 104 } },
{ kind: "restroom", room: "restroom", stand: { x: 1124, y: 104 } },
{ kind: "restroom", room: "restroom", stand: { x: 1164, y: 104 } },
// Standing spots along the south edge of the meeting table; meetings are
// group events the simulation convenes with at least two attendees.
{ kind: "meeting", room: "meeting", stand: { x: 412, y: 198 } },
{ kind: "meeting", room: "meeting", stand: { x: 452, y: 198 } },
{ kind: "meeting", room: "meeting", stand: { x: 492, y: 198 } },
{ kind: "meeting", room: "meeting", stand: { x: 532, y: 198 } },
];
interface Segment {
@@ -30,7 +30,7 @@ export interface DeskSlot {
seat: Vec;
}
export type AmenityKind = "coffee" | "snack" | "restroom";
export type AmenityKind = "coffee" | "snack" | "restroom" | "meeting";
export interface AmenitySpot {
kind: AmenityKind;
+45 -19
View File
@@ -44,12 +44,17 @@ const PLAYER_SPEED = 0.14;
const AGENT_SPEED = 0.085;
const MAX_STEP_MS = 100;
const WALK_CYCLE = 0.02;
/** An idle agent wanders off roughly every minute and a half. */
/** Everyone wanders off roughly every minute and a half, working or not. */
const MEAN_TRIP_INTERVAL_MS = 90_000;
/** Nobody leaves their desk in the first moments after settling in. */
const SETTLE_MS = 20_000;
const DWELL_MIN_MS = 6_000;
const DWELL_SPREAD_MS = 8_000;
/** Meetings run longer than a snack stop so attendees visibly overlap. */
const MEETING_MIN_MS = 18_000;
const MEETING_SPREAD_MS = 12_000;
/** A meeting is convened somewhere in the office every few minutes. */
const MEAN_MEETING_INTERVAL_MS = 180_000;
const CARRY_MIN_MS = 20_000;
const CARRY_SPREAD_MS = 20_000;
@@ -93,14 +98,16 @@ export class OfficeSimulation {
}
/**
* Advances the world by one frame: board-member movement plus agent life -
* idle agents wander to the restroom, snack bar, or coffee machine, dwell,
* and carry their coffee back; working agents head straight to their desk.
* 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 {
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.random() < dt / MEAN_MEETING_INTERVAL_MS) this.conveneMeeting();
}
private stepAgent(agent: AgentSim, dt: number): void {
@@ -111,13 +118,8 @@ export class OfficeSimulation {
agent.carrying = null;
}
}
const idle = agent.input.activity === "idle";
switch (agent.behavior.kind) {
case "seated": {
if (!idle) {
agent.settledMs = 0;
return;
}
agent.settledMs += dt;
if (agent.settledMs >= SETTLE_MS && this.random() < dt / MEAN_TRIP_INTERVAL_MS) {
agent.settledMs = 0;
@@ -126,18 +128,10 @@ export class OfficeSimulation {
return;
}
case "walking": {
if (!idle && agent.behavior.destination === "amenity") {
this.sendHome(agent);
return;
}
this.walkAlong(agent, dt);
return;
}
case "dwelling": {
if (!idle) {
this.finishDwell(agent);
return;
}
agent.behavior.remainingMs -= dt;
if (agent.behavior.remainingMs <= 0) this.finishDwell(agent);
return;
@@ -146,7 +140,10 @@ export class OfficeSimulation {
}
private startTrip(agent: AgentSim): void {
const spot = this.pickAmenity();
this.walkTo(agent, this.pickAmenity());
}
private walkTo(agent: AgentSim, spot: AmenitySpot): void {
agent.behavior = {
kind: "walking",
path: findPath(this.plan, { x: agent.x, y: agent.y }, spot.stand),
@@ -156,6 +153,7 @@ export class OfficeSimulation {
};
}
/** Solo breaks pick a personal amenity; meetings are convened separately. */
private pickAmenity(): AmenitySpot {
const roll = this.random();
const kind = roll < 0.4 ? "coffee" : roll < 0.65 ? "snack" : "restroom";
@@ -163,6 +161,31 @@ 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.
*/
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,
);
if (candidates.length < 2 || spots.length < 2) return 0;
const size = Math.min(spots.length, candidates.length, 2 + Math.floor(this.random() * 3));
const pool = [...candidates];
const chosen: AgentSim[] = [];
while (chosen.length < size && pool.length > 0) {
const index = Math.floor(this.random() * pool.length) % pool.length;
chosen.push(pool.splice(index, 1)[0]);
}
chosen.forEach((agent, position) => {
agent.settledMs = 0;
this.walkTo(agent, spots[position]);
});
return chosen.length;
}
private finishDwell(agent: AgentSim): void {
if (agent.behavior.kind === "dwelling") {
const kind = agent.behavior.spot.kind;
@@ -215,11 +238,14 @@ export class OfficeSimulation {
if (agent.behavior.kind !== "walking") return;
agent.walkPhase = 0;
if (agent.behavior.destination === "amenity" && agent.behavior.spot) {
const meeting = agent.behavior.spot.kind === "meeting";
agent.facing = "north";
agent.behavior = {
kind: "dwelling",
spot: agent.behavior.spot,
remainingMs: DWELL_MIN_MS + this.random() * DWELL_SPREAD_MS,
remainingMs: meeting
? MEETING_MIN_MS + this.random() * MEETING_SPREAD_MS
: DWELL_MIN_MS + this.random() * DWELL_SPREAD_MS,
};
return;
}
+10
View File
@@ -60,6 +60,16 @@ test("the office floor opens agent details on click", async () => {
await expect(card.getByText("Chief Executive Officer")).toBeVisible();
await expect(card.getByText("Executive", { exact: true })).toBeVisible();
// The card sits beside the clicked agent, not pinned to a screen corner.
const cardBox = await card.boundingBox();
expect(cardBox).not.toBeNull();
const gapToSprite = Math.min(
Math.abs(cardBox!.x - clickX),
Math.abs(cardBox!.x + cardBox!.width - clickX),
);
expect(gapToSprite).toBeLessThan(80);
expect(Math.abs(cardBox!.y + cardBox!.height / 2 - clickY)).toBeLessThan(160);
await page.keyboard.press("Escape");
await expect(card).not.toBeVisible();
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { cardPositionFor, CARD_W } from "../../src/renderer/src/pages/office/agent-card-position";
describe("agent detail card placement", () => {
test("sits to the right of the sprite, vertically centered on it", () => {
const position = cardPositionFor({ x: 400, y: 300 }, { x: 0, y: 0 }, 1200, 800, 170);
expect(position.left).toBe(418);
expect(position.top).toBe(300 - 16 - 85);
});
test("flips to the left when the right edge would leave the viewport", () => {
const position = cardPositionFor({ x: 1100, y: 300 }, { x: 0, y: 0 }, 1200, 800, 170);
expect(position.left).toBe(1100 - CARD_W - 18);
expect(position.left + CARD_W).toBeLessThanOrEqual(1200 - 8);
});
test("clamps fully on screen near every edge", () => {
for (const agent of [{ x: 5, y: 5 }, { x: 5, y: 795 }, { x: 1195, y: 5 }, { x: 1195, y: 795 }]) {
const position = cardPositionFor(agent, { x: 0, y: 0 }, 1200, 800, 170);
expect(position.left).toBeGreaterThanOrEqual(8);
expect(position.left + CARD_W).toBeLessThanOrEqual(1192);
expect(position.top).toBeGreaterThanOrEqual(8);
expect(position.top + 170).toBeLessThanOrEqual(792);
}
});
test("accounts for the camera offset", () => {
const onScreen = cardPositionFor({ x: 700, y: 500 }, { x: 300, y: 200 }, 1200, 800, 170);
const noCamera = cardPositionFor({ x: 400, y: 300 }, { x: 0, y: 0 }, 1200, 800, 170);
expect(onScreen).toEqual(noCamera);
});
});
+2
View File
@@ -30,6 +30,8 @@ describe("office floor plan", () => {
expect(plan.amenities.filter((a) => a.kind === "restroom").length).toBeGreaterThanOrEqual(2);
expect(plan.amenities.some((a) => a.kind === "coffee")).toBe(true);
expect(plan.amenities.some((a) => a.kind === "snack")).toBe(true);
// Meetings need at least two attendees, so the table has spots for them.
expect(plan.amenities.filter((a) => a.kind === "meeting").length).toBeGreaterThanOrEqual(2);
});
test("desk field grows by rows so no head-count is ever cut off", () => {
+53 -11
View File
@@ -121,30 +121,72 @@ describe("office simulation", () => {
expect(agent.carrying).toBe("coffee");
});
test("working agents never leave their desks", () => {
test("working agents (and the CEO) take breaks too", () => {
const sim = new OfficeSimulation(() => 0);
sim.sync([agentInput("a", "staff", "active")]);
sim.sync([agentInput("ceo", "c_suite", "active")]);
const agent = sim.agents[0];
for (let i = 0; i < 600; i++) sim.step(16, IDLE);
expect(agent.behavior.kind).toBe("seated");
expect(agent.x).toBe(agent.seat.x);
expect(agent.y).toBe(agent.seat.y);
let leftTheDesk = false;
for (let i = 0; i < 3000 && !leftTheDesk; i++) {
sim.step(16, IDLE);
if (agent.behavior.kind !== "seated") leftTheDesk = true;
}
expect(leftTheDesk).toBe(true);
});
test("an agent called to work abandons the break and returns to their desk", () => {
test("a snapshot refresh mid-break never recalls the walker", () => {
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.x === agent.seat.x && agent.y === agent.seat.y).toBe(false);
// Becoming busy does not cancel the human ritual of a coffee run.
sim.sync([agentInput("a", "staff", "active")]);
for (let i = 0; i < 6000 && sim.agents[0].behavior.kind !== "seated"; i++) sim.step(16, IDLE);
const refreshed = sim.agents[0];
expect(refreshed.behavior.kind).toBe("walking");
let dwelled = false;
for (let i = 0; i < 6000 && !dwelled; i++) {
sim.step(16, IDLE);
if (refreshed.behavior.kind === "dwelling") dwelled = true;
}
expect(dwelled).toBe(true);
});
test("meetings convene at least two attendees around the table, then everyone returns", () => {
const sim = new OfficeSimulation(() => 0);
sim.sync([agentInput("a", "staff", "active"), 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);
const walkers = sim.agents.filter((agent) => agent.behavior.kind === "walking");
expect(walkers).toHaveLength(attendees);
const spots = new Set(
walkers.map((agent) => (agent.behavior.kind === "walking" ? `${agent.behavior.spot?.stand.x}` : "")),
);
expect(spots.size).toBe(attendees);
// Everyone reaches the meeting room, dwells together, and heads home.
let overlapped = false;
for (let i = 0; i < 12000 && !overlapped; i++) {
sim.step(16, IDLE);
const dwellingInMeeting = sim.agents.filter(
(agent) => agent.behavior.kind === "dwelling" && agent.behavior.spot.kind === "meeting",
);
if (dwellingInMeeting.length >= 2) overlapped = true;
}
expect(overlapped).toBe(true);
});
test("a solo office never convenes a meeting", () => {
const sim = new OfficeSimulation(() => 0);
sim.sync([agentInput("a")]);
sim.agents[0].settledMs = 30_000;
expect(sim.conveneMeeting()).toBe(0);
expect(sim.agents[0].behavior.kind).toBe("seated");
expect(sim.agents[0].x).toBe(sim.agents[0].seat.x);
expect(sim.agents[0].y).toBe(sim.agents[0].seat.y);
});
test("clicks hit the agent drawn at that spot, frontmost first", () => {