@blkc-code/render-canvas 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/world.ts ADDED
@@ -0,0 +1,466 @@
1
+ /**
2
+ * The office as data: which tiles can be walked on, who is where, and where they are
3
+ * heading. Nothing in here touches a canvas, so all of it can be tested.
4
+ */
5
+
6
+ import type { AgentState, FloorStyle, FurnitureType, OfficeAgent, OfficeDoor, OfficeFurniture, OfficeLayout, OfficeRoom, OfficeScene, OfficeTask, TaskState } from "./types.js";
7
+
8
+ export const MAX_COLS = 64;
9
+ export const MAX_ROWS = 40;
10
+ const MAX_AGENTS = 40;
11
+ const MAX_FURNITURE = 300;
12
+ const MAX_TASKS = 60;
13
+ export const WALK_SPEED = 4.5; // tiles a second
14
+
15
+ /** Footprint in tiles, and where someone stands to use it, relative to its corner. */
16
+ export const FURNITURE: Record<FurnitureType, { w: number; h: number; solid?: boolean; wall?: boolean; spot?: { x: number; y: number } }> = {
17
+ desk: { w: 3, h: 2, spot: { x: 1, y: 2 } },
18
+ coffee: { w: 2, h: 1, spot: { x: 0, y: 1 } },
19
+ water: { w: 1, h: 1, spot: { x: 0, y: 1 } },
20
+ sofa: { w: 3, h: 1, spot: { x: 1, y: 1 } },
21
+ plant: { w: 1, h: 1 },
22
+ fern: { w: 1, h: 1 },
23
+ board: { w: 4, h: 1, spot: { x: 1, y: 1 } },
24
+ whiteboard: { w: 3, h: 1, spot: { x: 1, y: 1 } },
25
+ shelf: { w: 2, h: 1, spot: { x: 0, y: 1 } },
26
+ server: { w: 1, h: 1 },
27
+ table: { w: 3, h: 2, spot: { x: 1, y: 2 } },
28
+ rug: { w: 4, h: 3, solid: false },
29
+ arcade: { w: 1, h: 1, spot: { x: 0, y: 1 } },
30
+ fridge: { w: 1, h: 1, spot: { x: 0, y: 1 } },
31
+ printer: { w: 1, h: 1, spot: { x: 0, y: 1 } },
32
+ lamp: { w: 1, h: 1 },
33
+ beanbag: { w: 1, h: 1, spot: { x: 0, y: 1 } },
34
+ tv: { w: 3, h: 1, spot: { x: 1, y: 2 } },
35
+ vending: { w: 1, h: 1, spot: { x: 0, y: 1 } },
36
+ trophy: { w: 1, h: 1 },
37
+ clock: { w: 1, h: 1, solid: false, wall: true },
38
+ poster: { w: 1, h: 1, solid: false, wall: true },
39
+ };
40
+
41
+ const AGENT_STATES: readonly string[] = ["working", "thinking", "blocked", "reviewing", "idle", "done", "away"];
42
+ const TASK_STATES: readonly string[] = ["todo", "doing", "done"];
43
+ const FLOORS: readonly string[] = ["wood", "carpet", "tile", "stone", "concrete", "grass"];
44
+
45
+ export const DEFAULT_LAYOUT = {
46
+ cols: 26,
47
+ rows: 17,
48
+ rooms: [
49
+ { id: "studio", name: "Studio", x: 1, y: 3, w: 15, h: 13, floor: "wood" },
50
+ { id: "lounge", name: "Lounge", x: 17, y: 3, w: 8, h: 6, floor: "carpet" },
51
+ { id: "quests", name: "Quests", x: 17, y: 10, w: 8, h: 6, floor: "tile" },
52
+ ] as OfficeRoom[],
53
+ doors: [{ x: 16, y: 6 }, { x: 16, y: 13 }, { x: 23, y: 9 }] as OfficeDoor[],
54
+ furniture: [
55
+ { type: "desk", x: 2, y: 4 }, { type: "desk", x: 7, y: 4 }, { type: "desk", x: 12, y: 4 },
56
+ { type: "desk", x: 2, y: 10 }, { type: "desk", x: 7, y: 10 }, { type: "desk", x: 12, y: 10 },
57
+ { type: "plant", x: 1, y: 8 }, { type: "plant", x: 15, y: 8 }, { type: "server", x: 15, y: 15 }, { type: "server", x: 14, y: 15 },
58
+ { type: "shelf", x: 1, y: 15 },
59
+ { type: "coffee", x: 19, y: 3 }, { type: "water", x: 21, y: 3 }, { type: "sofa", x: 22, y: 3 }, { type: "rug", x: 20, y: 5 }, { type: "plant", x: 24, y: 8 },
60
+ { type: "board", x: 19, y: 10 }, { type: "table", x: 19, y: 13 }, { type: "plant", x: 24, y: 15 },
61
+ ] as OfficeFurniture[],
62
+ };
63
+
64
+ const int = (value: unknown, fallback: number): number => (typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : fallback);
65
+ const str = (value: unknown, max = 120): string => (typeof value === "string" ? value.slice(0, max) : "");
66
+ const list = <T>(value: unknown, max: number): T[] => (Array.isArray(value) ? (value.slice(0, max) as T[]) : []);
67
+
68
+ export interface Room { id: string; name: string; x: number; y: number; w: number; h: number; floor: FloorStyle; paths: string[] }
69
+ export interface Item { type: FurnitureType; x: number; y: number }
70
+ export interface Task { title: string; state: TaskState; agent: string }
71
+
72
+ export interface Agent {
73
+ id: string;
74
+ name: string;
75
+ role: string;
76
+ state: AgentState;
77
+ task: string;
78
+ area: string;
79
+ /** Seconds of celebration left after finishing something. */
80
+ party: number;
81
+ hair: string;
82
+ skin: string;
83
+ shirt: string;
84
+ /** Position in tiles; x/y is the tile the feet are on. */
85
+ x: number;
86
+ y: number;
87
+ path: { x: number; y: number }[];
88
+ facing: "up" | "down" | "left" | "right";
89
+ walking: boolean;
90
+ seat?: { x: number; y: number };
91
+ /** Seconds until an idle agent picks somewhere new. */
92
+ rest: number;
93
+ seed: number;
94
+ }
95
+
96
+ export interface World {
97
+ cols: number;
98
+ rows: number;
99
+ rooms: Room[];
100
+ doors: OfficeDoor[];
101
+ furniture: Item[];
102
+ agents: Agent[];
103
+ tasks: Task[];
104
+ walkable: boolean[];
105
+ spots: { lounge: { x: number; y: number }[]; board: { x: number; y: number }[] };
106
+ seats: { x: number; y: number }[];
107
+ }
108
+
109
+ export function hash(text: string): number {
110
+ let value = 2166136261;
111
+ for (let i = 0; i < text.length; i++) value = Math.imul(value ^ text.charCodeAt(i), 16777619);
112
+ return value >>> 0;
113
+ }
114
+
115
+ const HAIR = ["#2b1b12", "#5a3a22", "#a8622d", "#d9b36c", "#20242e", "#7d2f3f", "#4a5568", "#e5e7eb"];
116
+ const SKIN = ["#f4d3b5", "#e8b98f", "#c98d62", "#9c6644", "#6f4530", "#4e2f20"];
117
+ const SHIRT = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899", "#14b8a6", "#f97316"];
118
+
119
+ // A theme value becomes a canvas colour, so anything but a plain colour is refused.
120
+ const colour = (value: unknown, fallback: string): string =>
121
+ typeof value === "string" && /^#[0-9a-f]{3,8}$/i.test(value) ? value : fallback;
122
+
123
+ const at = (world: { cols: number }, x: number, y: number): number => y * world.cols + x;
124
+
125
+ export function isWalkable(world: World, x: number, y: number): boolean {
126
+ return x >= 0 && y >= 0 && x < world.cols && y < world.rows && world.walkable[at(world, x, y)] === true;
127
+ }
128
+
129
+ /** Shortest walk between two tiles, or null if they aren't connected. Excludes the start. */
130
+ export function findPath(world: World, from: { x: number; y: number }, to: { x: number; y: number }): { x: number; y: number }[] | null {
131
+ if (!isWalkable(world, to.x, to.y)) return null;
132
+ if (from.x === to.x && from.y === to.y) return [];
133
+ const previous = new Int32Array(world.cols * world.rows).fill(-1);
134
+ const start = at(world, from.x, from.y);
135
+ previous[start] = start;
136
+ const queue = [start];
137
+ for (let head = 0; head < queue.length; head++) {
138
+ const current = queue[head] as number;
139
+ const x = current % world.cols;
140
+ const y = Math.floor(current / world.cols);
141
+ if (x === to.x && y === to.y) {
142
+ const path: { x: number; y: number }[] = [];
143
+ for (let step = current; step !== start; step = previous[step] as number) {
144
+ path.unshift({ x: step % world.cols, y: Math.floor(step / world.cols) });
145
+ }
146
+ return path;
147
+ }
148
+ for (const [dx, dy] of [[0, 1], [0, -1], [1, 0], [-1, 0]] as const) {
149
+ const nx = x + dx;
150
+ const ny = y + dy;
151
+ if (!isWalkable(world, nx, ny)) continue;
152
+ const index = at(world, nx, ny);
153
+ if (previous[index] !== -1) continue;
154
+ previous[index] = current;
155
+ queue.push(index);
156
+ }
157
+ }
158
+ return null;
159
+ }
160
+
161
+ function toAgent(raw: OfficeAgent, index: number): Agent {
162
+ const id = str(raw?.id, 60) || `agent-${index + 1}`;
163
+ const seed = hash(id);
164
+ const state = AGENT_STATES.includes(raw?.state as string) ? (raw.state as AgentState) : "idle";
165
+ return {
166
+ id,
167
+ name: str(raw?.name, 24) || id,
168
+ role: str(raw?.role, 40),
169
+ state,
170
+ task: str(raw?.task, 120),
171
+ area: str(raw?.area, 60),
172
+ party: 0,
173
+ hair: colour(raw?.hair, HAIR[seed % HAIR.length] as string),
174
+ skin: colour(raw?.skin, SKIN[(seed >>> 4) % SKIN.length] as string),
175
+ shirt: colour(raw?.shirt, SHIRT[(seed >>> 8) % SHIRT.length] as string),
176
+ x: 0,
177
+ y: 0,
178
+ path: [],
179
+ facing: "down",
180
+ walking: false,
181
+ rest: 0,
182
+ seed,
183
+ };
184
+ }
185
+
186
+ function toTask(raw: OfficeTask): Task {
187
+ return { title: str(raw?.title, 80), state: TASK_STATES.includes(raw?.state as string) ? (raw.state as TaskState) : "todo", agent: str(raw?.agent, 60) };
188
+ }
189
+
190
+ function buildLayout(scene: OfficeLayout): Pick<World, "cols" | "rows" | "rooms" | "doors" | "furniture" | "walkable" | "spots" | "seats"> {
191
+ const custom = Array.isArray(scene?.rooms) && scene.rooms.length > 0;
192
+ const cols = Math.min(Math.max(int(scene?.cols, custom ? 20 : DEFAULT_LAYOUT.cols), 6), MAX_COLS);
193
+ const rows = Math.min(Math.max(int(scene?.rows, custom ? 14 : DEFAULT_LAYOUT.rows), 6), MAX_ROWS);
194
+ const rawRooms = custom ? list<OfficeRoom>(scene.rooms, 40) : DEFAULT_LAYOUT.rooms;
195
+ const rawDoors = custom ? list<OfficeDoor>(scene.doors, 60) : DEFAULT_LAYOUT.doors;
196
+ const rawItems = Array.isArray(scene?.furniture) ? list<OfficeFurniture>(scene.furniture, MAX_FURNITURE) : custom ? [] : DEFAULT_LAYOUT.furniture;
197
+
198
+ // Clamped to the grid: everything later loops over these, so a huge room must not hang the page.
199
+ const rooms: Room[] = rawRooms.map((room, index) => {
200
+ const x = Math.min(Math.max(int(room?.x, 0), 0), cols);
201
+ const y = Math.min(Math.max(int(room?.y, 0), 0), rows);
202
+ return {
203
+ id: str(room?.id, 60) || `room-${index + 1}`,
204
+ name: str(room?.name, 24),
205
+ x, y,
206
+ w: Math.min(Math.max(int(room?.w, 0), 0), cols - x),
207
+ h: Math.min(Math.max(int(room?.h, 0), 0), rows - y),
208
+ floor: FLOORS.includes(room?.floor as string) ? (room.floor as FloorStyle) : "wood",
209
+ paths: list<string>(room?.paths, 20).filter((p) => typeof p === "string").map((p) => p.slice(0, 200)),
210
+ };
211
+ });
212
+ const walkable = new Array<boolean>(cols * rows).fill(false);
213
+ const mark = (x: number, y: number, value: boolean): void => {
214
+ if (x >= 0 && y >= 0 && x < cols && y < rows) walkable[y * cols + x] = value;
215
+ };
216
+ for (const room of rooms) {
217
+ for (let y = room.y; y < room.y + room.h; y++) for (let x = room.x; x < room.x + room.w; x++) mark(x, y, true);
218
+ }
219
+ const doors = rawDoors.map((door) => ({ x: int(door?.x, -1), y: int(door?.y, -1) })).filter((door) => door.x >= 0 && door.y >= 0);
220
+ for (const door of doors) mark(door.x, door.y, true);
221
+
222
+ const furniture: Item[] = rawItems
223
+ .filter((item) => item && Object.hasOwn(FURNITURE, item.type))
224
+ .map((item) => ({ type: item.type, x: int(item.x, -99), y: int(item.y, -99) }));
225
+ for (const item of furniture) {
226
+ const spec = FURNITURE[item.type];
227
+ if (spec.solid === false) continue;
228
+ for (let y = item.y; y < item.y + spec.h; y++) for (let x = item.x; x < item.x + spec.w; x++) mark(x, y, false);
229
+ }
230
+
231
+ const open = (x: number, y: number): boolean => x >= 0 && y >= 0 && x < cols && y < rows && walkable[y * cols + x] === true;
232
+ const spot = (item: Item): { x: number; y: number } | undefined => {
233
+ const offset = FURNITURE[item.type].spot;
234
+ if (!offset) return undefined;
235
+ const point = { x: item.x + offset.x, y: item.y + offset.y };
236
+ return open(point.x, point.y) ? point : undefined;
237
+ };
238
+ const seats = furniture.filter((item) => item.type === "desk").map(spot).filter((p): p is { x: number; y: number } => Boolean(p));
239
+ const spots: World["spots"] = { lounge: [], board: [] };
240
+ for (const item of furniture) {
241
+ const point = spot(item);
242
+ if (!point) continue;
243
+ if (["coffee", "sofa", "water", "shelf", "arcade", "fridge", "beanbag", "vending", "tv"].includes(item.type)) spots.lounge.push(point);
244
+ if (["board", "table", "whiteboard"].includes(item.type)) spots.board.push(point);
245
+ }
246
+ return { cols, rows, rooms, doors, furniture, walkable, seats, spots };
247
+ }
248
+
249
+ /** Build a world from a scene. Bad values are dropped or clamped, so it never throws. */
250
+ export function createWorld(scene: OfficeScene = {}): World {
251
+ const world: World = {
252
+ ...buildLayout(scene),
253
+ agents: list<OfficeAgent>(scene?.agents, MAX_AGENTS).map(toAgent),
254
+ tasks: list<OfficeTask>(scene?.tasks, MAX_TASKS).map(toTask),
255
+ };
256
+ for (const agent of world.agents) placeAgent(world, agent);
257
+ return world;
258
+ }
259
+
260
+ /** Change the building while everyone is in it. Anyone left inside a wall or a desk moves. */
261
+ export function applyLayout(world: World, layout: OfficeLayout): void {
262
+ Object.assign(world, buildLayout(layout));
263
+ for (const agent of world.agents) {
264
+ agent.seat = undefined;
265
+ agent.path = [];
266
+ agent.x = Math.round(agent.x);
267
+ agent.y = Math.round(agent.y);
268
+ if (!isWalkable(world, agent.x, agent.y)) {
269
+ placeAgent(world, agent);
270
+ } else {
271
+ updateSeat(world, agent);
272
+ routeTo(world, agent, goalFor(world, agent));
273
+ }
274
+ }
275
+ }
276
+
277
+ function firstWalkable(world: World): { x: number; y: number } {
278
+ for (let y = 0; y < world.rows; y++) for (let x = 0; x < world.cols; x++) if (isWalkable(world, x, y)) return { x, y };
279
+ return { x: 0, y: 0 };
280
+ }
281
+
282
+ const NEEDS_DESK: readonly AgentState[] = ["working", "thinking", "blocked"];
283
+
284
+ /** Desks belong to whoever is working at one: claimed when an agent starts, freed when it stops. */
285
+ function updateSeat(world: World, agent: Agent): void {
286
+ if (!NEEDS_DESK.includes(agent.state)) {
287
+ agent.seat = undefined;
288
+ return;
289
+ }
290
+ const room = world.rooms.find((candidate) => candidate.id === agent.area);
291
+ const inRoom = (seat: { x: number; y: number }): boolean =>
292
+ Boolean(room) && seat.x >= (room as Room).x && seat.x < (room as Room).x + (room as Room).w && seat.y >= (room as Room).y && seat.y < (room as Room).y + (room as Room).h;
293
+ // Moved to another room: this desk is no longer the right one.
294
+ if (agent.seat && (!room || inRoom(agent.seat))) return;
295
+ agent.seat = undefined;
296
+ const taken = new Set(world.agents.filter((other) => other !== agent && other.seat).map((other) => `${other.seat?.x},${other.seat?.y}`));
297
+ const free = world.seats.filter((seat) => !taken.has(`${seat.x},${seat.y}`));
298
+ agent.seat = room ? free.find(inRoom) : free[0];
299
+ }
300
+
301
+ /** Where an agent is standing, or heading to if it is walking. */
302
+ function standing(agent: Agent): { x: number; y: number } {
303
+ const end = agent.path[agent.path.length - 1];
304
+ return end ?? { x: Math.round(agent.x), y: Math.round(agent.y) };
305
+ }
306
+
307
+ /** The closest tile to a spot that no other agent is on, so they don't stand inside each other. */
308
+ function freeTileNear(world: World, agent: Agent, spot: { x: number; y: number }): { x: number; y: number } {
309
+ const others = new Set(world.agents.filter((other) => other !== agent && other.state !== "away").map((other) => {
310
+ const at = standing(other);
311
+ return `${at.x},${at.y}`;
312
+ }));
313
+ for (let radius = 0; radius <= 4; radius++) {
314
+ for (let dy = -radius; dy <= radius; dy++) {
315
+ for (let dx = -radius; dx <= radius; dx++) {
316
+ if (Math.abs(dx) + Math.abs(dy) !== radius) continue;
317
+ const tile = { x: spot.x + dx, y: spot.y + dy };
318
+ if (isWalkable(world, tile.x, tile.y) && !others.has(`${tile.x},${tile.y}`) && findPath(world, spot, tile)) return tile;
319
+ }
320
+ }
321
+ }
322
+ return spot;
323
+ }
324
+
325
+ /** Where this agent's current state wants it to be. */
326
+ export function goalFor(world: World, agent: Agent): { x: number; y: number } | undefined {
327
+ const pick = (spots: { x: number; y: number }[]): { x: number; y: number } | undefined => {
328
+ const spot = spots.length > 0 ? spots[(agent.seed + Math.floor(agent.rest * 1000)) % spots.length] : undefined;
329
+ return spot && freeTileNear(world, agent, spot);
330
+ };
331
+ switch (agent.state) {
332
+ case "working":
333
+ case "thinking":
334
+ case "blocked": {
335
+ if (agent.seat) return agent.seat;
336
+ // No free desk in the room they are working in: they stand in it anyway.
337
+ const room = world.rooms.find((candidate) => candidate.id === agent.area);
338
+ if (room && room.w > 0 && room.h > 0) return freeTileNear(world, agent, { x: room.x + (room.w >> 1), y: room.y + (room.h >> 1) });
339
+ return pick(world.spots.lounge);
340
+ }
341
+ case "reviewing":
342
+ return pick(world.spots.board) ?? agent.seat;
343
+ case "idle":
344
+ case "done":
345
+ return pick(world.spots.lounge) ?? agent.seat;
346
+ default:
347
+ return undefined;
348
+ }
349
+ }
350
+
351
+ function placeAgent(world: World, agent: Agent): void {
352
+ updateSeat(world, agent);
353
+ const goal = goalFor(world, agent) ?? agent.seat ?? firstWalkable(world);
354
+ agent.x = goal.x;
355
+ agent.y = goal.y;
356
+ agent.path = [];
357
+ agent.walking = false;
358
+ agent.facing = isSeated(agent) ? "up" : "down";
359
+ agent.rest = 2 + (agent.seed % 50) / 10;
360
+ }
361
+
362
+ export const isSeated = (agent: Agent): boolean =>
363
+ (agent.state === "working" || agent.state === "thinking" || agent.state === "blocked") &&
364
+ agent.seat !== undefined && agent.seat.x === agent.x && agent.seat.y === agent.y && agent.path.length === 0;
365
+
366
+ function routeTo(world: World, agent: Agent, goal: { x: number; y: number } | undefined): void {
367
+ if (!goal) return;
368
+ const from = { x: Math.round(agent.x), y: Math.round(agent.y) };
369
+ agent.path = findPath(world, from, goal) ?? [];
370
+ }
371
+
372
+ /** Advance the whole office by `dt` seconds. */
373
+ export function step(world: World, dt: number): void {
374
+ for (const agent of world.agents) {
375
+ if (agent.state === "away") continue;
376
+
377
+ if (agent.path.length > 0) {
378
+ const target = agent.path[0] as { x: number; y: number };
379
+ const dx = target.x - agent.x;
380
+ const dy = target.y - agent.y;
381
+ const distance = Math.hypot(dx, dy);
382
+ const move = WALK_SPEED * dt;
383
+ if (Math.abs(dx) > Math.abs(dy)) agent.facing = dx > 0 ? "right" : "left";
384
+ else if (dy !== 0) agent.facing = dy > 0 ? "down" : "up";
385
+ agent.walking = true;
386
+ if (distance <= move) {
387
+ agent.x = target.x;
388
+ agent.y = target.y;
389
+ agent.path.shift();
390
+ } else {
391
+ agent.x += (dx / distance) * move;
392
+ agent.y += (dy / distance) * move;
393
+ }
394
+ continue;
395
+ }
396
+
397
+ agent.walking = false;
398
+ if (agent.party > 0) agent.party = Math.max(0, agent.party - dt);
399
+ if (agent.state === "idle") {
400
+ agent.rest -= dt;
401
+ if (agent.rest <= 0) {
402
+ agent.seed = hash(String(agent.seed)); // somewhere new, the same way each run
403
+ agent.rest = 3 + (agent.seed % 60) / 10;
404
+ routeTo(world, agent, goalFor(world, agent));
405
+ }
406
+ }
407
+ if (isSeated(agent)) agent.facing = "up";
408
+ }
409
+ }
410
+
411
+ /** Change what people are doing without restarting them. New agents appear at their goal. */
412
+ export function applyUpdate(world: World, scene: Pick<OfficeScene, "agents" | "tasks">): void {
413
+ if (Array.isArray(scene.tasks)) world.tasks = list<OfficeTask>(scene.tasks, MAX_TASKS).map(toTask);
414
+ if (!Array.isArray(scene.agents)) return;
415
+ const existing = new Map(world.agents.map((agent) => [agent.id, agent]));
416
+ const incoming = list<OfficeAgent>(scene.agents, MAX_AGENTS).map(toAgent);
417
+ world.agents = incoming.map((next) => existing.get(next.id)).filter((agent): agent is Agent => Boolean(agent));
418
+ const updated = incoming.map((next) => {
419
+ const current = existing.get(next.id);
420
+ if (!current) {
421
+ placeAgent(world, next);
422
+ world.agents = [...world.agents, next];
423
+ // New arrivals come in through a door and walk to where they are going.
424
+ const door = world.doors[0];
425
+ const goal = { x: next.x, y: next.y };
426
+ if (door && next.state !== "away") {
427
+ next.x = door.x;
428
+ next.y = door.y;
429
+ routeTo(world, next, goal);
430
+ }
431
+ return next;
432
+ }
433
+ Object.assign(current, { name: next.name, role: next.role, task: next.task, hair: next.hair, skin: next.skin, shirt: next.shirt });
434
+ const moved = current.area !== next.area;
435
+ current.area = next.area;
436
+ if (current.state !== next.state || moved) {
437
+ if (next.state === "done" && current.state !== "done") current.party = 3;
438
+ current.state = next.state;
439
+ updateSeat(world, current);
440
+ if (next.state !== "away") routeTo(world, current, goalFor(world, current));
441
+ } else if (NEEDS_DESK.includes(current.state) && !current.seat) {
442
+ // No desk was free before. One may be now.
443
+ updateSeat(world, current);
444
+ if (current.seat) routeTo(world, current, current.seat);
445
+ }
446
+ return current;
447
+ });
448
+ world.agents = updated;
449
+ }
450
+
451
+ /** Agent under a point given in tiles, nearest first. */
452
+ export function agentAt(world: World, x: number, y: number): Agent | undefined {
453
+ let best: Agent | undefined;
454
+ let bestDistance = Infinity;
455
+ for (const agent of world.agents) {
456
+ if (agent.state === "away") continue;
457
+ const dx = x - (agent.x + 0.5);
458
+ const dy = y - (agent.y + 0.1);
459
+ const distance = Math.hypot(dx, dy);
460
+ if (Math.abs(dx) < 0.8 && dy > -1.6 && dy < 1.1 && distance < bestDistance) {
461
+ best = agent;
462
+ bestDistance = distance;
463
+ }
464
+ }
465
+ return best;
466
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.