@polycode-projects/the-mechanical-code-talker 5.0.0 → 5.0.2

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.
@@ -0,0 +1,1247 @@
1
+ // mudiii-viz.mjs — mudiii.html: the one-player town-square demo PLAN_MUD_MUDIII.md
2
+ // describes, over a real three.js scene rather than mud.html's canvas-drawn
3
+ // room boxes. mud.html's whole control deck carries over unchanged (same
4
+ // ids, same ranges, same defaults — see MUDIII_STYLE and renderMudiiiHtml's
5
+ // own header below for the two sliders' new labels); what mudiii adds is a
6
+ // free camera (follow/pov/overhead), a single shared conversation instead of
7
+ // one pane per player, and an omniscient top-down map panel standing in for
8
+ // mud.html's burrow survey.
9
+ //
10
+ // This module owns the PAGE SHELL only. The 3D scene itself — the actual
11
+ // three.js renderer, the model loader, the per-frame camera update — is a
12
+ // concurrent track's file, src/services/mudiii-scene.mjs, reached through
13
+ // exactly one frozen function: `mudiiiSceneScript(opts) -> string`, a
14
+ // standalone inline <script> this page embeds next to its own. That module
15
+ // does not exist in every worktree yet (see the guarded import below), so
16
+ // this file also ships a "" stub until it lands — no edit needed here when
17
+ // it does.
18
+ //
19
+ // The contract runs both ways. Scene -> shell: the scene script calls
20
+ // `window.mudiiiHandleSceneClick(cellId)` on a raycast hit. Shell -> scene:
21
+ // this page's own script calls `window.mudiiiScene.boot(...)` once per
22
+ // world/reset/scenario switch, `.applyTick(...)` every tick, and
23
+ // `.setCamera(...)` on every camera-mode change, agent-select change and
24
+ // `nextCameraSelection` fallback (see `callScene`, below) — every call
25
+ // guarded, because a failed three.js vendor load (~800 KB) must never take
26
+ // the map panel, the HUD, the deck or the chat down with it.
27
+ //
28
+ // Deliberately absent, all P2P (mud.html's #statePill, share/join buttons,
29
+ // the share overlay, the wave button, the compass ring): this is the
30
+ // 1-player page. A later document adds sharing back from the same bundle.
31
+ //
32
+ // Two corrections applied against PLAN_MUD_MUDIII.md's own text (see
33
+ // AGENTS.md/the dispatch brief this track was built from): the page
34
+ // publishes through the ONE `globalThis.tmct` surface (tmct-surface.mjs),
35
+ // never a page-scoped `tmctMudiii` bag; and `createTicker`/`createSerialQueue`
36
+ // are spliced into the page script from viz-ticker.mjs, not carried by the
37
+ // browser entry.
38
+ //
39
+ // A THIRD correction, found while wiring the deck's own play control: the
40
+ // brief's "one createTicker per agent" does not fit the engine contract it
41
+ // was written against. `runPredatorPreyTick(memoryDir, opts)` returns
42
+ // `{ turn, agents, items, ecology }` for the WHOLE WORLD in one call — it has
43
+ // no per-character entry point the way mud-turn.mjs's `runMudTurn(character,
44
+ // ...)` does, so there is nothing for a second or third ticker to drive that
45
+ // the first one has not already advanced. This page runs ONE shared ticker
46
+ // for the whole simulation, serialized through the same createSerialQueue
47
+ // mud.html uses for its own multi-pane writes; every HUD card still reads
48
+ // its own agent's slice of the one tick result, which is what "per agent"
49
+ // was actually asking for.
50
+ import {
51
+ THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, scenarioLabel,
52
+ rowsForWorld, appendLogLine,
53
+ } from "./viz-theme.mjs";
54
+ import { createTicker, createSerialQueue } from "./viz-ticker.mjs";
55
+ import { renderMudEditorText, gridWorldEditorState } from "./mud-editor.mjs";
56
+ import {
57
+ pillCandidates, matchPills, pillCompleteMarkup, createPillComplete, PILL_COMPLETE_CSS,
58
+ } from "./pill-complete.mjs";
59
+ import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
60
+
61
+ // The scene module and this one import each other: this file embeds the
62
+ // scene's generated IIFE, and the scene splices this file's pure geometry
63
+ // helpers into it. A static cycle is fine here because every binding crossing
64
+ // it is a hoisted function declaration that nothing calls at module-evaluation
65
+ // time — the same shape world-teach.mjs and adventure.mjs already rely on. It
66
+ // must NOT be a top-level `await import()`: two modules awaiting each other at
67
+ // evaluation time never settle, and the failure is a silent hang rather than
68
+ // an error.
69
+ import { mudiiiSceneScript } from "./mudiii-scene.mjs";
70
+
71
+ const DEFAULT_TITLE = "tmct — mudiii";
72
+ const DISPLAY_STACK = SERIF_STACK;
73
+ const SANS_STACK = `"IBM Plex Sans", "Inter", -apple-system, BlinkMacSystemFont, sans-serif`;
74
+
75
+ // The deck's own detents, copied from mud-viz.mjs verbatim: every count here
76
+ // still divides evenly, and the sliders keep mud.html's exact ranges and
77
+ // defaults even though this page relabels what they size (foxes/goblins,
78
+ // never "players"/"npcs" — neither is a player on a one-player page).
79
+ const PLAYER_COUNTS = [1, 2, 4];
80
+ const DEFAULT_PLAYER_COUNT = 2;
81
+ const NPC_COUNT_MIN = 1;
82
+ const NPC_COUNT_MAX = 10;
83
+ const NPC_COUNT_LABELLED = [1, 5, 10];
84
+ const DEFAULT_NPC_COUNT = 2;
85
+ const DEFAULT_DELAY_MS = 650;
86
+ const DEFAULT_MAX_TURNS = 400;
87
+ // test/fixtures/mudiii-ticks.json's own board size — the fallback for a
88
+ // scenario that names no gridSize of its own.
89
+ const DEFAULT_GRID_SIZE = 12;
90
+ const DEFAULT_FACING = "south";
91
+ const CAMERA_MODES = ["follow", "pov", "overhead"];
92
+
93
+ const MUDIII_NOTE_LINES = [
94
+ "One fox and a handful of goblins share a town square, rendered in three dimensions rather than mud.html's flat rooms. The foxes slider picks how many predators are cast; the goblins slider adds more prey. Nothing here is a player either — you watch from whichever camera you pick.",
95
+ "Follow tracks whichever agent the dropdown names, from just behind it. POV puts the camera at that agent's own eyes, facing the way it is facing. Overhead looks straight down at the whole square, no fog of war, the same deliberate exception mud.html's own survey makes.",
96
+ "Click place food, then click a clear cell in the square to drop a morsel there — the same click a fox or a goblin would use to walk there, refused the same way if the cell is already taken.",
97
+ ];
98
+
99
+ /** A live agent id's kind — "fox-1" -> "fox", "goblin-3" -> "goblin" —
100
+ * mirroring mud-viz.mjs's own speciesOfCharacter. This is the manifest KEY
101
+ * a species resolves its model and clips under (data/mudiii-assets.json's
102
+ * own `key` column), not the engine's predator/prey ROLE tag — an agent's
103
+ * actual role travels on its own tick payload (`agent.role`) and is never
104
+ * re-derived from its id here. Pure, self-contained. */
105
+ export function roleOfAgentId(id) {
106
+ return String(id).replace(/-\d+$/, "");
107
+ }
108
+
109
+ /** A manifest row's `destPath` as a URL the page can actually fetch.
110
+ * `data/mudiii-assets.json` records paths from the repository root
111
+ * ("public/models/props/well.glb"), because that is where the checker and
112
+ * the credits generator read them from. The deployed site is rooted AT
113
+ * `public/`, so the leading segment has to come off or every model 404s
114
+ * under a doubled prefix. One function so the rule is written once: the
115
+ * loader, the cache key and any future preload all go through it. Pure. */
116
+ export function modelUrlFor(destPath) {
117
+ const path = String(destPath == null ? "" : destPath).trim();
118
+ if (!path) return null;
119
+ return `./${path.replace(/^\.?\/?public\//, "")}`;
120
+ }
121
+
122
+ /** `cell-<x>-<y>` (x, y both 1..gridSize) to a ground-plane world position,
123
+ * `{ x, z }`, centred on the grid's own middle so a `gridSize`-square board
124
+ * sits symmetrically around the origin regardless of size. `cellSize` is
125
+ * the world-unit edge length of one cell — the scene track's own convention
126
+ * for how many three.js units a step covers. Returns null for anything that
127
+ * is not a well-formed cell id, or a non-finite/non-positive `gridSize`.
128
+ * Pure, self-contained, no closed-over constant (gridSize/cellSize both
129
+ * travel as parameters for exactly that reason). The inverse is
130
+ * `cellFromGroundPoint`, below. */
131
+ export function cellToWorld(cell, gridSize, cellSize) {
132
+ const match = /^cell-(\d+)-(\d+)$/.exec(String(cell == null ? "" : cell));
133
+ if (!match) return null;
134
+ const size = Number(gridSize);
135
+ const unit = Number(cellSize);
136
+ if (!Number.isFinite(size) || size <= 0 || !Number.isFinite(unit)) return null;
137
+ const center = (size + 1) / 2;
138
+ return { x: (Number(match[1]) - center) * unit, z: (Number(match[2]) - center) * unit };
139
+ }
140
+
141
+ /** The inverse of `cellToWorld`: a ground-plane point `{ x, z }` to the
142
+ * nearest `cell-<x>-<y>` id, clamped into the board (1..gridSize on both
143
+ * axes) — a raycast hit a hair past the square's own edge still resolves to
144
+ * the edge cell rather than refusing outright. Null for a non-finite point
145
+ * or board. Pure, self-contained. */
146
+ export function cellFromGroundPoint(point, gridSize, cellSize) {
147
+ if (!point || typeof point.x !== "number" || typeof point.z !== "number") return null;
148
+ const size = Number(gridSize);
149
+ const unit = Number(cellSize);
150
+ if (!Number.isFinite(size) || size <= 0 || !Number.isFinite(unit) || unit === 0) return null;
151
+ const center = (size + 1) / 2;
152
+ const x = Math.max(1, Math.min(size, Math.round(point.x / unit + center)));
153
+ const y = Math.max(1, Math.min(size, Math.round(point.z / unit + center)));
154
+ return `cell-${x}-${y}`;
155
+ }
156
+
157
+ /** Every static prop the world's own fact rows place, resolved against
158
+ * `assetManifest` (data/mudiii-assets.json's own `assets` rows) by its
159
+ * `mgx:model` value — a fact row never carries a file path (see this
160
+ * module's header), so this JOIN is the one place a `destPath` enters the
161
+ * page. Each returned entry is `{ id, cell, model, rotation, asset }`;
162
+ * `asset` is the matching manifest row, or null when the world names a
163
+ * model the manifest carries no key for (an honest gap, never a guessed
164
+ * fallback model). `rotation` defaults to "0" for a prop whose row omits
165
+ * it. Only rows shaped `rdf:type prop` / `mgx:currently-in` / `mgx:model` /
166
+ * `mgx:rotation` are read; every other predicate on the same subject is
167
+ * ignored. Sorted by id for a stable, testable order. Pure. */
168
+ export function propPlacementsFrom(rows, assetManifest) {
169
+ const byKey = new Map();
170
+ for (const row of assetManifest || []) if (row && row.key) byKey.set(row.key, row);
171
+ const bySubject = new Map();
172
+ for (const row of rows || []) {
173
+ if (!row || !row.subject) continue;
174
+ if (
175
+ row.predicate !== "rdf:type" && row.predicate !== "mgx:currently-in"
176
+ && row.predicate !== "mgx:model" && row.predicate !== "mgx:rotation"
177
+ ) continue;
178
+ if (!bySubject.has(row.subject)) bySubject.set(row.subject, { id: row.subject });
179
+ const entry = bySubject.get(row.subject);
180
+ if (row.predicate === "rdf:type") entry.type = row.object;
181
+ else if (row.predicate === "mgx:currently-in") entry.cell = row.object;
182
+ else if (row.predicate === "mgx:model") entry.model = row.object;
183
+ else if (row.predicate === "mgx:rotation") entry.rotation = row.object;
184
+ }
185
+ const placements = [];
186
+ for (const entry of bySubject.values()) {
187
+ if (entry.type !== "prop" || !entry.cell || !entry.model) continue;
188
+ placements.push({
189
+ id: entry.id, cell: entry.cell, model: entry.model,
190
+ rotation: entry.rotation || "0",
191
+ asset: byKey.get(entry.model) || null,
192
+ });
193
+ }
194
+ placements.sort((a, b) => a.id.localeCompare(b.id));
195
+ return placements;
196
+ }
197
+
198
+ /** Every cell an agent or an item currently sits on, as a `Set<string>` —
199
+ * the page's own quick "is anything here at all" read, e.g. for a map dot
200
+ * pass that wants to know which cells to draw without walking two lists
201
+ * twice. `agents`/`items` are plain arrays of `{ id, cell, ... }` (the
202
+ * shape every tick's `agents`/`items` map turns into once the page
203
+ * flattens it — see this file's own header on why the engine's per-turn
204
+ * payload is keyed by id rather than an array). Pure. */
205
+ export function occupiedCells(agents, items) {
206
+ const cells = new Set();
207
+ for (const agent of agents || []) if (agent && agent.cell) cells.add(agent.cell);
208
+ for (const item of items || []) if (item && item.cell) cells.add(item.cell);
209
+ return cells;
210
+ }
211
+
212
+ /** Why a click-to-move or a food placement on `cell` would be refused, or
213
+ * null when it is clear — `"cell-4-3 is blocked"`, worded once so both the
214
+ * 3D raycast click and a typed "go there" chat verb read the same refusal.
215
+ * A cell is blocked by a static prop (`props`, `propPlacementsFrom`'s own
216
+ * shape) or by any live agent standing on it — never by a loose food item,
217
+ * which the fixture's own readme notes a prop blocks movement and an item
218
+ * does not. Pure. */
219
+ export function blockedCellReason(cell, props, agents) {
220
+ const byProp = (props || []).some((p) => p && p.cell === cell);
221
+ const byAgent = (agents || []).some((a) => a && a.cell === cell);
222
+ if (byProp || byAgent) return `${cell} is blocked`;
223
+ return null;
224
+ }
225
+
226
+ /** The camera's own position/target for `mode` ("follow" | "pov" |
227
+ * "overhead") against `agent` (`{ cell, facing, ... }`, or null/absent for
228
+ * overhead) and a `gridSize`-square board. Pure geometry only — no three.js
229
+ * object is built or referenced here, so the scene track can hand this
230
+ * straight to whatever camera primitive it uses. Assumes a one-world-unit
231
+ * cell (the scene track's own baseline scale; every model in
232
+ * data/mudiii-assets.json ships `targetHeight` in the same metres-per-unit
233
+ * convention), which is why this takes no separate `cellSize` the way
234
+ * `cellToWorld` does.
235
+ *
236
+ * "overhead" ignores `agent` entirely and looks straight down at the
237
+ * board's own centre. "follow" sits back and above the agent's cell, offset
238
+ * opposite its facing, looking at the agent. "pov" sits AT the agent's cell
239
+ * at eye height, looking the way it is facing. Returns null when a
240
+ * non-overhead mode is asked for with no agent (or an agent standing on no
241
+ * cell) to rig against. Pure, self-contained — calls `cellToWorld` by bare
242
+ * name, spliced alongside it. */
243
+ export function cameraRigFor(mode, agent, gridSize) {
244
+ const cellSize = 1;
245
+ if (mode === "overhead") {
246
+ const height = Math.max(4, Number(gridSize) || DEFAULT_GRID_SIZE) * 1.4;
247
+ return { mode: "overhead", position: { x: 0, y: height, z: 0 }, lookAt: { x: 0, y: 0, z: 0 } };
248
+ }
249
+ if (!agent || !agent.cell) return null;
250
+ const world = cellToWorld(agent.cell, gridSize, cellSize);
251
+ if (!world) return null;
252
+ const FACING_VECTOR = {
253
+ north: { x: 0, z: -1 }, south: { x: 0, z: 1 }, east: { x: 1, z: 0 }, west: { x: -1, z: 0 },
254
+ };
255
+ const dir = FACING_VECTOR[agent.facing] || FACING_VECTOR[DEFAULT_FACING];
256
+ if (mode === "pov") {
257
+ return {
258
+ mode: "pov",
259
+ position: { x: world.x, y: 1.6, z: world.z },
260
+ lookAt: { x: world.x + dir.x * 4, y: 1.4, z: world.z + dir.z * 4 },
261
+ };
262
+ }
263
+ return {
264
+ mode: "follow",
265
+ position: { x: world.x - dir.x * 4, y: 3.2, z: world.z - dir.z * 4 },
266
+ lookAt: { x: world.x, y: 0.8, z: world.z },
267
+ };
268
+ }
269
+
270
+ /** The deck's most valuable pure function: what the camera should do THIS
271
+ * turn given `prev`'s own selection (`{ mode, selectedId, status }`), the
272
+ * live `agents` (flattened `{ id, cell, ... }[]`, this turn's survivors),
273
+ * and `ecology` (this turn's tagged-union events, the shape
274
+ * test/fixtures/mudiii-ticks.json's own `_readme.ecology` documents). Five
275
+ * cases:
276
+ * 1. nothing is followed (`prev.selectedId` is null/empty) — unchanged,
277
+ * no status line;
278
+ * 2. the followed agent is still on the board — unchanged, no status
279
+ * line (a live follow never re-announces itself every turn);
280
+ * 3. it was the PREY of an `eat-agent` event this turn — falls back to
281
+ * overhead, naming predator and prey;
282
+ * 4. it was the agent of a `starve` event this turn — falls back to
283
+ * overhead, naming the starvation;
284
+ * 5. it is simply missing with no ecology event to explain it (a recast,
285
+ * a reset) — falls back to overhead with a generic "left the board"
286
+ * status, rather than staying silent about an agent the dropdown can
287
+ * no longer find.
288
+ * Pure. */
289
+ export function nextCameraSelection(prev, agents, ecology) {
290
+ const current = prev || { mode: "overhead", selectedId: null, status: null };
291
+ if (!current.selectedId) return { mode: current.mode, selectedId: current.selectedId, status: null };
292
+ const stillHere = (agents || []).some((a) => a && a.id === current.selectedId);
293
+ if (stillHere) return { mode: current.mode, selectedId: current.selectedId, status: null };
294
+ for (const event of ecology || []) {
295
+ if (event && event.type === "eat-agent" && event.prey === current.selectedId) {
296
+ return {
297
+ mode: "overhead", selectedId: null,
298
+ status: `${event.predator} ate ${event.prey} — switching to overhead`,
299
+ };
300
+ }
301
+ if (event && event.type === "starve" && event.agent === current.selectedId) {
302
+ return { mode: "overhead", selectedId: null, status: `${event.agent} starved — switching to overhead` };
303
+ }
304
+ }
305
+ return {
306
+ mode: "overhead", selectedId: null,
307
+ status: `${current.selectedId} left the board — switching to overhead`,
308
+ };
309
+ }
310
+
311
+ /** Every agent and item's own dot for the 2D top-down map panel, as
312
+ * percentage coordinates within a square panel (`{ id, kind, xPct, yPct
313
+ * }[]`) — never pixel values, so the panel's own CSS controls its actual
314
+ * size. `agents`/`items` are flattened `{ id, cell, ... }[]`; an agent's
315
+ * `kind` is its own `role` field (predator/prey) and an item's is its own
316
+ * `kind` field (crumb/morsel) — the caller's tick payload already carries
317
+ * both under those exact names. An entry with no parseable cell is
318
+ * dropped rather than drawn at a guessed position. Pure, self-contained. */
319
+ export function mapDotsFor(agents, items, gridSize) {
320
+ function percentFor(cell) {
321
+ const match = /^cell-(\d+)-(\d+)$/.exec(String(cell == null ? "" : cell));
322
+ if (!match) return null;
323
+ const size = Number(gridSize);
324
+ if (!Number.isFinite(size) || size <= 0) return null;
325
+ return { xPct: ((Number(match[1]) - 0.5) / size) * 100, yPct: ((Number(match[2]) - 0.5) / size) * 100 };
326
+ }
327
+ const dots = [];
328
+ for (const agent of agents || []) {
329
+ const pct = agent && percentFor(agent.cell);
330
+ if (pct) dots.push({ id: agent.id, kind: agent.role || "agent", xPct: pct.xPct, yPct: pct.yPct });
331
+ }
332
+ for (const item of items || []) {
333
+ const pct = item && percentFor(item.cell);
334
+ if (pct) dots.push({ id: item.id, kind: item.kind || "item", xPct: pct.xPct, yPct: pct.yPct });
335
+ }
336
+ return dots;
337
+ }
338
+
339
+ /** One HUD card's own field set, read off `agent` (`{ id, role, goal, mood,
340
+ * plan, mass, belief }`, this turn's slice of the tick payload) and a
341
+ * resolved `mudiiiConfig` (DEFAULT_GAME_CONFIG.mudiii's own shape). `massPct`
342
+ * is null whenever the role carries no mass ceiling to scale against
343
+ * (mirrors game-config.mjs's own `mudiiiMassScaleFor`, reimplemented here
344
+ * rather than imported so this stays `.toString()`-splice safe with no
345
+ * closed-over module). `planText` reads "holding" for an empty plan, the
346
+ * same wording spider-fly-viz.mjs's own `planLineHtml` uses. `beliefEntries`
347
+ * is `Object.entries(agent.belief)` unmodified — the caller renders it,
348
+ * this only shapes it. Pure. */
349
+ export function hudCardFieldsFor(agent, config) {
350
+ const role = agent && agent.role ? agent.role : null;
351
+ const scale = role === "predator" ? config?.predatorInitialMass : role === "prey" ? config?.preyInitialMass : null;
352
+ const mass = agent && typeof agent.mass === "number" ? agent.mass : null;
353
+ const massPct = scale && mass !== null ? Math.max(0, Math.min(100, Math.round((mass / scale) * 100))) : null;
354
+ const plan = agent && Array.isArray(agent.plan) ? agent.plan : [];
355
+ return {
356
+ id: agent ? agent.id : null,
357
+ role,
358
+ goal: (agent && agent.goal) || "",
359
+ mood: (agent && agent.mood) || "",
360
+ mass,
361
+ massPct,
362
+ planText: plan.length ? plan.join(" → ") : "holding",
363
+ beliefEntries: agent && agent.belief ? Object.entries(agent.belief) : [],
364
+ };
365
+ }
366
+
367
+ /** The animation clip name for `action` (an engine-level rung/event word —
368
+ * "wander", "chase", "evade", "forage", "eat-agent", "eat-item", "starve")
369
+ * against one creature's own `clipMap` (data/mudiii-assets.json's `clips`
370
+ * object for that asset — `{ idle, walk, run, attack, hit, death, eat }`,
371
+ * some values themselves arrays). `role` is the engine's predator/prey tag,
372
+ * which only matters for `eat-agent`: the predator plays its attack, the
373
+ * prey (briefly still animate the instant it is caught) plays its death.
374
+ * Falls back to `clipMap.idle`, then null, for an action/role combination
375
+ * the map carries no clip for (goblin.glb ships no "eat" clip — see the
376
+ * manifest's own row — so `clipForAction("prey", "eat-item", ...)` lands on
377
+ * idle rather than a guessed name). An array-valued clip (the two rigs'
378
+ * own `hit` list) returns its first entry. Pure, self-contained. */
379
+ export function clipForAction(role, action, clipMap) {
380
+ const clips = clipMap || {};
381
+ const kindFor = {
382
+ wander: "walk",
383
+ forage: "walk",
384
+ chase: "run",
385
+ evade: "run",
386
+ "eat-agent": role === "predator" ? "attack" : "death",
387
+ "eat-item": "eat",
388
+ starve: "death",
389
+ death: "death",
390
+ idle: "idle",
391
+ };
392
+ const wanted = kindFor[action] || "idle";
393
+ const clip = clips[wanted];
394
+ const resolved = Array.isArray(clip) ? clip[0] : clip;
395
+ return resolved || clips.idle || null;
396
+ }
397
+
398
+ /** One HUD card's whole markup, character-agnostic — mirrors mud-viz.mjs's
399
+ * own `paneMarkup`: which agent it shows is stamped in at render time
400
+ * (`data-agent`, the id/role text), so the row can be rebuilt for a
401
+ * different cast without a second template living anywhere else. `slot` is
402
+ * an opaque per-card key (the page uses the agent's position in the sorted
403
+ * id list); this function reads nothing from it beyond namespacing its own
404
+ * element ids. Pure, self-contained (escapeHtml is spliced alongside it). */
405
+ export function agentCardMarkup(slot) {
406
+ const w = `hud-${escapeHtml(String(slot))}`;
407
+ return `<div class="hud-card" id="${w}" data-slot="${escapeHtml(String(slot))}" data-agent="">
408
+ <div class="hud-card-head">
409
+ <span class="hud-card-id mono" id="${w}-id"></span>
410
+ <span class="hud-card-role mono" id="${w}-role"></span>
411
+ </div>
412
+ <div class="hud-meter" id="${w}-meter"><div class="hud-meter-fill" id="${w}-meter-fill"></div></div>
413
+ <p class="hud-goal" id="${w}-goal"></p>
414
+ <p class="hud-plan mono" id="${w}-plan"></p>
415
+ <p class="hud-belief mono" id="${w}-belief"></p>
416
+ </div>`;
417
+ }
418
+
419
+ /** The self-contained mudiii.html page. Pure — identical output for
420
+ * identical input; the live world, the ticking simulation and the 3D scene
421
+ * are all computed in the browser once the sibling bundle loads.
422
+ *
423
+ * `worldPayload` is `{ name, facts, rules, opening }`, the same shape every
424
+ * other viz page's world payload takes. `agents` is the roster this page
425
+ * may cast from, `[{ id, role }]` (role is "predator" or "prey" — see
426
+ * MUDIII_ROLES). `scenarios` is `[{ label, worldPayload, agents, gridSize }]`
427
+ * — a page given none is the one-square case and ships no dropdown, the
428
+ * same rule mud-viz.mjs's own renderMudHtml follows. `assetManifest` is
429
+ * data/mudiii-assets.json's own `assets` rows, embedded as page data and
430
+ * resolved client-side by `propPlacementsFrom` — this is the one parameter
431
+ * mud-viz.mjs's renderMudHtml has no equivalent of, because mud.html has no
432
+ * file-backed models to resolve. `mudiiiConfig` defaults to
433
+ * game-config.mjs's own DEFAULT_GAME_CONFIG.mudiii. `engineBundleJs` inlines
434
+ * the built browser bundle instead of a sibling `<script src>`, the same
435
+ * standalone-export knob every other viz page carries. */
436
+ export function renderMudiiiHtml({
437
+ title = DEFAULT_TITLE,
438
+ worldPayload,
439
+ agents = [],
440
+ scenarios = [],
441
+ assetManifest = [],
442
+ mudiiiConfig = DEFAULT_GAME_CONFIG.mudiii,
443
+ engineBundleJs = "",
444
+ } = {}) {
445
+ const scenarioList = scenarios.length
446
+ ? scenarios
447
+ : [{ label: scenarioLabel(worldPayload?.name), worldPayload, agents }];
448
+ const opening = scenarioList[0];
449
+ const openingAgents = opening.agents || [];
450
+ const gridSize = opening.gridSize || DEFAULT_GRID_SIZE;
451
+
452
+ const pageData = embedJson({
453
+ scenarios: scenarioList, assetManifest, mudiiiConfig,
454
+ playerCounts: PLAYER_COUNTS,
455
+ defaultPlayerCount: DEFAULT_PLAYER_COUNT,
456
+ defaultNpcCount: DEFAULT_NPC_COUNT,
457
+ defaultDelayMs: DEFAULT_DELAY_MS,
458
+ defaultMaxTurns: DEFAULT_MAX_TURNS,
459
+ gridSize,
460
+ defaultFacing: DEFAULT_FACING,
461
+ cameraModes: CAMERA_MODES,
462
+ });
463
+
464
+ const sceneScript = mudiiiSceneScript({ canvasId: "sceneCanvas", statusId: "sceneStatus", gridSize, cellSize: 1 });
465
+
466
+ return `<!doctype html>
467
+ <html lang="en">
468
+ <head>
469
+ <meta charset="utf-8">
470
+ <meta name="viewport" content="width=device-width, initial-scale=1">
471
+ <title>${escapeHtml(title)}</title>
472
+ ${engineBundleJs ? "" : `<link rel="icon" href="./favicon.svg" type="image/svg+xml">
473
+ <link rel="icon" href="./favicon.ico" sizes="any">
474
+ <link rel="apple-touch-icon" href="./apple-touch-icon.png">`}
475
+ <style>
476
+ ${THEME_TOKENS_CSS}
477
+ ${MUDIII_STYLE}
478
+ ${PILL_COMPLETE_CSS}
479
+ </style>
480
+ </head>
481
+ <body>
482
+ <main>
483
+ <header class="mudiii-topbar">
484
+ <h1 class="eyebrow">tmct &middot; mudiii</h1>
485
+ <a class="mudiii-topbar-help" href="./help.html" target="_blank" rel="noopener"
486
+ title="how this demo works, in a new tab" aria-label="how this demo works, opens in a new tab">?</a>
487
+ </header>
488
+ <div class="deck-row">
489
+ <section class="deck" aria-label="simulation controls">
490
+ <div class="deck-controls">
491
+ <button type="button" class="deck-play" id="autoToggle" aria-pressed="false">&#9654; play</button>
492
+ <button type="button" id="resetBtn">reset</button>
493
+ ${scenarioList.length > 1 ? ` <select id="scenarioSelect" class="deck-select" aria-label="which town square to play">
494
+ ${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " selected" : ""}>${escapeHtml(s.label || scenarioLabel(s.worldPayload?.name))}</option>`).join("\n")}
495
+ </select>` : ""}
496
+ <button type="button" id="editModeBtn" aria-pressed="false">edit</button>
497
+ <button type="button" class="deck-info-btn" id="deckInfoBtn" aria-expanded="false" aria-controls="deckInfoPopup" aria-label="about this demo">?</button>
498
+ <span class="mono deck-turns" id="globalTurnCount">turns: 0</span>
499
+ </div>
500
+ <div class="deck-sliders">
501
+ <label class="deck-slider">foxes
502
+ <input type="range" id="playerCountSlider" min="0" max="${PLAYER_COUNTS.length - 1}" step="1"
503
+ value="${Math.max(0, PLAYER_COUNTS.indexOf(DEFAULT_PLAYER_COUNT))}"
504
+ list="playerCountTicks" aria-valuetext="${DEFAULT_PLAYER_COUNT} foxes">
505
+ <datalist id="playerCountTicks">${PLAYER_COUNTS.map((n, i) => `<option value="${i}" label="${n}"></option>`).join("")}</datalist>
506
+ <span class="mono" id="playerCountValue">${DEFAULT_PLAYER_COUNT}</span>
507
+ </label>
508
+ <label class="deck-slider">goblins
509
+ <input type="range" id="npcCountSlider" min="${NPC_COUNT_MIN}" max="${NPC_COUNT_MAX}" step="1"
510
+ value="${DEFAULT_NPC_COUNT}"
511
+ list="npcCountTicks" aria-valuetext="${DEFAULT_NPC_COUNT} goblins">
512
+ <datalist id="npcCountTicks">${Array.from({ length: NPC_COUNT_MAX - NPC_COUNT_MIN + 1 }, (_, i) => {
513
+ const n = NPC_COUNT_MIN + i;
514
+ return NPC_COUNT_LABELLED.includes(n) ? `<option value="${n}" label="${n}"></option>` : `<option value="${n}"></option>`;
515
+ }).join("")}</datalist>
516
+ <span class="mono" id="npcCountValue">${DEFAULT_NPC_COUNT}</span>
517
+ </label>
518
+ <label class="deck-slider">delay
519
+ <input type="range" id="delaySlider" min="80" max="2000" step="20" value="${DEFAULT_DELAY_MS}">
520
+ <span class="mono" id="delayValue">${DEFAULT_DELAY_MS}ms</span>
521
+ </label>
522
+ <label class="deck-slider">max turns
523
+ <input type="range" id="maxTurnsSlider" min="20" max="2000" step="20" value="${DEFAULT_MAX_TURNS}">
524
+ <span class="mono" id="maxTurnsValue">${DEFAULT_MAX_TURNS}</span>
525
+ </label>
526
+ </div>
527
+ <div class="deck-camera">
528
+ <label class="deck-slider">follow
529
+ <select id="agentSelect" class="deck-select" aria-label="which agent to follow">
530
+ ${openingAgents.map((a) => ` <option value="${escapeHtml(a.id)}">${escapeHtml(a.id)}</option>`).join("\n")}
531
+ </select>
532
+ </label>
533
+ <div class="camera-mode" id="cameraMode" role="group" aria-label="camera mode">
534
+ <button type="button" data-mode="follow" aria-pressed="true">follow</button>
535
+ <button type="button" data-mode="pov" aria-pressed="false">pov</button>
536
+ <button type="button" data-mode="overhead" aria-pressed="false">overhead</button>
537
+ </div>
538
+ <button type="button" class="pill affordance" id="foodPill" data-command="place food" aria-pressed="false">place food</button>
539
+ </div>
540
+ <div class="deck-info-popup mudiii-note" id="deckInfoPopup" role="dialog" aria-label="about this demo" hidden>
541
+ ${MUDIII_NOTE_LINES.map((line) => `<p>${escapeHtml(line)}</p>`).join("\n ")}
542
+ <button type="button" class="deck-info-popup-close" id="deckInfoClose" aria-label="close">&times;</button>
543
+ </div>
544
+ </section>
545
+ <section class="map-panel" id="mapPanel" aria-label="the town square, from above">
546
+ <div class="map-panel-head">
547
+ <span class="map-panel-title">the square, from above</span>
548
+ <span class="mono map-panel-turn" id="mapPanelTurn">turn 0</span>
549
+ </div>
550
+ <div class="map-panel-board" id="mapPanelBoard"></div>
551
+ </section>
552
+ </div>
553
+ <section class="scene-stage" id="sceneStage" aria-label="the town square, in three dimensions">
554
+ <canvas id="sceneCanvas"></canvas>
555
+ <p class="scene-status" id="sceneStatus" role="status"></p>
556
+ </section>
557
+ <div class="hud-row" id="hudRow" aria-label="every agent's own status"></div>
558
+ <div class="edit-stage" id="mudiiiEditStage" aria-label="the square's own facts, in plain sentences">
559
+ <section class="edit-text" aria-label="the world's facts as editable sentences">
560
+ <h2>the square, in plain sentences</h2>
561
+ <p class="edit-lede">Every fact this world is built from. Change a line and the square changes with it.</p>
562
+ <textarea id="editorText" spellcheck="false" aria-label="the world's own facts as plain sentences, one per line"></textarea>
563
+ <div class="chatpills" id="editorPills" aria-label="related words for the term before the cursor"></div>
564
+ <p class="edit-status" id="editorStatus" role="status"></p>
565
+ </section>
566
+ <aside class="edit-side" aria-label="who and what stands where">
567
+ <section class="edit-panel">
568
+ <h2>who and what stands where</h2>
569
+ <div class="edit-placements" id="editPlacements"></div>
570
+ </section>
571
+ </aside>
572
+ </div>
573
+ <section class="mudiii-chat" aria-label="talk to the square">
574
+ <div class="chatlog" id="chatLog" aria-live="polite"></div>
575
+ <div class="log-popup" id="chatLogPopup" role="dialog" aria-label="the whole reply" hidden>
576
+ <p class="log-popup-text" id="chatLogPopupText"></p>
577
+ <button type="button" class="log-popup-close" id="chatLogPopupClose" aria-label="close the whole reply">&times;</button>
578
+ </div>
579
+ <div class="chat-console">
580
+ <div class="pill-strip">
581
+ <div class="chatpills" id="chatPills" role="group" aria-label="quick commands"></div>
582
+ </div>
583
+ <form class="chatask" id="chatForm">
584
+ <span class="prompt mono">tmct&gt;</span>
585
+ ${pillCompleteMarkup({
586
+ inputId: "chatInput",
587
+ inputHtml: '<input id="chatInput" type="text" placeholder="@fox-1 look" aria-label="type a command" disabled>',
588
+ })}
589
+ </form>
590
+ </div>
591
+ </section>
592
+ </main>
593
+ <script>
594
+ const MUDIII_PAGE_DATA = ${pageData};
595
+ </script>
596
+ ${sceneScript ? `<script>\n${embedScriptText(sceneScript)}\n</script>` : ""}
597
+ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `<script src="./mudiii-browser.bundle.js"></script>`}
598
+ <script>
599
+ ${embedScriptText(pageScript())}
600
+ </script>
601
+ </body>
602
+ </html>
603
+ `;
604
+ }
605
+
606
+ const MUDIII_STYLE = `
607
+ :root {
608
+ --square-sky: #BFE3F0; --square-horizon: #E9D9B6; --square-stone: #8C8172;
609
+ --square-stone-dark: #59503F; --square-thatch: #B5702E; --parchment: #F3ECDD;
610
+ --square-ink: #2B2318; --square-accent: #D98A2B; --square-grass: #7C9A5B;
611
+ --square-predator: #B5502C; --square-prey: #4E7C5B;
612
+ }
613
+ html { background: var(--square-stone-dark); }
614
+ body { margin: 0; background: linear-gradient(180deg, var(--square-sky) 0%, var(--square-horizon) 40%, var(--square-stone) 100%) fixed; color: var(--square-ink); font-family: ${SANS_STACK}; font-size: 15px; line-height: 1.5; }
615
+ .mono { font-family: ${MONO_STACK}; }
616
+ main { max-width: 1280px; margin: 0 auto; padding: 1.1rem 1.2rem 2.4rem; }
617
+ .eyebrow { font-family: ${MONO_STACK}; font-weight: 500; font-size: .72rem; letter-spacing: .16em; text-transform: uppercase; color: var(--square-ink); opacity: .85; margin: 0; }
618
+ h2 { font-family: ${DISPLAY_STACK}; font-size: 1rem; margin: 0; }
619
+ h3 { font-family: ${MONO_STACK}; font-size: .58rem; margin: 0 0 .3rem; text-transform: uppercase; letter-spacing: .12em; color: var(--square-stone-dark); }
620
+ button { font: inherit; color: inherit; background: none; cursor: pointer; }
621
+ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--square-accent); outline-offset: 2px; }
622
+ button:disabled { opacity: .4; cursor: default; }
623
+
624
+ .mudiii-topbar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin: 0 0 .8rem; }
625
+ .mudiii-topbar-help {
626
+ background: var(--parchment); color: var(--square-ink); border: 1px solid var(--square-stone-dark); border-radius: 3px;
627
+ padding: .32rem .55rem; text-decoration: none; font-size: .82rem; line-height: 1.2;
628
+ }
629
+ .mudiii-topbar-help:hover { border-color: var(--square-accent); }
630
+
631
+ .deck-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: stretch; margin-bottom: 1rem; }
632
+ .deck {
633
+ position: relative;
634
+ background: var(--parchment); border: 1px solid var(--square-stone-dark); border-radius: 4px;
635
+ box-shadow: 0 2px 0 rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.35);
636
+ padding: .8rem .9rem; display: flex; flex-direction: column; gap: .55rem; min-width: 0;
637
+ }
638
+ .deck-controls, .deck-camera { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
639
+ .deck-info-btn {
640
+ font-family: ${MONO_STACK}; font-size: .78rem; line-height: 1; width: 1.5rem; height: 1.5rem;
641
+ border-radius: 50%; border: 1px solid var(--square-stone-dark); background: rgba(255,255,255,.5);
642
+ color: var(--square-stone-dark); padding: 0; flex: 0 0 auto;
643
+ }
644
+ .deck-info-btn:hover, .deck-info-btn[aria-expanded="true"] { border-color: var(--square-accent); color: var(--square-ink); }
645
+ .deck button, .camera-mode button {
646
+ font-family: ${MONO_STACK}; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em;
647
+ padding: .32rem .7rem; border: 1px solid var(--square-stone-dark); border-radius: 3px; background: rgba(255,255,255,.5);
648
+ }
649
+ .deck button:hover:not(:disabled), .camera-mode button:hover:not(:disabled) { border-color: var(--square-accent); }
650
+ .deck-select {
651
+ font-family: ${MONO_STACK}; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em;
652
+ padding: .32rem .7rem; border: 1px solid var(--square-stone-dark); border-radius: 3px;
653
+ background: rgba(255,255,255,.5); color: var(--square-ink);
654
+ }
655
+ .deck-select:hover { border-color: var(--square-accent); }
656
+ .deck-play { background: var(--square-ink) !important; color: var(--parchment); border-color: var(--square-ink) !important; padding: .38rem 1.1rem !important; }
657
+ .deck-play[aria-pressed="true"] { background: var(--square-accent) !important; border-color: var(--square-accent) !important; color: var(--square-ink); }
658
+ .deck-turns { margin-left: auto; font-size: .74rem; color: var(--square-stone-dark); background: var(--square-stone-dark); background: rgba(43,35,24,.9); color: var(--square-accent); border-radius: 2px; padding: .1rem .5rem; }
659
+ .deck-sliders { display: flex; flex-wrap: wrap; gap: 1rem; }
660
+ .deck-slider { display: flex; align-items: center; gap: .35rem; font-family: ${MONO_STACK}; font-size: .62rem; text-transform: uppercase; letter-spacing: .08em; color: var(--square-stone-dark); }
661
+ .deck-slider input[type="range"] { accent-color: var(--square-accent); width: 8rem; max-width: 34vw; }
662
+ .camera-mode { display: inline-flex; gap: .25rem; }
663
+ .camera-mode button[aria-pressed="true"] { background: var(--square-accent); border-color: var(--square-accent); color: var(--square-ink); }
664
+ .deck-info-popup {
665
+ position: absolute; left: .9rem; right: .9rem; top: calc(100% + 8px); z-index: 8;
666
+ background: var(--square-stone-dark); color: var(--parchment);
667
+ border: 1px solid var(--square-accent); border-radius: 4px;
668
+ padding: .55rem 1.6rem .6rem .65rem; box-shadow: 0 8px 18px rgba(0,0,0,.5);
669
+ }
670
+ .deck-info-popup::before {
671
+ content: ""; position: absolute; left: 1.1rem; top: -6px; width: 0; height: 0;
672
+ border-left: 6px solid transparent; border-right: 6px solid transparent; border-bottom: 6px solid var(--square-accent);
673
+ }
674
+ .deck-info-popup p { margin: 0 0 .4rem; max-width: 62ch; font-size: .78rem; line-height: 1.4; }
675
+ .deck-info-popup p:last-child { margin-bottom: 0; }
676
+ .deck-info-popup-close { position: absolute; top: .1rem; right: .3rem; font-size: 1rem; line-height: 1; color: var(--parchment); padding: .1rem .2rem; }
677
+ .deck-info-popup-close:hover { color: var(--square-accent); }
678
+
679
+ .map-panel {
680
+ background: var(--square-stone-dark); color: var(--parchment);
681
+ border: 1px solid var(--square-accent); border-radius: 4px; padding: .55rem .65rem .6rem;
682
+ display: flex; flex-direction: column; gap: .4rem; min-width: 0;
683
+ }
684
+ .map-panel-head { display: flex; justify-content: space-between; align-items: baseline; gap: .5rem; }
685
+ .map-panel-title { font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em; opacity: .85; }
686
+ .map-panel-turn { font-size: .62rem; opacity: .7; }
687
+ .map-panel-board { position: relative; flex: 1; min-height: 200px; background: rgba(124,154,91,.25); border: 1px solid rgba(233,217,182,.35); border-radius: 3px; }
688
+ .map-dot { position: absolute; width: .55rem; height: .55rem; margin: -.28rem 0 0 -.28rem; border-radius: 50%; border: 1px solid rgba(0,0,0,.4); }
689
+ .map-dot-predator { background: var(--square-predator); }
690
+ .map-dot-prey { background: var(--square-prey); }
691
+ .map-dot-crumb, .map-dot-morsel, .map-dot-item { background: var(--square-accent); width: .34rem; height: .34rem; margin: -.17rem 0 0 -.17rem; }
692
+
693
+ .scene-stage { position: relative; margin-bottom: 1rem; border: 1px solid var(--square-stone-dark); border-radius: 4px; overflow: hidden; background: #10161B; min-height: 360px; }
694
+ .scene-stage canvas { display: block; width: 100%; height: 360px; }
695
+ .scene-status {
696
+ position: absolute; left: .6rem; bottom: .6rem; margin: 0; max-width: calc(100% - 1.2rem);
697
+ font-family: ${MONO_STACK}; font-size: .68rem; color: var(--parchment); background: rgba(43,35,24,.78);
698
+ border-radius: 3px; padding: .28rem .55rem;
699
+ }
700
+ .scene-status:empty { display: none; }
701
+
702
+ .hud-row { display: flex; flex-wrap: wrap; gap: .7rem; margin-bottom: 1rem; }
703
+ .hud-card {
704
+ flex: 1 1 220px; min-width: 200px; max-width: 320px;
705
+ background: var(--parchment); border: 1px solid var(--square-stone-dark); border-radius: 4px;
706
+ box-shadow: 0 2px 0 rgba(0,0,0,.18); padding: .55rem .65rem; display: flex; flex-direction: column; gap: .3rem;
707
+ }
708
+ .hud-card-head { display: flex; align-items: baseline; justify-content: space-between; gap: .4rem; }
709
+ .hud-card-id { font-size: .78rem; font-weight: 600; }
710
+ .hud-card-role { font-size: .6rem; text-transform: uppercase; letter-spacing: .08em; color: var(--square-stone-dark); }
711
+ .hud-meter { height: .4rem; background: rgba(0,0,0,.15); border-radius: 99px; overflow: hidden; }
712
+ .hud-meter-fill { height: 100%; background: var(--square-accent); width: 0%; transition: width .3s ease; }
713
+ .hud-goal { margin: 0; font-size: .74rem; }
714
+ .hud-plan, .hud-belief { margin: 0; font-size: .62rem; color: var(--square-stone-dark); }
715
+ @media (prefers-reduced-motion: reduce) { .hud-meter-fill { transition: none; } }
716
+
717
+ .edit-stage { display: none; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 1rem; align-items: start; margin-bottom: 1rem; }
718
+ body.editing .mudiii-chat, body.editing .scene-stage, body.editing .hud-row, body.editing .deck-row .map-panel { display: none; }
719
+ body.editing .deck-row { grid-template-columns: 1fr; }
720
+ body.editing .edit-stage { display: grid; }
721
+ #editModeBtn[aria-pressed="true"] { background: var(--square-accent); border-color: var(--square-accent); }
722
+ .edit-text, .edit-panel {
723
+ background: var(--parchment); border: 1px solid var(--square-stone-dark); border-radius: 4px;
724
+ box-shadow: 0 2px 0 rgba(0,0,0,.18); padding: .7rem .8rem; min-width: 0;
725
+ }
726
+ .edit-text { display: flex; flex-direction: column; gap: .45rem; }
727
+ .edit-lede { margin: 0; font-size: .74rem; color: var(--square-stone-dark); max-width: 62ch; }
728
+ #editorText {
729
+ width: 100%; box-sizing: border-box; min-height: 22rem; flex: 1 1 auto; resize: vertical;
730
+ font-family: ${MONO_STACK}; font-size: .74rem; line-height: 1.6;
731
+ color: var(--square-ink); background: rgba(255,255,255,.6);
732
+ border: 1px solid var(--square-stone-dark); border-radius: 3px; padding: .55rem .6rem;
733
+ }
734
+ .edit-status { margin: 0; font-family: ${MONO_STACK}; font-size: .62rem; min-height: 1.2em; color: var(--square-stone-dark); }
735
+ .edit-status.pending { color: #9A5B12; }
736
+ .edit-status.ok { color: #4E6B2E; }
737
+ .edit-side { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
738
+ .edit-panel { display: flex; flex-direction: column; gap: .35rem; }
739
+ .edit-placements { display: flex; flex-direction: column; gap: .2rem; font-size: .74rem; }
740
+ .edit-placement-row { display: flex; gap: .4rem; }
741
+ .edit-empty { font-size: .74rem; font-style: italic; color: var(--square-stone-dark); }
742
+
743
+ .mudiii-chat { background: var(--parchment); border: 1px solid var(--square-stone-dark); border-radius: 4px; box-shadow: 0 2px 0 rgba(0,0,0,.18); padding: .7rem .8rem; display: flex; flex-direction: column; gap: .5rem; }
744
+ .chatlog { min-height: 8rem; max-height: 16rem; overflow-y: auto; }
745
+ .chatlog > * { margin: 0 0 .32rem; }
746
+ .chatlog:empty::after { content: "Type a command below and the answer lands here."; color: var(--square-stone-dark); font-style: italic; font-size: .72rem; }
747
+ .chatlog .u { font-family: ${MONO_STACK}; font-size: .7rem; color: var(--square-stone-dark); overflow-wrap: anywhere; }
748
+ .chatlog .u::before { content: "tmct> "; color: var(--square-accent); }
749
+ .chatlog .a { font-size: .78rem; line-height: 1.4; }
750
+ .log-popup {
751
+ position: relative; z-index: 8; background: var(--square-stone-dark); color: var(--parchment);
752
+ border: 1px solid var(--square-accent); border-radius: 4px; padding: .45rem 1.5rem .5rem .55rem;
753
+ }
754
+ .log-popup-text { margin: 0; font-size: .74rem; line-height: 1.4; }
755
+ .log-popup-close { position: absolute; top: .1rem; right: .25rem; font-size: 1rem; line-height: 1; color: var(--parchment); padding: .1rem .2rem; }
756
+ .chat-console { display: flex; flex-direction: column; gap: .3rem; }
757
+ .chatask { display: flex; align-items: center; gap: .4rem; }
758
+ .chatask .prompt { color: var(--square-accent); font-size: .7rem; }
759
+ .chatask .pc-field { flex: 1; min-width: 0; }
760
+ .chatask input {
761
+ width: 100%; box-sizing: border-box; font-family: ${MONO_STACK}; font-size: .74rem; color: var(--square-ink);
762
+ border: 1px solid var(--square-stone-dark); border-radius: 2px; padding: .3rem .5rem;
763
+ }
764
+ .chatpills { display: flex; flex-wrap: wrap; gap: .22rem; max-height: 3.9rem; overflow-y: auto; }
765
+ .chatpills:empty { display: none; }
766
+ .pill { font-family: ${MONO_STACK}; font-size: .58rem; padding: .14rem .45rem; border: 1px solid var(--square-stone-dark); border-radius: 99px; background: rgba(255,255,255,.5); white-space: nowrap; }
767
+ .pill:hover:not(:disabled) { border-color: var(--square-accent); background: var(--square-accent); }
768
+ .pill.affordance { border-style: dashed; border-color: var(--square-stone); }
769
+ .pill.affordance[aria-pressed="true"] { background: var(--square-accent); border-style: solid; }
770
+
771
+ @media (max-width: 900px) {
772
+ .deck-row { grid-template-columns: 1fr; }
773
+ .edit-stage { grid-template-columns: 1fr; }
774
+ #editorText { min-height: 16rem; }
775
+ }
776
+ `;
777
+
778
+ /** The inlined page script, spliced the same way mud-viz.mjs's own
779
+ * `pageScript` is: every helper this page needs client-side is listed at
780
+ * the top of the closure as a `const` binding built off that function's own
781
+ * `.toString()`. The scene track's own script (see `renderMudiiiHtml`'s
782
+ * `sceneScript`) is a SEPARATE, standalone `<script>` tag rather than one of
783
+ * these bindings — it is a whole IIFE of its own, not a helper function this
784
+ * page's closure calls into. */
785
+ function pageScript() {
786
+ return `(function () {
787
+ "use strict";
788
+ const DATA = MUDIII_PAGE_DATA;
789
+ const createTicker = ${createTicker.toString()};
790
+ const createSerialQueue = ${createSerialQueue.toString()};
791
+ const escapeHtml = ${escapeHtml.toString()};
792
+ const esc = escapeHtml;
793
+ const appendLogLine = ${appendLogLine.toString()};
794
+ const rowsForWorld = ${rowsForWorld.toString()};
795
+ const renderMudEditorText = ${renderMudEditorText.toString()};
796
+ const gridWorldEditorState = ${gridWorldEditorState.toString()};
797
+ const pillCandidates = ${pillCandidates.toString()};
798
+ const matchPills = ${matchPills.toString()};
799
+ const createPillComplete = ${createPillComplete.toString()};
800
+ const roleOfAgentId = ${roleOfAgentId.toString()};
801
+ const cellToWorld = ${cellToWorld.toString()};
802
+ const cellFromGroundPoint = ${cellFromGroundPoint.toString()};
803
+ const propPlacementsFrom = ${propPlacementsFrom.toString()};
804
+ const occupiedCells = ${occupiedCells.toString()};
805
+ const blockedCellReason = ${blockedCellReason.toString()};
806
+ const cameraRigFor = ${cameraRigFor.toString()};
807
+ const nextCameraSelection = ${nextCameraSelection.toString()};
808
+ const mapDotsFor = ${mapDotsFor.toString()};
809
+ const hudCardFieldsFor = ${hudCardFieldsFor.toString()};
810
+ const clipForAction = ${clipForAction.toString()};
811
+ const agentCardMarkup = ${agentCardMarkup.toString()};
812
+
813
+ const el = (id) => document.getElementById(id);
814
+ let scenarioIndex = 0;
815
+ const scenario = function () { return DATA.scenarios[scenarioIndex]; };
816
+ const rosterOf = function (s, role) {
817
+ return (s.agents || []).filter(function (a) { return !role || a.role === role; }).map(function (a) { return a.id; });
818
+ };
819
+
820
+ // ---- roster picking: the same shuffle-and-slice mud-browser-entry.mjs's
821
+ // own pickMudRoster performs, kept here rather than in the bundle because
822
+ // it needs no engine at all and this page's own tests exercise it through
823
+ // the rendered controls, never directly.
824
+ function pickRoster(roster, count) {
825
+ const pool = [...(roster || [])];
826
+ for (let i = pool.length - 1; i > 0; i -= 1) {
827
+ const j = Math.floor(Math.random() * (i + 1));
828
+ const tmp = pool[i]; pool[i] = pool[j]; pool[j] = tmp;
829
+ }
830
+ return pool.slice(0, Math.min(count, pool.length));
831
+ }
832
+
833
+ let cast = [];
834
+ let props = [];
835
+ let agentsById = {};
836
+ let itemsById = {};
837
+ let globalTurn = 0;
838
+ let maxTurns = DATA.defaultMaxTurns;
839
+ let delayMs = DATA.defaultDelayMs;
840
+ let session = null;
841
+ let tickQueue = createSerialQueue();
842
+ function serializeTick(fn) { return tickQueue.run(fn); }
843
+ let camera = { mode: "follow", selectedId: null, status: null };
844
+ let foodArmed = false;
845
+ let livePills = [];
846
+ let pillComplete = null;
847
+ let autoOn = false;
848
+ let editing = false;
849
+ let ticker = null;
850
+
851
+ function hasNext() { return globalTurn < maxTurns; }
852
+ const wait = function (ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); };
853
+ const liveWait = function () { return wait(delayMs); };
854
+
855
+ function agentsList() {
856
+ return Object.keys(agentsById).map(function (id) { return Object.assign({ id: id }, agentsById[id]); });
857
+ }
858
+ function itemsList() {
859
+ return Object.keys(itemsById).map(function (id) { return Object.assign({ id: id }, itemsById[id]); });
860
+ }
861
+
862
+ function setSceneStatus(text) {
863
+ const node = el("sceneStatus");
864
+ if (node) node.textContent = text || "";
865
+ }
866
+
867
+ // The scene track's own half of the frozen contract: window.mudiiiScene.
868
+ // three.js is ~800 KB and can fail to vendor-load independently of
869
+ // anything this page does, so every call is guarded the same way — a
870
+ // missing or throwing scene must never take the map panel, the HUD, the
871
+ // deck or the chat down with it.
872
+ function callScene(method) {
873
+ const scene = window.mudiiiScene;
874
+ if (!scene || typeof scene[method] !== "function") return;
875
+ try {
876
+ return scene[method].apply(scene, Array.prototype.slice.call(arguments, 1));
877
+ } catch (err) {
878
+ setSceneStatus("the 3D scene hit an error and stopped updating \\u2014 the rest of the page still works.");
879
+ }
880
+ }
881
+
882
+ function applyTickResult(result) {
883
+ if (!result) return;
884
+ if (result.agents) agentsById = result.agents;
885
+ if (result.items) itemsById = result.items;
886
+ callScene("applyTick", { agents: result.agents, items: result.items, ecology: result.ecology });
887
+ camera = nextCameraSelection(camera, agentsList(), result.ecology || []);
888
+ callScene("setCamera", camera);
889
+ if (camera.status) setSceneStatus(camera.status);
890
+ }
891
+
892
+ async function runOneTick() {
893
+ return serializeTick(async function () {
894
+ if (!session) return null;
895
+ globalTurn += 1;
896
+ const result = await session.tick(globalTurn);
897
+ applyTickResult(result);
898
+ renderAll();
899
+ return result;
900
+ });
901
+ }
902
+
903
+ function ensureTicker() {
904
+ if (ticker) return ticker;
905
+ ticker = createTicker({
906
+ onTick: runOneTick,
907
+ onRender: function (state) {
908
+ const playBtn = el("autoToggle");
909
+ playBtn.setAttribute("aria-pressed", state.playing ? "true" : "false");
910
+ playBtn.textContent = state.playing ? "\\u23F8 pause" : "\\u25B6 play";
911
+ },
912
+ hasNext: hasNext,
913
+ wait: liveWait,
914
+ });
915
+ return ticker;
916
+ }
917
+
918
+ // ---- chat ------------------------------------------------------------
919
+ function appendChat(cls, text) { appendLogLine(el("chatLog"), cls, text, { clip: true }); }
920
+
921
+ function sendCommand(line) {
922
+ appendChat("u", line);
923
+ if (!session) { appendChat("a", "no session is open yet \\u2014 reset to start one."); return Promise.resolve(); }
924
+ return serializeTick(function () { return tmct.turn(line); }).then(function (res) {
925
+ appendChat("a", res.answer);
926
+ renderAll();
927
+ return res;
928
+ });
929
+ }
930
+
931
+ el("chatForm").addEventListener("submit", function (e) {
932
+ e.preventDefault();
933
+ const input = el("chatInput");
934
+ const line = input.value.trim();
935
+ if (!line) return;
936
+ input.value = "";
937
+ sendCommand(line);
938
+ });
939
+
940
+ const chatLog = el("chatLog");
941
+ chatLog.addEventListener("click", function (e) {
942
+ const line = e.target.closest(".clipped");
943
+ if (line) { el("chatLogPopupText").textContent = line.textContent; el("chatLogPopup").hidden = false; }
944
+ });
945
+ el("chatLogPopupClose").addEventListener("click", function () { el("chatLogPopup").hidden = true; });
946
+
947
+ // ---- the pill rail and its typeahead ----------------------------------
948
+ function renderChatPills() {
949
+ const pills = [{ command: "look", label: "look" }];
950
+ for (const id of Object.keys(agentsById)) pills.push({ command: "@" + id + " look", label: "@" + id + " look" });
951
+ livePills = pills;
952
+ el("chatPills").innerHTML = pills.map(function (p) {
953
+ return '<button type="button" class="pill" data-command="' + esc(p.command) + '">' + esc(p.label) + "</button>";
954
+ }).join("");
955
+ const buttons = el("chatPills").querySelectorAll(".pill");
956
+ for (let i = 0; i < buttons.length; i += 1) {
957
+ buttons[i].addEventListener("click", function (e) { sendCommand(e.currentTarget.getAttribute("data-command")); });
958
+ }
959
+ if (pillComplete) pillComplete.refresh();
960
+ }
961
+
962
+ function wirePillComplete() {
963
+ pillComplete = createPillComplete({
964
+ input: el("chatInput"),
965
+ ghostEl: el("chatInput-pc-ghost"),
966
+ statusEl: el("chatInput-pc-status"),
967
+ railEl: el("chatPills"),
968
+ getCandidates: function () { return livePills; },
969
+ });
970
+ }
971
+
972
+ // ---- food placement ----------------------------------------------------
973
+ el("foodPill").addEventListener("click", function () {
974
+ foodArmed = !foodArmed;
975
+ el("foodPill").setAttribute("aria-pressed", foodArmed ? "true" : "false");
976
+ });
977
+
978
+ // The scene track's own script calls this on a raycast hit against the
979
+ // ground plane (see this module's header on the two scripts' split). Kept
980
+ // as a plain global rather than a spliced closure binding, because the
981
+ // scene script is embedded as its OWN standalone <script> tag and shares
982
+ // no lexical scope with this one.
983
+ //
984
+ // Routed through tmct.turn (sendCommand), never session.placeFood
985
+ // directly: a typed "put food at cell-3-4" and this click must write the
986
+ // same facts through the same provenance stamp, so "who put that there?"
987
+ // grounds and answers identically either way, and the click's own result
988
+ // lands in the chat log instead of vanishing silently. A blocked cell is
989
+ // still refused entirely client-side — nothing is written, and the food
990
+ // pill stays armed for another try.
991
+ window.mudiiiHandleSceneClick = function (cellId) {
992
+ if (!foodArmed || !session) return;
993
+ const reason = blockedCellReason(cellId, props, agentsList());
994
+ if (reason) { setSceneStatus(reason); return; }
995
+ sendCommand("put food at " + cellId).then(function () {
996
+ foodArmed = false;
997
+ el("foodPill").setAttribute("aria-pressed", "false");
998
+ });
999
+ };
1000
+
1001
+ // ---- the HUD row --------------------------------------------------------
1002
+ function renderHudRow() {
1003
+ const ids = Object.keys(agentsById).sort();
1004
+ const row = el("hudRow");
1005
+ if (row.children.length !== ids.length) {
1006
+ row.innerHTML = ids.map(function (id, i) { return agentCardMarkup(String(i)); }).join("");
1007
+ }
1008
+ const cards = row.querySelectorAll(".hud-card");
1009
+ for (let i = 0; i < ids.length; i += 1) {
1010
+ const id = ids[i];
1011
+ const card = cards[i];
1012
+ if (!card) continue;
1013
+ card.setAttribute("data-agent", id);
1014
+ const fields = hudCardFieldsFor(Object.assign({ id: id }, agentsById[id]), DATA.mudiiiConfig);
1015
+ card.querySelector(".hud-card-id").textContent = fields.id;
1016
+ card.querySelector(".hud-card-role").textContent = fields.role || "";
1017
+ card.querySelector(".hud-meter-fill").style.width = (fields.massPct === null ? 0 : fields.massPct) + "%";
1018
+ card.querySelector(".hud-goal").textContent = fields.goal;
1019
+ card.querySelector(".hud-plan").textContent = "plan: " + fields.planText;
1020
+ card.querySelector(".hud-belief").textContent = fields.beliefEntries.length
1021
+ ? "believes: " + fields.beliefEntries.map(function (entry) {
1022
+ return entry[0] + (entry[1] ? " @ " + entry[1] : " unseen");
1023
+ }).join(" \\u00b7 ")
1024
+ : "";
1025
+ }
1026
+ }
1027
+
1028
+ // ---- the top-down map panel ---------------------------------------------
1029
+ function renderMapPanel() {
1030
+ const dots = mapDotsFor(agentsList(), itemsList(), DATA.gridSize);
1031
+ el("mapPanelBoard").innerHTML = dots.map(function (d) {
1032
+ return '<span class="map-dot map-dot-' + esc(d.kind) + '" style="left:' + d.xPct + '%;top:' + d.yPct + '%" title="' + esc(d.id) + '"></span>';
1033
+ }).join("");
1034
+ el("mapPanelTurn").textContent = "turn " + globalTurn;
1035
+ }
1036
+
1037
+ // ---- follow dropdown and camera mode ------------------------------------
1038
+ function renderAgentSelect() {
1039
+ const select = el("agentSelect");
1040
+ const ids = Object.keys(agentsById).sort();
1041
+ const current = camera.selectedId;
1042
+ select.innerHTML = ids.map(function (id) {
1043
+ return '<option value="' + esc(id) + '"' + (id === current ? " selected" : "") + '>' + esc(id) + "</option>";
1044
+ }).join("");
1045
+ }
1046
+ el("agentSelect").addEventListener("change", function () {
1047
+ const id = el("agentSelect").value || null;
1048
+ camera = { mode: camera.mode, selectedId: id, status: null };
1049
+ callScene("setCamera", camera);
1050
+ });
1051
+
1052
+ function renderCameraButtons() {
1053
+ const buttons = el("cameraMode").querySelectorAll("button");
1054
+ for (let i = 0; i < buttons.length; i += 1) {
1055
+ buttons[i].setAttribute("aria-pressed", buttons[i].getAttribute("data-mode") === camera.mode ? "true" : "false");
1056
+ }
1057
+ }
1058
+ el("cameraMode").addEventListener("click", function (e) {
1059
+ const btn = e.target.closest("button[data-mode]");
1060
+ if (!btn) return;
1061
+ camera = { mode: btn.getAttribute("data-mode"), selectedId: camera.selectedId, status: null };
1062
+ renderCameraButtons();
1063
+ callScene("setCamera", camera);
1064
+ });
1065
+
1066
+ // ---- the control deck ----------------------------------------------------
1067
+ function chosenFoxCount() { return DATA.playerCounts[Number(el("playerCountSlider").value)] || DATA.defaultPlayerCount; }
1068
+ function showFoxCount(n) {
1069
+ el("playerCountValue").textContent = String(n);
1070
+ el("playerCountSlider").setAttribute("aria-valuetext", n + (n === 1 ? " fox" : " foxes"));
1071
+ }
1072
+ function chosenGoblinCount() { const n = Number(el("npcCountSlider").value); return Number.isFinite(n) ? n : DATA.defaultNpcCount; }
1073
+ function showGoblinCount(n) {
1074
+ el("npcCountValue").textContent = String(n);
1075
+ el("npcCountSlider").setAttribute("aria-valuetext", n + (n === 1 ? " goblin" : " goblins"));
1076
+ }
1077
+
1078
+ function wireDeck() {
1079
+ el("autoToggle").addEventListener("click", function () {
1080
+ if (!session) return;
1081
+ autoOn = !autoOn;
1082
+ if (autoOn) ensureTicker().play(); else ensureTicker().pause();
1083
+ });
1084
+ el("delaySlider").addEventListener("input", function () {
1085
+ delayMs = Number(el("delaySlider").value);
1086
+ el("delayValue").textContent = delayMs + "ms";
1087
+ });
1088
+ el("maxTurnsSlider").addEventListener("input", function () {
1089
+ maxTurns = Number(el("maxTurnsSlider").value);
1090
+ el("maxTurnsValue").textContent = String(maxTurns);
1091
+ });
1092
+ el("playerCountSlider").addEventListener("input", function () { showFoxCount(chosenFoxCount()); });
1093
+ el("playerCountSlider").addEventListener("change", function () { boot(); });
1094
+ el("npcCountSlider").addEventListener("input", function () { showGoblinCount(chosenGoblinCount()); });
1095
+ el("npcCountSlider").addEventListener("change", function () { boot(); });
1096
+ el("resetBtn").addEventListener("click", function () { boot(); });
1097
+ const scenarioSelect = el("scenarioSelect");
1098
+ if (scenarioSelect) {
1099
+ scenarioSelect.addEventListener("change", function () {
1100
+ const picked = Number(scenarioSelect.value);
1101
+ if (!DATA.scenarios[picked] || picked === scenarioIndex) return;
1102
+ scenarioIndex = picked;
1103
+ boot();
1104
+ });
1105
+ }
1106
+ el("editModeBtn").addEventListener("click", function () {
1107
+ if (editing) exitEditMode(); else enterEditMode();
1108
+ });
1109
+ const infoBtn = el("deckInfoBtn");
1110
+ const infoPopup = el("deckInfoPopup");
1111
+ infoBtn.addEventListener("click", function () {
1112
+ const opening = infoPopup.hidden;
1113
+ infoPopup.hidden = !opening;
1114
+ infoBtn.setAttribute("aria-expanded", opening ? "true" : "false");
1115
+ });
1116
+ el("deckInfoClose").addEventListener("click", function () {
1117
+ infoPopup.hidden = true;
1118
+ infoBtn.setAttribute("aria-expanded", "false");
1119
+ });
1120
+ document.addEventListener("keydown", function (e) { if (e.key === "Escape" && !infoPopup.hidden) { infoPopup.hidden = true; infoBtn.setAttribute("aria-expanded", "false"); } });
1121
+ }
1122
+
1123
+ // ---- edit mode -------------------------------------------------------
1124
+ // renderMudEditorText/session.applyEdit round-trip mgx:model and
1125
+ // mgx:rotation unchanged (mud-editor.mjs, shared with mud.html); the side
1126
+ // panel here is a plain "who/what stands where" list rather than
1127
+ // mud.html's burrow-survey SVG, since a town square is a grid, not a room
1128
+ // graph.
1129
+ function worldOnlyRows(rows) { return rowsForWorld(rows, scenario().worldPayload.name); }
1130
+ let editRows = [];
1131
+
1132
+ function renderEditPlacements() {
1133
+ const placements = {};
1134
+ for (const row of editRows) {
1135
+ if (row.predicate !== "mgx:currently-in") continue;
1136
+ placements[row.subject] = row.object;
1137
+ }
1138
+ const subjects = Object.keys(placements).sort();
1139
+ el("editPlacements").innerHTML = subjects.length
1140
+ ? subjects.map(function (s) {
1141
+ return '<div class="edit-placement-row"><span class="mono">' + esc(s) + '</span><span>' + esc(placements[s]) + "</span></div>";
1142
+ }).join("")
1143
+ : '<span class="edit-empty">nothing placed yet</span>';
1144
+ }
1145
+
1146
+ async function enterEditMode() {
1147
+ if (!session) return;
1148
+ editing = true;
1149
+ autoOn = false;
1150
+ if (ticker) ticker.pause();
1151
+ document.body.classList.add("editing");
1152
+ el("editModeBtn").textContent = "back to playing";
1153
+ el("editModeBtn").setAttribute("aria-pressed", "true");
1154
+ const snap = await session.snapshot();
1155
+ editRows = worldOnlyRows(snap.rows);
1156
+ el("editorText").value = renderMudEditorText(editRows, gridWorldEditorState(snap.state));
1157
+ el("editorStatus").className = "edit-status";
1158
+ el("editorStatus").textContent = "";
1159
+ el("editorPills").innerHTML = "";
1160
+ renderEditPlacements();
1161
+ }
1162
+
1163
+ function exitEditMode() {
1164
+ editing = false;
1165
+ document.body.classList.remove("editing");
1166
+ el("editModeBtn").textContent = "edit";
1167
+ el("editModeBtn").setAttribute("aria-pressed", "false");
1168
+ }
1169
+
1170
+ let syncTimer = null;
1171
+ function scheduleSync() { clearTimeout(syncTimer); syncTimer = setTimeout(applyEditorText, 450); }
1172
+ el("editorText").addEventListener("input", scheduleSync);
1173
+
1174
+ async function applyEditorText() {
1175
+ if (!session) return;
1176
+ const status = el("editorStatus");
1177
+ status.className = "edit-status pending";
1178
+ status.textContent = "reading the square\\u2026";
1179
+ const result = await serializeTick(function () { return session.applyEdit(el("editorText").value); });
1180
+ const snap = await session.snapshot();
1181
+ editRows = worldOnlyRows(snap.rows);
1182
+ if (result && result.unrecognized && result.unrecognized.length) {
1183
+ status.className = "edit-status pending";
1184
+ status.textContent = result.unrecognized.length + " line" + (result.unrecognized.length === 1 ? "" : "s")
1185
+ + " not understood yet \\u2014 nothing is retracted until they are.";
1186
+ } else {
1187
+ status.className = "edit-status ok";
1188
+ status.textContent = (result && (result.added || result.removed))
1189
+ ? "synced \\u2014 " + result.added + " fact(s) written, " + result.removed + " retracted."
1190
+ : "synced \\u2014 no change.";
1191
+ }
1192
+ renderEditPlacements();
1193
+ }
1194
+
1195
+ // ---- booting ---------------------------------------------------------
1196
+ // Nothing plays on load: booting draws the opening state and stops there.
1197
+ // Ticking waits for the deck's own play control, exactly like mud.html.
1198
+ let bootSeq = 0;
1199
+ async function boot() {
1200
+ const seq = bootSeq += 1;
1201
+ autoOn = false;
1202
+ if (ticker) { ticker.pause(); ticker = null; }
1203
+ globalTurn = 0;
1204
+ tickQueue = createSerialQueue();
1205
+ camera = { mode: "follow", selectedId: null, status: null };
1206
+ const s = scenario();
1207
+ const foxes = pickRoster(rosterOf(s, "predator"), chosenFoxCount());
1208
+ const goblins = pickRoster(rosterOf(s, "prey"), chosenGoblinCount());
1209
+ cast = foxes.concat(goblins);
1210
+ showFoxCount(foxes.length);
1211
+ showGoblinCount(goblins.length);
1212
+ props = propPlacementsFrom((s.worldPayload && s.worldPayload.facts) || [], DATA.assetManifest);
1213
+ const opened = await window.tmct.open(s.worldPayload, { agents: cast, epoch: 0 });
1214
+ if (seq !== bootSeq) return;
1215
+ session = opened;
1216
+ agentsById = {};
1217
+ itemsById = {};
1218
+ el("chatInput").disabled = false;
1219
+ callScene("boot", { propPlacements: props, assetManifest: DATA.assetManifest, gridSize: DATA.gridSize, cellSize: 1 });
1220
+
1221
+ // The opening board, drawn through the very path a tick takes. Without
1222
+ // this the page's first sight of where anything stands is the first tick,
1223
+ // so every mesh sits unplaced and the map panel is blank until the visitor
1224
+ // presses play. The engine mints the cast at seeded cells, so the ids and
1225
+ // the cells both come back from it rather than being guessed here.
1226
+ const opening = await session.board();
1227
+ if (seq !== bootSeq) return;
1228
+ globalTurn = opening.turn || 0;
1229
+ camera.selectedId = Object.keys(opening.agents || {}).sort()[0] || null;
1230
+ applyTickResult(opening);
1231
+ renderAll();
1232
+ }
1233
+
1234
+ function renderAll() {
1235
+ renderHudRow();
1236
+ renderMapPanel();
1237
+ renderAgentSelect();
1238
+ renderCameraButtons();
1239
+ renderChatPills();
1240
+ el("globalTurnCount").textContent = "turns: " + globalTurn;
1241
+ }
1242
+
1243
+ wireDeck();
1244
+ wirePillComplete();
1245
+ boot();
1246
+ })();`;
1247
+ }