Bring the floor to life: coffee runs, snack breaks, restroom trips
Idle agents now live a little: after settling in they occasionally walk the lane graph to the coffee machine, snack counter, or a restroom stall, dwell there, and stroll back - carrying their coffee or snack to the desk for a while. Working agents never leave, and an agent woken to work mid-break abandons the trip and heads straight home. Randomness is injected so the whole loop - trip, dwell, carry-back, interruption - is deterministic under unit test, and a settle period keeps the floor calm right after it loads.
This commit is contained in:
1 parent
e8c8414613
commit
574c7e0ec6
2 files changed
+218
-9
No files matched your search
@@ -1,7 +1,12 @@
|
||||
import { buildFloorPlan } from "./floor-plan";
|
||||
import type { Camera, FloorPlan, OfficeAgentInput, Rect, Vec } 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 =
|
||||
| { kind: "seated" }
|
||||
| { kind: "walking"; path: Vec[]; next: number; destination: "amenity" | "seat"; spot: AmenitySpot | null }
|
||||
| { kind: "dwelling"; spot: AmenitySpot; remainingMs: number };
|
||||
|
||||
export interface AgentSim {
|
||||
input: OfficeAgentInput;
|
||||
x: number;
|
||||
@@ -13,6 +18,9 @@ export interface AgentSim {
|
||||
facing: Facing;
|
||||
walkPhase: number;
|
||||
carrying: Carrying;
|
||||
behavior: AgentBehavior;
|
||||
carryRemainingMs: number;
|
||||
settledMs: number;
|
||||
}
|
||||
|
||||
export interface PlayerSim {
|
||||
@@ -33,8 +41,17 @@ export interface MoveInput {
|
||||
}
|
||||
|
||||
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. */
|
||||
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;
|
||||
const CARRY_MIN_MS = 20_000;
|
||||
const CARRY_SPREAD_MS = 20_000;
|
||||
|
||||
/**
|
||||
* Holds the living state of the office: who sits where and where everyone
|
||||
@@ -46,9 +63,11 @@ export class OfficeSimulation {
|
||||
agents: AgentSim[] = [];
|
||||
player: PlayerSim | null = null;
|
||||
private openDeskDemand = -1;
|
||||
private readonly random: () => number;
|
||||
|
||||
constructor() {
|
||||
constructor(random: () => number = Math.random) {
|
||||
this.plan = buildFloorPlan(0);
|
||||
this.random = random;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,10 +92,139 @@ export class OfficeSimulation {
|
||||
};
|
||||
}
|
||||
|
||||
/** Advances the world by one frame: board-member movement and collision. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
private stepAgent(agent: AgentSim, dt: number): void {
|
||||
if (agent.carryRemainingMs > 0) {
|
||||
agent.carryRemainingMs -= dt;
|
||||
if (agent.carryRemainingMs <= 0) {
|
||||
agent.carryRemainingMs = 0;
|
||||
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;
|
||||
this.startTrip(agent);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private startTrip(agent: AgentSim): void {
|
||||
const spot = this.pickAmenity();
|
||||
agent.behavior = {
|
||||
kind: "walking",
|
||||
path: findPath(this.plan, { x: agent.x, y: agent.y }, spot.stand),
|
||||
next: 1,
|
||||
destination: "amenity",
|
||||
spot,
|
||||
};
|
||||
}
|
||||
|
||||
private pickAmenity(): AmenitySpot {
|
||||
const roll = this.random();
|
||||
const kind = roll < 0.4 ? "coffee" : roll < 0.65 ? "snack" : "restroom";
|
||||
const options = this.plan.amenities.filter((spot) => spot.kind === kind);
|
||||
return options[Math.floor(this.random() * options.length) % options.length];
|
||||
}
|
||||
|
||||
private finishDwell(agent: AgentSim): void {
|
||||
if (agent.behavior.kind === "dwelling") {
|
||||
const kind = agent.behavior.spot.kind;
|
||||
if (kind === "coffee" || kind === "snack") {
|
||||
agent.carrying = kind;
|
||||
agent.carryRemainingMs = CARRY_MIN_MS + this.random() * CARRY_SPREAD_MS;
|
||||
}
|
||||
}
|
||||
this.sendHome(agent);
|
||||
}
|
||||
|
||||
private sendHome(agent: AgentSim): void {
|
||||
agent.behavior = {
|
||||
kind: "walking",
|
||||
path: findPath(this.plan, { x: agent.x, y: agent.y }, agent.seat),
|
||||
next: 1,
|
||||
destination: "seat",
|
||||
spot: null,
|
||||
};
|
||||
}
|
||||
|
||||
private walkAlong(agent: AgentSim, dt: number): void {
|
||||
if (agent.behavior.kind !== "walking") return;
|
||||
let budget = AGENT_SPEED * dt;
|
||||
while (budget > 0) {
|
||||
const target = agent.behavior.path[agent.behavior.next];
|
||||
if (!target) {
|
||||
this.arrive(agent);
|
||||
return;
|
||||
}
|
||||
const dx = target.x - agent.x;
|
||||
const dy = target.y - agent.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
if (distance <= budget) {
|
||||
agent.x = target.x;
|
||||
agent.y = target.y;
|
||||
agent.behavior.next += 1;
|
||||
budget -= distance;
|
||||
continue;
|
||||
}
|
||||
agent.x += (dx / distance) * budget;
|
||||
agent.y += (dy / distance) * budget;
|
||||
agent.facing = Math.abs(dx) >= Math.abs(dy) ? (dx < 0 ? "west" : "east") : (dy < 0 ? "north" : "south");
|
||||
budget = 0;
|
||||
}
|
||||
agent.walkPhase += dt * WALK_CYCLE;
|
||||
}
|
||||
|
||||
private arrive(agent: AgentSim): void {
|
||||
if (agent.behavior.kind !== "walking") return;
|
||||
agent.walkPhase = 0;
|
||||
if (agent.behavior.destination === "amenity" && agent.behavior.spot) {
|
||||
agent.facing = "north";
|
||||
agent.behavior = {
|
||||
kind: "dwelling",
|
||||
spot: agent.behavior.spot,
|
||||
remainingMs: DWELL_MIN_MS + this.random() * DWELL_SPREAD_MS,
|
||||
};
|
||||
return;
|
||||
}
|
||||
agent.facing = "south";
|
||||
agent.behavior = { kind: "seated" };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,7 +279,8 @@ export class OfficeSimulation {
|
||||
sync(inputs: OfficeAgentInput[]): void {
|
||||
const ceoId = inputs.find((input) => input.roleLevel === "c_suite")?.id ?? null;
|
||||
const openCount = inputs.filter((input) => input.id !== ceoId).length;
|
||||
if (openCount !== this.openDeskDemand) {
|
||||
const planRebuilt = openCount !== this.openDeskDemand;
|
||||
if (planRebuilt) {
|
||||
this.openDeskDemand = openCount;
|
||||
this.plan = buildFloorPlan(openCount);
|
||||
}
|
||||
@@ -143,7 +292,7 @@ export class OfficeSimulation {
|
||||
const deskIndex = isCeo ? null : openIndex++;
|
||||
const seat = isCeo ? this.plan.ceoSeat : this.plan.desks[deskIndex!].seat;
|
||||
const prev = existing.get(input.id);
|
||||
return {
|
||||
const agent: AgentSim = {
|
||||
input,
|
||||
x: prev?.x ?? seat.x,
|
||||
y: prev?.y ?? seat.y,
|
||||
@@ -154,7 +303,14 @@ export class OfficeSimulation {
|
||||
facing: prev?.facing ?? "south",
|
||||
walkPhase: prev?.walkPhase ?? 0,
|
||||
carrying: prev?.carrying ?? null,
|
||||
behavior: prev?.behavior ?? { kind: "seated" },
|
||||
carryRemainingMs: prev?.carryRemainingMs ?? 0,
|
||||
settledMs: prev?.settledMs ?? 0,
|
||||
};
|
||||
if (planRebuilt && prev && (agent.x !== seat.x || agent.y !== seat.y)) {
|
||||
this.sendHome(agent);
|
||||
}
|
||||
return agent;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { OfficeSimulation, cameraFor, type MoveInput } from "../../src/renderer/src/pages/office/simulation";
|
||||
import type { OfficeAgentInput } from "../../src/renderer/src/pages/office/office-types";
|
||||
import type { AgentActivity, OfficeAgentInput } from "../../src/renderer/src/pages/office/office-types";
|
||||
|
||||
const IDLE: MoveInput = { up: false, down: false, left: false, right: false };
|
||||
|
||||
function agentInput(id: string, roleLevel = "staff"): OfficeAgentInput {
|
||||
function agentInput(id: string, roleLevel = "staff", activity: AgentActivity = "idle"): OfficeAgentInput {
|
||||
return {
|
||||
id,
|
||||
name: `Agent ${id}`,
|
||||
@@ -12,7 +12,7 @@ function agentInput(id: string, roleLevel = "staff"): OfficeAgentInput {
|
||||
title: "Engineer",
|
||||
department: "Engineering",
|
||||
roleLevel,
|
||||
activity: "idle",
|
||||
activity,
|
||||
taskTitle: null,
|
||||
};
|
||||
}
|
||||
@@ -94,6 +94,59 @@ describe("office simulation", () => {
|
||||
expect(diagonal).toBeGreaterThan(straight * 0.7);
|
||||
});
|
||||
|
||||
test("idle agents take a coffee break and carry the cup back to their desk", () => {
|
||||
const sim = new OfficeSimulation(() => 0);
|
||||
sim.sync([agentInput("a")]);
|
||||
const agent = sim.agents[0];
|
||||
const coffee = sim.plan.amenities.find((spot) => spot.kind === "coffee")!;
|
||||
|
||||
let reachedCoffee = false;
|
||||
let backSeated = false;
|
||||
for (let i = 0; i < 6000; i++) {
|
||||
sim.step(16, IDLE);
|
||||
if (agent.behavior.kind === "dwelling") {
|
||||
expect(agent.x).toBe(coffee.stand.x);
|
||||
expect(agent.y).toBe(coffee.stand.y);
|
||||
reachedCoffee = true;
|
||||
}
|
||||
if (reachedCoffee && agent.behavior.kind === "seated") {
|
||||
backSeated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(reachedCoffee).toBe(true);
|
||||
expect(backSeated).toBe(true);
|
||||
expect(agent.x).toBe(agent.seat.x);
|
||||
expect(agent.y).toBe(agent.seat.y);
|
||||
expect(agent.carrying).toBe("coffee");
|
||||
});
|
||||
|
||||
test("working agents never leave their desks", () => {
|
||||
const sim = new OfficeSimulation(() => 0);
|
||||
sim.sync([agentInput("a", "staff", "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);
|
||||
});
|
||||
|
||||
test("an agent called to work abandons the break and returns to their 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.x === agent.seat.x && agent.y === agent.seat.y).toBe(false);
|
||||
|
||||
sim.sync([agentInput("a", "staff", "active")]);
|
||||
for (let i = 0; i < 6000 && sim.agents[0].behavior.kind !== "seated"; i++) sim.step(16, IDLE);
|
||||
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", () => {
|
||||
const sim = new OfficeSimulation();
|
||||
sim.sync([agentInput("a"), agentInput("b")]);
|
||||
|
||||
Reference in new issue
Block a user