@polycode-projects/the-mechanical-code-talker 3.2.0 → 4.0.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.
Files changed (34) hide show
  1. package/corpus/sprites/src/sprite-facts.jsonl +28 -0
  2. package/corpus/tier2/generate.mjs +10 -1
  3. package/corpus/tier2/human.jsonl +23 -0
  4. package/corpus/tier2/manifest.json +3 -3
  5. package/corpus/worlds/index.json.gz +0 -0
  6. package/corpus/worlds/manifest.json +15 -5
  7. package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
  8. package/corpus/worlds/src/mud-garden.jsonl +101 -0
  9. package/package.json +2 -1
  10. package/src/adapters/p2p/webrtc-transport.mjs +146 -0
  11. package/src/domain/game-config.mjs +67 -0
  12. package/src/domain/grammar/ace.mjs +11 -3
  13. package/src/domain/grammar/lexicon-core.json +3 -0
  14. package/src/domain/grammar/lexicon.mjs +13 -0
  15. package/src/domain/memory/trust.mjs +15 -0
  16. package/src/domain/p2p/facts.mjs +81 -0
  17. package/src/domain/p2p/peer-id.mjs +32 -0
  18. package/src/domain/p2p/provenance-relabel.mjs +26 -0
  19. package/src/domain/p2p/sync-filter.mjs +31 -0
  20. package/src/domain/p2p/wire.mjs +123 -0
  21. package/src/domain/sprite-map.mjs +10 -2
  22. package/src/services/adventure-editor.mjs +10 -2
  23. package/src/services/adventure-viz.mjs +192 -39
  24. package/src/services/adventure.mjs +989 -69
  25. package/src/services/chat-page-viz.mjs +1060 -7
  26. package/src/services/chat-session.mjs +28 -3
  27. package/src/services/chat.mjs +2 -2
  28. package/src/services/mud-editor.mjs +313 -0
  29. package/src/services/mud-turn.mjs +572 -0
  30. package/src/services/mud-viz.mjs +2055 -0
  31. package/src/services/p2p-room.mjs +559 -0
  32. package/src/surfaces/web/memory-ask-browser.bundle.js +77 -77
  33. package/src/surfaces/web/mud-browser-entry.mjs +330 -0
  34. package/src/surfaces/web/p2p-browser-entry.mjs +39 -0
@@ -0,0 +1,2055 @@
1
+ // mud-viz.mjs — mud.html: the self-contained proof of the shared,
2
+ // multi-character shape PLAN_MUD.md's "Demo phase" section describes. One,
3
+ // two or four burrowing animals — the deck's own players slider picks how
4
+ // many — are drawn from the world's roster and each get their own pane over
5
+ // ONE shared live world (mud-browser-entry.mjs's createMudSession); an
6
+ // omniscient survey of the whole burrow sits in the top row beside the
7
+ // controls with no fog of war at all, the one deliberate exception
8
+ // PLAN_MUD.md names.
9
+ //
10
+ // The deck's second cast slider adds NPCs: one to ten more animals in the
11
+ // same world, drawn from the same roster, running the same scripted turn as
12
+ // a pane's own character and differing in exactly one way — no pane. They are
13
+ // ordinary world individuals throughout, so every reader here (the survey,
14
+ // a pane's room view, the talk affordances, the room caption) picks them up
15
+ // as "another character present" without knowing they exist. Two things do
16
+ // know: the survey draws them in one shared colour because the four actor
17
+ // hues belong to the panes, and the eaten scene needs a pane to play in, so a
18
+ // taken NPC just stops. They carry no mgx:is-npc marker either — that
19
+ // predicate drives adventure.mjs's scripted-by-data scheduler, which is a
20
+ // different mechanism from these.
21
+ //
22
+ // The stage is built in the browser, never fixed at build time: paneMarkup is
23
+ // spliced into the page script alongside the render glue, and every boot
24
+ // rebuilds #mudStage with one call per cast member. The page ships the
25
+ // default cast's panes so the first paint is not an empty box, and the grid
26
+ // reads how many to lay out off the stage's own data-panes attribute.
27
+ //
28
+ // Shaped after adventure-viz.mjs/spider-fly-viz.mjs: one inlined <style>
29
+ // over viz-theme.mjs's shared tokens, behaviour as an inlined IIFE, the
30
+ // engine arriving via a sibling <script src="./mud-browser.bundle.js">
31
+ // (mirroring adventure-viz.mjs's own worldPayload-embedding rationale — the
32
+ // world's canonical source is a Node-only gzipped JSONL shard the browser
33
+ // cannot read, so it is read ONCE at build time through the real worlds-pack
34
+ // provider and embedded as page data). `createTicker` and a small set of
35
+ // self-contained, `.toString()`-splice-safe room-scene helpers are spliced
36
+ // into the inline script exactly the way spider-fly-viz.mjs splices its own
37
+ // render-glue — `roomSceneObjects`/`scenePlacement`/`spriteClassForObject`/
38
+ // `spriteAncestryRows`/`factsForSubject`/`visibleRoomOf`/`roomKindForRoom`
39
+ // are REUSED directly from adventure-viz.mjs rather than re-derived:
40
+ // mud-garden ships no individual
41
+ // named "player" (the whole point of the multi-character demo), so those
42
+ // functions' own hardcoded "player" exclusion never fires for a mud
43
+ // character — every character reads back as an ordinary visible object of
44
+ // its room until this page's own code filters the CURRENT viewing character
45
+ // out by name (mudRoomSceneObjects, below).
46
+ //
47
+ // A room's graphic is a rendering of worldDigestRows'/roomAffordances' own
48
+ // text digest, never a replacement for it (PLAN_MUD.md's own "Room view"
49
+ // spec): a soil-toned canvas backdrop (roomKindForRoom picks outdoor/
50
+ // underground), the viewing character's own sprite standing right, every
51
+ // room-mate entering from the left, and every loose object hung on the back
52
+ // wall in a portrait frame (scenePlacement decides how high it hangs). The
53
+ // six compass affordances sit around that box where their own directions
54
+ // point, each one either the "go" a written exit already allows or the "dig"
55
+ // that would open one.
56
+ //
57
+ // The burrow survey is ONE svg routine (burrowSvg over burrowGraph) drawn
58
+ // twice: the omniscient board in the top row, and each pane's own
59
+ // visited-only "known ground". Same layout, same treatment, one code path —
60
+ // adapted from adventure-viz.mjs's own roomMapSvg/visitedRoomGraph pair, with
61
+ // a cell-collision nudge burrowGraph needs and that page does not (a mud
62
+ // character can dig down AND south out of one room, and both land on the
63
+ // same grid cell otherwise).
64
+ //
65
+ // ONE ticker instance per pane (viz-ticker.mjs's createTicker), so
66
+ // each can play/step independently, plus the deck's own "play" control that
67
+ // calls .play()/.pause() on both pane tickers at once. Nothing plays until
68
+ // that control is clicked. Every tick, from ANY pane, is funneled through one
69
+ // shared async queue (serializeTick, spliced below) so two characters' turns
70
+ // can never interleave their reads/writes of the one shared memoryDir —
71
+ // mud-browser-entry.mjs's own header names this as the caller's
72
+ // responsibility, and this is where that responsibility is discharged. The
73
+ // queue is also what makes the deck's GLOBAL turn counter well-defined: it
74
+ // increments in the exact order turns actually executed, never a race
75
+ // between panes.
76
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
77
+ import { createTicker } from "./viz-ticker.mjs";
78
+ import {
79
+ roomSceneObjects, scenePlacement, spriteClassForObject, spriteAncestryRows, factsForSubject,
80
+ visibleRoomOf, roomKindForRoom, allRoomIds,
81
+ } from "./adventure-viz.mjs";
82
+ import { renderMudEditorText, wordBeforeCursor } from "./mud-editor.mjs";
83
+ import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
84
+
85
+ const DEFAULT_TITLE = "tmct — the mud";
86
+ // No display-face embedding pipeline exists anywhere in this project yet
87
+ // (grep turns up no @font-face/font-display in any *-viz.mjs) — Fraunces
88
+ // itself never ships, so DISPLAY_STACK is SERIF_STACK's own web-safe serif
89
+ // fallback, named separately only so a later session that DOES add a font
90
+ // pipeline has one obvious constant to point at Fraunces instead of typing
91
+ // "Georgia" into headings by hand.
92
+ const DISPLAY_STACK = SERIF_STACK;
93
+ const SANS_STACK = `"IBM Plex Sans", "Inter", -apple-system, BlinkMacSystemFont, sans-serif`;
94
+
95
+ const ROOT_ROOM = "garden";
96
+ const PANE_SLOTS = ["a", "b", "c", "d"];
97
+ // The players slider's own detents. Every count here divides the two-column
98
+ // grid evenly, so no layout ever carries a half-empty row.
99
+ const PLAYER_COUNTS = [1, 2, 4];
100
+ const DEFAULT_PLAYER_COUNT = 2;
101
+ // The npcs slider is a plain count, not a detent list: nothing lays an NPC
102
+ // out, so every number between its ends is as good as any other.
103
+ const NPC_COUNT_MIN = 1;
104
+ const NPC_COUNT_MAX = 10;
105
+ const NPC_COUNT_LABELLED = [1, 5, 10];
106
+ const DEFAULT_NPC_COUNT = 2;
107
+ const DEFAULT_DELAY_MS = 650;
108
+ const DEFAULT_MAX_TURNS = 400;
109
+
110
+ const MUD_NOTE_LINES = [
111
+ "Burrowing animals share one world here. The players slider picks how many get a window of their own; the npcs slider adds more animals that dig and forage without one. Each one only knows what it has dug up, asked about, or been told. Nobody sees the whole burrow except you, watching from the survey above.",
112
+ `This is a MUD, short for Multi Underground creature Dig. The name nods to MUD, or in its current form MUDII (mudii.co.uk), one of the first multiplayer text games. The dig-your-own-rooms idea came from a skim of Wikipedia's Colossal Cave Adventure article, the game that started the genre.`,
113
+ ];
114
+
115
+ /** A character id's species — "mole-1" -> "mole", "groundhog-1" ->
116
+ * "groundhog" — mirroring spider-fly-viz.mjs's own classOfAgentId. Self-
117
+ * contained, `.toString()`-splice safe. */
118
+ export function speciesOfCharacter(id) {
119
+ return String(id).replace(/-\d+$/, "");
120
+ }
121
+
122
+ /** Every object visible in `here` EXCEPT `viewer` itself — `roomSceneObjects`
123
+ * (adventure-viz.mjs) only ever excludes the literal id "player", which no
124
+ * mud-garden character is ever named, so every OTHER character sharing the
125
+ * room (and every loose object) comes back exactly like any prop; this page
126
+ * draws the viewer's own sprite separately, so it is the one subject this
127
+ * wrapper drops. Pure, self-contained (roomSceneObjects is spliced
128
+ * alongside it). */
129
+ export function mudRoomSceneObjects(rows, state, here, viewer) {
130
+ return roomSceneObjects(rows, state, here).filter((o) => o.subject !== viewer);
131
+ }
132
+
133
+ /** Every object `character` carries, sorted, each with its sprite class —
134
+ * mud's own version of adventure-viz.mjs's carriedItems, parametrized by
135
+ * character instead of hardcoded to "player" (mud-garden ships no such
136
+ * individual). Pure, self-contained. */
137
+ export function carriedItemsFor(rows, state, character) {
138
+ return [...state.placements]
139
+ .filter(([, p]) => p.predicate === "mgx:located-in" && p.object === character)
140
+ .map(([subject]) => ({ subject, spriteClass: spriteClassForObject(rows, subject) }))
141
+ .sort((a, b) => a.subject.localeCompare(b.subject));
142
+ }
143
+
144
+ /** Every room's depth level, `Map<roomId, number>`, BFS from `root` at level
145
+ * 0: an "up"/"down" exit moves the level by ∓ 1, any other direction
146
+ * keeps the level unchanged — what tells the survey where the turf line
147
+ * falls. A room unreachable from `root` (should not happen — every dug room
148
+ * writes a two-way exit back) is simply absent from the map rather than
149
+ * guessed at. Pure, self-contained. */
150
+ export function levelsOf(state, root = "garden") {
151
+ const levels = new Map([[root, 0]]);
152
+ const queue = [root];
153
+ while (queue.length) {
154
+ const room = queue.shift();
155
+ const level = levels.get(room);
156
+ for (const [direction, target] of state.exits.get(room) ?? []) {
157
+ if (levels.has(target)) continue;
158
+ const delta = direction === "down" ? -1 : direction === "up" ? 1 : 0;
159
+ levels.set(target, level + delta);
160
+ queue.push(target);
161
+ }
162
+ }
163
+ return levels;
164
+ }
165
+
166
+ /** Which characters currently stand in `room` — the survey's own per-room
167
+ * roster, both for the omniscient board and a pane's own visited-only one.
168
+ * Pure. */
169
+ export function charactersInRoom(state, room, characters) {
170
+ return characters.filter((c) => state.placements.get(c)?.object === room);
171
+ }
172
+
173
+ /** The rooms in `roomIds` laid out on an integer grid FROM the world's own
174
+ * has-exit-* directions — never a force-directed guess, so a room north of
175
+ * another sits one row above it and a room dug DOWN sits one row below.
176
+ * Adapted from adventure-viz.mjs's `visitedRoomGraph`, with two differences
177
+ * a burrow needs and a manor does not: a cell already taken nudges right
178
+ * rather than stacking two rooms on one square (dig down and dig south from
179
+ * the same room otherwise collide), and every node carries its own `level`
180
+ * so the renderer can draw the turf line between the surface and the soil.
181
+ * An edge is drawn only between two rooms BOTH in `roomIds`; `hints` names
182
+ * every exit from an included room toward one that is not — the direction
183
+ * only, never the excluded room's own name, so a fog-of-war caller can draw
184
+ * "there's a way on" and nothing more. Pure. */
185
+ export function burrowGraph(state, roomIds, root = "garden") {
186
+ const DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
187
+ const known = new Set(roomIds || []);
188
+ const positions = new Map();
189
+ const taken = new Set();
190
+ const edges = [];
191
+ const edgeKeys = new Set();
192
+ const hints = [];
193
+ const place = (room, x, y) => {
194
+ let px = x;
195
+ for (let guard = 0; taken.has(px + "," + y) && guard < 64; guard += 1) px += 1;
196
+ positions.set(room, { x: px, y });
197
+ taken.add(px + "," + y);
198
+ };
199
+ let offsetX = 0;
200
+ for (const start of [root, ...[...known].sort()]) {
201
+ if (!known.has(start) || positions.has(start)) continue;
202
+ place(start, offsetX, 0);
203
+ const queue = [start];
204
+ while (queue.length) {
205
+ const room = queue.shift();
206
+ const pos = positions.get(room);
207
+ const dirs = state.exits.get(room);
208
+ for (const direction of [...(dirs?.keys() ?? [])].sort()) {
209
+ const target = dirs.get(direction);
210
+ if (!known.has(target)) { hints.push({ from: room, direction }); continue; }
211
+ const key = [room, target].sort().join("\0");
212
+ if (!edgeKeys.has(key)) { edgeKeys.add(key); edges.push({ from: room, to: target, direction }); }
213
+ if (positions.has(target)) continue;
214
+ const d = DELTA[direction] ?? [0, 0];
215
+ place(target, pos.x + d[0], pos.y + d[1]);
216
+ queue.push(target);
217
+ }
218
+ }
219
+ offsetX = Math.max(...[...positions.values()].map((p) => p.x)) + 2;
220
+ }
221
+ const levels = levelsOf(state, root);
222
+ const xs = [...positions.values()].map((p) => p.x);
223
+ const ys = [...positions.values()].map((p) => p.y);
224
+ const minX = Math.min(0, ...xs);
225
+ const minY = Math.min(0, ...ys);
226
+ const nodes = [...known].filter((room) => positions.has(room)).sort().map((room) => {
227
+ const p = positions.get(room);
228
+ return { id: room, x: p.x - minX, y: p.y - minY, level: levels.has(room) ? levels.get(room) : null };
229
+ });
230
+ return { nodes, edges, hints };
231
+ }
232
+
233
+ /** The directions `here` can still be dug in: every direction the room's kind
234
+ * allows that has no written exit yet. Above ground there is nothing to
235
+ * tunnel sideways THROUGH, so a garden digs straight down and no other way;
236
+ * underground, all six are open soil. The engine is the authority on this —
237
+ * the page prefers `tmctMud.diggableDirections` whenever the bundle exposes
238
+ * one and only falls back to the rule below. Pure, self-contained
239
+ * (roomKindForRoom is spliced alongside it). */
240
+ export function diggableDirections(rows, state, here) {
241
+ const exits = state.exits.get(here);
242
+ const kind = roomKindForRoom(rows, here);
243
+ const allowed = kind === "outdoor" ? ["down"] : ["north", "south", "east", "west", "up", "down"];
244
+ return allowed.filter((direction) => !exits || !exits.has(direction));
245
+ }
246
+
247
+ /** A carried or loose object's display name. A dug object is minted as
248
+ * `<kind>-<the full nested room id it was dug from>` (adventure.mjs's
249
+ * freshRoomId), which reads as a path, not a thing — so a subject that ends
250
+ * in "-<a room this world actually has>" shows as its kind alone. Every
251
+ * other id (the world's hand-authored props: "carrot", "worm-1") is already
252
+ * a name and passes straight through. Pure. */
253
+ export function itemLabel(subject, roomIds) {
254
+ const id = String(subject);
255
+ for (const room of roomIds || []) {
256
+ const suffix = "-" + room;
257
+ if (id.length > suffix.length && id.endsWith(suffix)) return id.slice(0, -suffix.length);
258
+ }
259
+ return id;
260
+ }
261
+
262
+ /** Whether `subject` is a creature rather than a prop: the world places a
263
+ * character with `mgx:currently-in` and everything else with a containment
264
+ * predicate, the same split mud-turn.mjs's own "what is there to examine"
265
+ * filter reads. Pure — so the room view can put animals on the floor and
266
+ * props on the wall without a roster to check against, and a character the
267
+ * page is not itself driving (an NPC) still reads as an animal. */
268
+ export function isCreature(state, subject) {
269
+ return state.placements.get(subject)?.predicate === "mgx:currently-in";
270
+ }
271
+
272
+ /** The self-contained mud.html page. Pure — identical output for identical
273
+ * input; every other piece of state (the live world, chat, ticks) is
274
+ * computed in the browser once the sibling bundle loads.
275
+ * `characters` is the ROSTER this page may draw from, `[{ id, species }]`;
276
+ * the page ships the default cast's panes and rebuilds the whole stage at
277
+ * every boot, so how many of the roster play, and which of them, is decided
278
+ * live by the players slider and the engine's own pickMudRoster — the same
279
+ * draw the shared session is then opened for, never a second one. A count
280
+ * the roster cannot fill draws as many panes as it has animals.
281
+ * `worldPayload` is `{ name, facts, rules, opening }`, read once at build
282
+ * time through the real worlds-pack provider (see this module's own
283
+ * header). `spriteTemplates` is the
284
+ * large-tier sprite set (data/sprites-large/*.toml) so every species
285
+ * resolves its own art instead of falling back to the flat animal icon.
286
+ * `mudConfig` defaults to game-config.mjs's own DEFAULT_GAME_CONFIG.mud —
287
+ * the per-species mass/speed/dig-reach reference table each pane's stat line
288
+ * reads for flavor. `engineBundleJs` inlines the built mud-browser bundle
289
+ * instead of the sibling `<script src>`, mirroring spider-fly-viz.mjs's own
290
+ * standalone-export knob; default empty keeps the site build's sibling-file
291
+ * arrangement unchanged. */
292
+ export function renderMudHtml({
293
+ title = DEFAULT_TITLE,
294
+ worldPayload,
295
+ characters = [],
296
+ spriteTemplates = [],
297
+ mudConfig = DEFAULT_GAME_CONFIG.mud,
298
+ engineBundleJs = "",
299
+ } = {}) {
300
+ const openingCount = Math.max(1, Math.min(DEFAULT_PLAYER_COUNT, characters.length || DEFAULT_PLAYER_COUNT));
301
+ const pageData = embedJson({
302
+ worldPayload, characters, spriteTemplates, mudConfig,
303
+ paneSlots: PANE_SLOTS,
304
+ playerCounts: PLAYER_COUNTS,
305
+ defaultPlayerCount: DEFAULT_PLAYER_COUNT,
306
+ defaultNpcCount: DEFAULT_NPC_COUNT,
307
+ rootRoom: ROOT_ROOM,
308
+ defaultDelayMs: DEFAULT_DELAY_MS,
309
+ defaultMaxTurns: DEFAULT_MAX_TURNS,
310
+ });
311
+
312
+ const paneHtml = PANE_SLOTS.slice(0, openingCount).map((slot) => paneMarkup(slot)).join("\n");
313
+
314
+ return `<!doctype html>
315
+ <html lang="en">
316
+ <head>
317
+ <meta charset="utf-8">
318
+ <meta name="viewport" content="width=device-width, initial-scale=1">
319
+ <title>${escapeHtml(title)}</title>
320
+ ${engineBundleJs ? "" : `<link rel="icon" href="./favicon.svg" type="image/svg+xml">
321
+ <link rel="icon" href="./favicon.ico" sizes="any">
322
+ <link rel="apple-touch-icon" href="./apple-touch-icon.png">`}
323
+ <style>
324
+ ${THEME_TOKENS_CSS}
325
+ ${MUD_STYLE}
326
+ </style>
327
+ </head>
328
+ <body>
329
+ <main>
330
+ <h1 class="eyebrow">tmct &middot; mud</h1>
331
+ <div class="deck-row">
332
+ <section class="deck" aria-label="simulation controls">
333
+ <div class="deck-controls">
334
+ <button type="button" class="deck-play" id="autoToggle" aria-pressed="false">&#9654; play</button>
335
+ <button type="button" id="resetBtn">reset</button>
336
+ <button type="button" id="editModeBtn" aria-pressed="false">edit</button>
337
+ <button type="button" class="deck-info-btn" id="deckInfoBtn" aria-expanded="false" aria-controls="deckInfoPopup" aria-label="about this demo">?</button>
338
+ <span class="mono deck-turns" id="globalTurnCount">turns: 0</span>
339
+ </div>
340
+ <div class="deck-sliders">
341
+ <label class="deck-slider">players
342
+ <input type="range" id="playerCountSlider" min="0" max="${PLAYER_COUNTS.length - 1}" step="1"
343
+ value="${Math.max(0, PLAYER_COUNTS.indexOf(DEFAULT_PLAYER_COUNT))}"
344
+ list="playerCountTicks" aria-valuetext="${DEFAULT_PLAYER_COUNT} players">
345
+ <datalist id="playerCountTicks">${PLAYER_COUNTS.map((n, i) => `<option value="${i}" label="${n}"></option>`).join("")}</datalist>
346
+ <span class="mono" id="playerCountValue">${DEFAULT_PLAYER_COUNT}</span>
347
+ </label>
348
+ <label class="deck-slider">npcs
349
+ <input type="range" id="npcCountSlider" min="${NPC_COUNT_MIN}" max="${NPC_COUNT_MAX}" step="1"
350
+ value="${DEFAULT_NPC_COUNT}"
351
+ list="npcCountTicks" aria-valuetext="${DEFAULT_NPC_COUNT} npcs">
352
+ <datalist id="npcCountTicks">${Array.from({ length: NPC_COUNT_MAX - NPC_COUNT_MIN + 1 }, (_, i) => {
353
+ const n = NPC_COUNT_MIN + i;
354
+ return NPC_COUNT_LABELLED.includes(n) ? `<option value="${n}" label="${n}"></option>` : `<option value="${n}"></option>`;
355
+ }).join("")}</datalist>
356
+ <span class="mono" id="npcCountValue">${DEFAULT_NPC_COUNT}</span>
357
+ </label>
358
+ <label class="deck-slider">delay
359
+ <input type="range" id="delaySlider" min="80" max="2000" step="20" value="${DEFAULT_DELAY_MS}">
360
+ <span class="mono" id="delayValue">${DEFAULT_DELAY_MS}ms</span>
361
+ </label>
362
+ <label class="deck-slider">max turns
363
+ <input type="range" id="maxTurnsSlider" min="20" max="2000" step="20" value="${DEFAULT_MAX_TURNS}">
364
+ <span class="mono" id="maxTurnsValue">${DEFAULT_MAX_TURNS}</span>
365
+ </label>
366
+ </div>
367
+ <div class="deck-info-popup mud-note" id="deckInfoPopup" role="dialog" aria-label="about this demo" hidden>
368
+ <p>${escapeHtml(MUD_NOTE_LINES[0])}</p>
369
+ <p>${escapeHtml(MUD_NOTE_LINES[1])}</p>
370
+ <button type="button" class="deck-info-popup-close" id="deckInfoClose" aria-label="close">&times;</button>
371
+ </div>
372
+ </section>
373
+ <section class="world-map" id="worldMap" aria-label="the whole burrow, every room, every character">
374
+ <div class="world-map-head">
375
+ <span class="world-map-title">the whole burrow</span>
376
+ <span class="mono world-map-turn" id="worldMapTurn">turn 0</span>
377
+ </div>
378
+ <div class="world-map-board" id="worldMapBoard"></div>
379
+ <div class="world-map-key" id="worldMapKey"></div>
380
+ </section>
381
+ </div>
382
+ <div class="mud-stage" id="mudStage" data-panes="${openingCount}">
383
+ ${paneHtml}
384
+ </div>
385
+ <div class="edit-stage" id="mudEditStage" aria-label="the burrow's own facts, in plain sentences">
386
+ <section class="edit-text" aria-label="the world's facts as editable sentences">
387
+ <h2>the burrow, in plain sentences</h2>
388
+ <p class="edit-lede">Every fact this world is built from. Change a line and the burrow changes with it &mdash; the survey beside you redraws as you type.</p>
389
+ <textarea id="editorText" spellcheck="false" aria-label="the world's own facts as plain sentences, one per line"></textarea>
390
+ <div class="chatpills" id="editorPills" aria-label="related words for the term before the cursor"></div>
391
+ <p class="edit-status" id="editorStatus" role="status"></p>
392
+ </section>
393
+ <aside class="edit-side" aria-label="the burrow as written, a clicked room, and the legend">
394
+ <section class="world-map edit-map" aria-label="every room this world writes">
395
+ <div class="world-map-head"><span class="world-map-title">the burrow as written</span></div>
396
+ <div class="world-map-board" id="editMapBoard"></div>
397
+ </section>
398
+ <section class="edit-panel" id="roomDetailPanel">
399
+ <h2 id="roomDetailTitle">click a room</h2>
400
+ <div class="room-detail-cast" id="roomDetailCast"></div>
401
+ <p class="edit-caption" id="roomDetailCaption">Click a room on the survey to see what stands in it.</p>
402
+ </section>
403
+ <section class="edit-panel">
404
+ <h2>legend</h2>
405
+ <div class="edit-legend" id="editLegend"></div>
406
+ </section>
407
+ </aside>
408
+ </div>
409
+ </main>
410
+ <script>
411
+ const MUD_PAGE_DATA = ${pageData};
412
+ </script>
413
+ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `<script src="./mud-browser.bundle.js"></script>`}
414
+ <script>
415
+ ${embedScriptText(pageScript())}
416
+ </script>
417
+ </body>
418
+ </html>
419
+ `;
420
+ }
421
+
422
+ /** One pane, character-agnostic: which animal it shows is stamped in at boot
423
+ * (`data-character`, the heading, the chat dock's own labels), so the page
424
+ * can draw a different cast out of the roster on every reset without a
425
+ * rebuild. Pure and self-contained (escapeHtml is spliced alongside it) —
426
+ * the page script calls this same function to build the stage in the
427
+ * browser, so the panes a count change adds are the panes this file ships. */
428
+ export function paneMarkup(slot) {
429
+ const w = `window-${slot}`;
430
+ return ` <section class="mud-window pane-${escapeHtml(slot)}" id="${w}" data-slot="${escapeHtml(slot)}" data-character="">
431
+ <div class="pane-head">
432
+ <h2 id="${w}-name">waiting</h2>
433
+ <span class="mono pane-turn" id="${w}-turn">turn 0</span>
434
+ </div>
435
+ <div class="room-stage">
436
+ <div class="room-view" id="${w}-room">
437
+ <canvas id="${w}-canvas" width="420" height="152" aria-hidden="true"></canvas>
438
+ <div class="wall-band" id="${w}-wall"></div>
439
+ <div class="floor-band">
440
+ <div class="floor-others" id="${w}-others"></div>
441
+ <div class="floor-self" id="${w}-self"></div>
442
+ </div>
443
+ <div class="bubbles" id="${w}-bubbles"></div>
444
+ <div class="dig-flourish" id="${w}-flourish" hidden></div>
445
+ <div class="strike-flash" id="${w}-strike" aria-hidden="true"></div>
446
+ </div>
447
+ <div class="dir-ring" id="${w}-dirs" aria-label="ways out of this room">
448
+ <div class="dir-slot dir-north" id="${w}-dir-north"></div>
449
+ <div class="dir-slot dir-up" id="${w}-dir-up"></div>
450
+ <div class="dir-slot dir-west" id="${w}-dir-west"></div>
451
+ <div class="dir-slot dir-east" id="${w}-dir-east"></div>
452
+ <div class="dir-slot dir-south" id="${w}-dir-south"></div>
453
+ <div class="dir-slot dir-down" id="${w}-dir-down"></div>
454
+ </div>
455
+ </div>
456
+ <p class="room-caption" id="${w}-caption"></p>
457
+ <div class="pane-columns">
458
+ <div class="pouch" aria-label="pouch">
459
+ <h3>pouch</h3>
460
+ <ul class="pouch-list" id="${w}-pouch-list"></ul>
461
+ <p class="mono stat-line" id="${w}-stats"></p>
462
+ </div>
463
+ <div class="minimap" aria-label="what this character has dug or walked">
464
+ <h3>known ground</h3>
465
+ <div class="minimap-board" id="${w}-minimap"></div>
466
+ </div>
467
+ </div>
468
+ <div class="chat">
469
+ <div class="chatlog" id="${w}-chatlog" aria-live="polite"></div>
470
+ <div class="log-popup" id="${w}-logpop" role="dialog" aria-label="the whole reply" hidden>
471
+ <p class="log-popup-text" id="${w}-logpop-text"></p>
472
+ <button type="button" class="log-popup-close" id="${w}-logpop-close" aria-label="close the whole reply">&times;</button>
473
+ </div>
474
+ </div>
475
+ <div class="chat-console">
476
+ <div class="chatpills" id="${w}-chatpills" role="group" aria-label="quick commands"></div>
477
+ <form class="chatask" id="${w}-chatform">
478
+ <span class="prompt mono">tmct&gt;</span>
479
+ <input id="${w}-chatq" type="text" placeholder="look" aria-label="type a command" disabled>
480
+ </form>
481
+ </div>
482
+ <div class="pane-controls">
483
+ <button type="button" id="${w}-play" disabled>&#9654; play</button>
484
+ <button type="button" id="${w}-step" disabled>step</button>
485
+ <p class="pane-fate" id="${w}-fate" role="status" hidden></p>
486
+ </div>
487
+ </section>`;
488
+ }
489
+
490
+ const MUD_STYLE = `
491
+ :root {
492
+ --soil-deep: #241710; --soil-mid: #4A3324; --soil-light: #7A5A3D;
493
+ --root-moss: #6B7A4F; --parchment: #EFE6D8; --mud-ink: #2A211A;
494
+ --burrow-glow: #E8A33D; --chalk: #D9CDB9;
495
+ /* Four hues that stay apart at the size an occupant dot is drawn, and all
496
+ four sit on the dark soil board as well as on parchment. Nothing green
497
+ enough to sink into a turf-filled surface room. */
498
+ --actor-a: #E0912A; --actor-b: #5F97B3; --actor-c: #BFC85F; --actor-d: #AE749E;
499
+ --pane-height: 618px;
500
+ --room-height: 152px;
501
+ }
502
+ html { background: var(--soil-deep); }
503
+ body { margin: 0; background: linear-gradient(180deg, var(--root-moss) 0%, var(--soil-light) 14%, var(--soil-mid) 48%, var(--soil-deep) 100%) fixed; color: var(--mud-ink); font-family: ${SANS_STACK}; font-size: 15px; line-height: 1.5; }
504
+ .mono { font-family: ${MONO_STACK}; }
505
+ main { max-width: 1280px; margin: 0 auto; padding: 1.1rem 1.2rem 2.4rem; }
506
+ .eyebrow { font-family: ${MONO_STACK}; font-weight: 500; font-size: .72rem; letter-spacing: .16em; text-transform: uppercase; color: var(--parchment); opacity: .9; margin: 0 0 .8rem; }
507
+ h2 { font-family: ${DISPLAY_STACK}; font-size: 1rem; margin: 0; text-transform: capitalize; }
508
+ h3 { font-family: ${MONO_STACK}; font-size: .58rem; margin: 0 0 .3rem; text-transform: uppercase; letter-spacing: .12em; color: var(--soil-mid); }
509
+ button { font: inherit; color: inherit; background: none; cursor: pointer; }
510
+ button:focus-visible, input:focus-visible { outline: 2px solid var(--burrow-glow); outline-offset: 2px; }
511
+ button:disabled { opacity: .4; cursor: default; }
512
+
513
+ /* ---- top row: the control deck, and the survey beside it ---- */
514
+ .deck-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: stretch; margin-bottom: 1rem; }
515
+ .deck {
516
+ position: relative;
517
+ background: var(--parchment); border: 1px solid var(--soil-mid); border-radius: 4px;
518
+ box-shadow: 0 2px 0 rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.35);
519
+ padding: .8rem .9rem; display: flex; flex-direction: column; gap: .55rem; min-width: 0;
520
+ }
521
+ .deck-controls { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
522
+ .deck-info-btn {
523
+ font-family: ${MONO_STACK}; font-size: .78rem; line-height: 1; width: 1.5rem; height: 1.5rem;
524
+ border-radius: 50%; border: 1px solid var(--soil-mid); background: rgba(255,255,255,.5);
525
+ color: var(--soil-mid); padding: 0; flex: 0 0 auto;
526
+ }
527
+ .deck-info-btn:hover, .deck-info-btn[aria-expanded="true"] { border-color: var(--burrow-glow); color: var(--mud-ink); }
528
+ .deck button, .pane-controls button {
529
+ font-family: ${MONO_STACK}; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em;
530
+ padding: .32rem .7rem; border: 1px solid var(--soil-mid); border-radius: 3px; background: rgba(255,255,255,.5);
531
+ }
532
+ .deck button:hover:not(:disabled), .pane-controls button:hover:not(:disabled) { border-color: var(--burrow-glow); }
533
+ .deck-play { background: var(--mud-ink) !important; color: var(--parchment); border-color: var(--mud-ink) !important; padding: .38rem 1.1rem !important; }
534
+ .deck-play[aria-pressed="true"] { background: var(--burrow-glow) !important; border-color: var(--burrow-glow) !important; color: var(--mud-ink); }
535
+ .deck-turns { margin-left: auto; font-size: .74rem; color: var(--soil-mid); }
536
+ .deck-sliders { display: flex; flex-wrap: wrap; gap: 1rem; }
537
+ .deck-slider { display: flex; align-items: center; gap: .35rem; font-family: ${MONO_STACK}; font-size: .62rem; text-transform: uppercase; letter-spacing: .08em; color: var(--soil-mid); }
538
+ .deck-slider input[type="range"] { accent-color: var(--burrow-glow); width: 8rem; max-width: 34vw; }
539
+ .deck-info-popup {
540
+ position: absolute; left: .9rem; right: .9rem; top: calc(100% + 8px); z-index: 8;
541
+ background: var(--soil-deep); color: var(--parchment);
542
+ border: 1px solid var(--burrow-glow); border-radius: 4px;
543
+ padding: .55rem 1.6rem .6rem .65rem; box-shadow: 0 8px 18px rgba(0,0,0,.5);
544
+ animation: popup-rise .16s ease-out;
545
+ }
546
+ .deck-info-popup::before {
547
+ content: ""; position: absolute; left: 1.1rem; top: -6px; width: 0; height: 0;
548
+ border-left: 6px solid transparent; border-right: 6px solid transparent; border-bottom: 6px solid var(--burrow-glow);
549
+ }
550
+ .deck-info-popup p { margin: 0 0 .4rem; max-width: 62ch; font-size: .78rem; line-height: 1.4; }
551
+ .deck-info-popup p:last-child { margin-bottom: 0; }
552
+ .deck-info-popup-close { position: absolute; top: .1rem; right: .3rem; font-size: 1rem; line-height: 1; color: var(--parchment); padding: .1rem .2rem; }
553
+ .deck-info-popup-close:hover { color: var(--burrow-glow); }
554
+ @media (prefers-reduced-motion: reduce) { .deck-info-popup { animation: none; } }
555
+
556
+ .world-map {
557
+ background: var(--soil-deep); color: var(--parchment);
558
+ border: 1px solid var(--burrow-glow); border-radius: 4px; padding: .55rem .65rem .6rem;
559
+ box-shadow: 0 2px 0 rgba(0,0,0,.3); display: flex; flex-direction: column; gap: .4rem; min-width: 0;
560
+ }
561
+ .world-map-head { display: flex; justify-content: space-between; align-items: baseline; gap: .5rem; }
562
+ .world-map-title { font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em; opacity: .85; }
563
+ .world-map-turn { font-size: .62rem; opacity: .7; }
564
+ .world-map-board { flex: 1; min-height: 168px; display: flex; align-items: center; justify-content: center; }
565
+ .world-map-board svg { width: 100%; height: 100%; max-height: 230px; }
566
+ .world-map-key { display: flex; flex-wrap: wrap; gap: .5rem; font-family: ${MONO_STACK}; font-size: .58rem; opacity: .85; }
567
+ .key-actor, .key-npcs { display: inline-flex; align-items: center; gap: .28rem; }
568
+ .key-npcs { opacity: .75; }
569
+ .key-dot { width: .5rem; height: .5rem; border-radius: 50%; display: inline-block; }
570
+
571
+ /* ---- the survey itself, drawn twice at two sizes ---- */
572
+ .burrow svg { display: block; }
573
+ .burrow .turf { fill: var(--root-moss); opacity: .3; }
574
+ .burrow .ground-line { stroke: var(--root-moss); stroke-width: 1.2; stroke-dasharray: 5 3; opacity: .9; }
575
+ .burrow .tunnel { stroke: var(--soil-light); stroke-width: 5.5; stroke-linecap: round; opacity: .85; }
576
+ .burrow .tunnel.shaft { stroke-dasharray: 3 3.5; stroke-width: 4; }
577
+ .burrow .hint { fill: var(--chalk); opacity: .45; }
578
+ .burrow .room rect { fill: var(--soil-mid); stroke: var(--chalk); stroke-width: .8; }
579
+ .burrow .room.surface rect { fill: var(--root-moss); }
580
+ .burrow .room text { fill: var(--parchment); font-family: ${MONO_STACK}; font-size: 7px; text-anchor: middle; }
581
+ .burrow .room.freshly-dug rect { stroke: var(--burrow-glow); stroke-width: 1.8; animation: dig-pulse 1.2s ease-out; }
582
+ .burrow .occupant { stroke: rgba(0,0,0,.45); stroke-width: .6; }
583
+ @keyframes dig-pulse { 0% { opacity: .25; } 100% { opacity: 1; } }
584
+ @media (prefers-reduced-motion: reduce) { .burrow .room.freshly-dug rect { animation: none; } }
585
+
586
+ /* ---- the panes ----
587
+ Two columns hold every count the players slider offers: four fills them
588
+ twice over, two fills them once. One is the exception, and it earns it —
589
+ a lone animal takes a single centred column at a reading measure and
590
+ spends the width it saves on a taller room and a longer log, so the page
591
+ reads as one portrait rather than one pane marooned in an empty grid. */
592
+ .mud-stage { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; align-items: start; }
593
+ .mud-stage[data-panes="1"] {
594
+ grid-template-columns: minmax(0, 46rem); justify-content: center;
595
+ --pane-height: 700px; --room-height: 196px;
596
+ }
597
+ .mud-window {
598
+ background: var(--parchment); border: 1px solid var(--soil-mid); border-radius: 4px;
599
+ box-shadow: 0 2px 0 rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.35);
600
+ padding: .7rem .8rem; display: flex; flex-direction: column; gap: .5rem;
601
+ min-width: 0; height: var(--pane-height); box-sizing: border-box; overflow: hidden;
602
+ border-top: 3px solid var(--actor-a);
603
+ }
604
+ .mud-window.pane-b { border-top-color: var(--actor-b); }
605
+ .mud-window.pane-c { border-top-color: var(--actor-c); }
606
+ .mud-window.pane-d { border-top-color: var(--actor-d); }
607
+ .pane-head { display: flex; align-items: baseline; justify-content: space-between; gap: .5rem; }
608
+ .char-id { font-size: .66rem; color: var(--soil-mid); margin-left: .35rem; text-transform: none; }
609
+ .pane-turn { font-size: .68rem; color: var(--soil-mid); }
610
+
611
+ /* ---- room view: one row of soil, the compass around it ---- */
612
+ .room-stage { position: relative; padding: 19px 34px; flex: 0 0 auto; }
613
+ .room-view { position: relative; height: var(--room-height); border-radius: 3px; overflow: hidden; border: 1px solid var(--soil-mid); }
614
+ .room-view canvas { display: block; width: 100%; height: 100%; }
615
+ .wall-band { position: absolute; left: 0; right: 0; top: 4px; height: 62px; display: flex; align-items: flex-start; justify-content: center; gap: .5rem; }
616
+ .wall-item { display: flex; flex-direction: column; align-items: center; min-width: 34px; max-width: 72px; }
617
+ .wall-item .hook { width: 1px; height: 5px; background: rgba(0,0,0,.45); }
618
+ .wall-item .sprite-frame { width: 30px; padding: 2px; box-sizing: border-box; background: rgba(239,230,216,.85); border: 1px solid var(--soil-deep); border-radius: 1px; box-shadow: 0 1px 2px rgba(0,0,0,.35); }
619
+ .wall-item.hangs-high { margin-top: 0; }
620
+ .wall-item.hangs-low { margin-top: 14px; }
621
+ .wall-item svg { width: 100%; display: block; }
622
+ .floor-band { position: absolute; left: 0; right: 0; bottom: 0; height: 86px; display: flex; align-items: flex-end; justify-content: space-between; padding: 0 6px 4px; box-sizing: border-box; }
623
+ /* A room can hold a dozen animals once the npcs slider is up, and the floor
624
+ is one row wide either way — so the crowd shares the row it has instead of
625
+ the surplus running off the edge under the overflow clip. --mate-width is
626
+ set per redraw from the room's own measured width. */
627
+ .floor-others { display: flex; align-items: flex-end; gap: 6px; min-width: 0; }
628
+ .floor-others .sprite-card { max-width: var(--mate-width, 76px); }
629
+ .floor-others .sprite { width: min(40px, var(--mate-width, 40px)); }
630
+ .sprite { width: 40px; }
631
+ .sprite svg { width: 100%; display: block; }
632
+ .floor-self .sprite { width: 48px; }
633
+
634
+ /* ---- sprite cards: adventure.html's own frame-plus-nameplate treatment,
635
+ shrunk to the one soil row a mud pane has for it. The plate names the
636
+ real subject (mole-1, carrot), never a class word. ---- */
637
+ .sprite-card { display: flex; flex-direction: column; align-items: center; max-width: 76px; }
638
+ .sprite-label {
639
+ margin-top: 2px; max-width: 100%; box-sizing: border-box;
640
+ font-family: ${MONO_STACK}; font-size: .5rem; line-height: 1.25; letter-spacing: .01em; text-align: center;
641
+ color: var(--mud-ink); background: rgba(239,230,216,.92); border: 1px solid var(--soil-mid); border-radius: 2px;
642
+ padding: 0 .22rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
643
+ }
644
+ .sprite-mass {
645
+ margin-top: 1px; font-family: ${MONO_STACK}; font-size: .46rem; line-height: 1.3; letter-spacing: .02em;
646
+ color: var(--parchment); background: rgba(36,23,16,.72); border-radius: 2px; padding: 0 .2rem; white-space: nowrap;
647
+ }
648
+ .sprite-card.actionable { cursor: pointer; }
649
+ .sprite-card.actionable:hover .sprite-frame, .sprite-card.actionable:focus-visible .sprite-frame { border-color: var(--burrow-glow); }
650
+ .sprite-card.actionable:hover .sprite-label, .sprite-card.actionable:focus-visible .sprite-label { border-color: var(--burrow-glow); background: var(--burrow-glow); }
651
+ .sprite-card.actionable:focus-visible { outline: 2px solid var(--burrow-glow); outline-offset: 1px; }
652
+ .wall-band .sprite-card.actionable { pointer-events: auto; }
653
+
654
+ /* ---- the compass ring: each way out sits where it points ---- */
655
+ .dir-ring { position: absolute; inset: 0; pointer-events: none; }
656
+ .dir-slot { position: absolute; pointer-events: auto; }
657
+ .dir-north { top: 0; left: 50%; transform: translateX(-102%); }
658
+ .dir-up { top: 0; left: 50%; transform: translateX(6px); }
659
+ .dir-south { bottom: 0; left: 50%; transform: translateX(-102%); }
660
+ .dir-down { bottom: 0; left: 50%; transform: translateX(6px); }
661
+ .dir-west { left: 0; top: 50%; transform: translateY(-50%); }
662
+ .dir-east { right: 0; top: 50%; transform: translateY(-50%); }
663
+ .dir-pill {
664
+ font-family: ${MONO_STACK}; font-size: .58rem; letter-spacing: .06em; line-height: 1;
665
+ padding: .22rem .42rem; border-radius: 2px; border: 1px solid var(--soil-mid);
666
+ background: var(--parchment); color: var(--mud-ink); white-space: nowrap;
667
+ }
668
+ .dir-pill.dig { border-style: dashed; border-color: var(--burrow-glow); color: var(--soil-mid); background: rgba(232,163,61,.14); }
669
+ .dir-pill.vertical { border-radius: 50%; width: 1.35rem; height: 1.35rem; padding: 0; display: inline-flex; align-items: center; justify-content: center; font-size: .68rem; }
670
+ .dir-pill:hover:not(:disabled) { border-color: var(--burrow-glow); background: var(--burrow-glow); color: var(--mud-ink); }
671
+
672
+ .bubbles { position: absolute; inset: 0; pointer-events: none; }
673
+ .bubble {
674
+ position: absolute; max-width: 46%;
675
+ background: var(--parchment); color: var(--mud-ink); border: 1px solid var(--soil-mid); border-radius: 7px;
676
+ padding: .22rem .45rem; font-size: .66rem; line-height: 1.25; box-shadow: 0 2px 4px rgba(0,0,0,.3);
677
+ }
678
+ .bubble.from-self { right: 4%; bottom: 52%; }
679
+ .bubble.from-other { left: 4%; bottom: 52%; }
680
+ .bubble::after { content: ""; position: absolute; bottom: -5px; width: 0; height: 0; border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 5px solid var(--parchment); }
681
+ .bubble.from-self::after { right: 12%; }
682
+ .bubble.from-other::after { left: 12%; }
683
+
684
+ .dig-flourish { position: absolute; inset: 0; pointer-events: none; opacity: 0; background: radial-gradient(circle at 50% 80%, var(--burrow-glow) 0%, transparent 62%); transition: opacity .5s ease; }
685
+ .dig-flourish.shown { opacity: .5; }
686
+ @media (prefers-reduced-motion: reduce) { .dig-flourish { transition: none; opacity: 0 !important; } }
687
+
688
+ /* Two lines clamped, and a box tall enough to hold both of them: at 1.5
689
+ line-height that is 3em, and anything shorter cuts the second line's
690
+ descenders off. */
691
+ .room-caption { flex: 0 0 auto; font-size: .74rem; margin: 0; color: var(--soil-mid); height: 3em; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
692
+
693
+ .pane-columns { flex: 0 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: .5rem; height: 86px; }
694
+ .pouch, .minimap { background: rgba(255,255,255,.35); border: 1px solid var(--soil-light); border-radius: 3px; padding: .35rem .45rem; overflow: hidden; display: flex; flex-direction: column; }
695
+ .pouch-list { list-style: none; margin: 0; padding: 0; font-family: ${MONO_STACK}; font-size: .64rem; display: flex; flex-wrap: wrap; gap: .2rem .3rem; overflow-y: auto; }
696
+ .pouch-list li { background: var(--soil-light); color: var(--parchment); border-radius: 2px; padding: .06rem .3rem; }
697
+ .pouch-list:empty::after { content: "carrying nothing"; color: var(--soil-mid); font-family: ${SANS_STACK}; font-style: italic; font-size: .68rem; }
698
+ .stat-line { margin: .25rem 0 0; font-size: .58rem; color: var(--soil-mid); }
699
+ .minimap-board { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; }
700
+ .minimap-board svg { width: 100%; height: 100%; max-height: 56px; }
701
+ .minimap-board:empty::after { content: "nothing dug yet"; color: var(--soil-mid); font-style: italic; font-size: .68rem; }
702
+ .minimap .burrow .room text { fill: var(--parchment); font-size: 6px; }
703
+ .minimap .burrow .room.here rect { fill: var(--burrow-glow); }
704
+ .minimap .burrow .room.here text { fill: var(--mud-ink); }
705
+
706
+ /* The log is the ONLY thing in a pane allowed to grow, and it grows inward:
707
+ flex-basis 0 keeps its own content out of the pane's height arithmetic, so
708
+ a hundred turns of narration scroll inside it instead of pushing the
709
+ command console off the bottom of a fixed-height pane. */
710
+ .chat { position: relative; flex: 1 1 0; min-height: 2.6rem; display: flex; flex-direction: column; }
711
+ /* Block flow, deliberately, not a flex column: a scrolled log's own lines
712
+ must keep their natural height, and flex items inside a height-capped
713
+ column shrink toward zero instead. */
714
+ .chatlog {
715
+ flex: 1 1 0; min-height: 0; display: block;
716
+ overflow-y: scroll; overscroll-behavior: contain; padding-right: .25rem;
717
+ }
718
+ .chatlog > * { margin: 0 0 .28rem; }
719
+ .chatlog > *:last-child { margin-bottom: 0; }
720
+ /* The log carries typed exchanges only — a scripted turn narrates through
721
+ the room view — so an untouched pane's log says what would fill it. */
722
+ .chatlog:empty::after { content: "Type a command below and the answer lands here."; color: var(--soil-mid); font-style: italic; font-size: .72rem; }
723
+ /* A styled webkit scrollbar reserves its own gutter, where the overlay one
724
+ Chromium defaults to on macOS shows nothing until you already scroll. The
725
+ standard properties go behind @supports because setting them at all makes
726
+ Blink ignore the pseudo-elements and fall back to that overlay bar. */
727
+ .chatlog::-webkit-scrollbar { width: 9px; }
728
+ .chatlog::-webkit-scrollbar-track { background: rgba(122,90,61,.18); border-radius: 5px; }
729
+ .chatlog::-webkit-scrollbar-thumb { background: var(--soil-light); border-radius: 5px; border: 2px solid var(--parchment); }
730
+ .chatlog::-webkit-scrollbar-thumb:hover { background: var(--soil-mid); }
731
+ @supports not selector(::-webkit-scrollbar) {
732
+ .chatlog { scrollbar-width: thin; scrollbar-color: var(--soil-light) rgba(122,90,61,.18); }
733
+ }
734
+ .chatlog .u { font-family: ${MONO_STACK}; font-size: .66rem; color: var(--soil-mid); overflow-wrap: anywhere; }
735
+ .chatlog .u::before { content: "tmct> "; color: var(--burrow-glow); }
736
+ /* A reply is capped at four rendered lines. A capped one says so and opens the
737
+ whole text in the popup above, so no single answer can take the log over.
738
+ The unclamped variant exists only to be measured against: a line-clamped box
739
+ reports its clamped height as its scrollHeight. */
740
+ .chatlog .a { font-size: .74rem; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
741
+ .chatlog .a.unclamped { display: block; -webkit-line-clamp: none; overflow: visible; }
742
+ .chatlog .clipped { position: relative; cursor: pointer; padding-right: 2.4rem; }
743
+ .chatlog .clipped::after {
744
+ content: "more"; position: absolute; right: 0; bottom: 0;
745
+ font-family: ${MONO_STACK}; font-size: .5rem; letter-spacing: .06em; text-transform: uppercase;
746
+ color: var(--mud-ink); background: var(--burrow-glow); border-radius: 99px; padding: 0 .32rem;
747
+ }
748
+ .chatlog .clipped:hover::after, .chatlog .clipped:focus-visible::after { background: var(--mud-ink); color: var(--parchment); }
749
+ .chatlog .clipped:focus-visible { outline: 2px solid var(--burrow-glow); outline-offset: 1px; }
750
+
751
+ .log-popup {
752
+ position: absolute; left: 0; right: 0; bottom: calc(100% - 1.4rem); z-index: 8;
753
+ background: var(--soil-deep); color: var(--parchment);
754
+ border: 1px solid var(--burrow-glow); border-radius: 4px;
755
+ padding: .45rem 1.5rem .5rem .55rem; box-shadow: 0 8px 18px rgba(0,0,0,.5);
756
+ max-height: 13rem; overflow-y: auto; animation: popup-rise .16s ease-out;
757
+ }
758
+ .log-popup::after {
759
+ content: ""; position: absolute; left: 1.4rem; bottom: -6px; width: 0; height: 0;
760
+ border-left: 6px solid transparent; border-right: 6px solid transparent; border-top: 6px solid var(--burrow-glow);
761
+ }
762
+ .log-popup-text { margin: 0; font-size: .74rem; line-height: 1.4; }
763
+ .log-popup-close { position: absolute; top: .1rem; right: .25rem; font-size: 1rem; line-height: 1; color: var(--parchment); padding: .1rem .2rem; }
764
+ .log-popup-close:hover { color: var(--burrow-glow); }
765
+ @keyframes popup-rise { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } }
766
+ @media (prefers-reduced-motion: reduce) { .log-popup { animation: none; } }
767
+
768
+ /* The console never moves: pills and prompt sit outside the log's own flex
769
+ track, so no amount of narration can reach them. */
770
+ .chat-console { flex: 0 0 auto; display: flex; flex-direction: column; gap: .3rem; margin-top: .3rem; }
771
+ .chatask { display: flex; align-items: center; gap: .4rem; }
772
+ .chatask .prompt { color: var(--burrow-glow); font-size: .7rem; }
773
+ .chatask input { flex: 1; min-width: 0; font-family: ${MONO_STACK}; font-size: .7rem; background: rgba(255,255,255,.6); color: var(--mud-ink); border: 1px solid var(--soil-mid); border-radius: 2px; padding: .26rem .45rem; box-sizing: border-box; }
774
+ .chatpills { display: flex; flex-wrap: wrap; gap: .22rem; max-height: 3.9rem; overflow-y: auto; scrollbar-width: thin; }
775
+ .chatpills:empty { display: none; }
776
+ .pill { font-family: ${MONO_STACK}; font-size: .58rem; padding: .14rem .45rem; border: 1px solid var(--soil-mid); border-radius: 99px; background: rgba(255,255,255,.5); white-space: nowrap; }
777
+ .pill:hover:not(:disabled) { border-color: var(--burrow-glow); background: var(--burrow-glow); }
778
+ .pill.affordance { border-style: dashed; border-color: var(--soil-light); }
779
+ .pill.way { border-style: solid; border-color: var(--soil-mid); background: rgba(232,163,61,.16); }
780
+ .pane-controls { flex: 0 0 auto; display: flex; align-items: center; gap: .4rem; }
781
+ .pane-fate {
782
+ margin: 0; font-family: ${MONO_STACK}; font-size: .62rem; text-transform: uppercase; letter-spacing: .08em;
783
+ padding: .3rem .6rem; border-radius: 3px; background: var(--soil-deep); color: var(--parchment);
784
+ }
785
+ .mud-window.out-of-play { border-top-color: var(--soil-mid); }
786
+ .mud-window.out-of-play .pane-controls button { display: none; }
787
+ .room-view, .pane-columns { transition: filter .4s ease, opacity .4s ease; }
788
+ .mud-window.out-of-play .room-view, .mud-window.out-of-play .pane-columns { filter: grayscale(1); opacity: .5; }
789
+ .mud-window.out-of-play .dir-ring { display: none; }
790
+
791
+ /* ---- the pounce: the one violent beat on a page that otherwise only fades.
792
+ Every other motion here eases out; this one accelerates into the prey and
793
+ stops dead. It plays once, on the tick a character is first out of play,
794
+ and the pane grays out underneath it when it lands. ---- */
795
+ .strike-flash {
796
+ position: absolute; inset: 0; pointer-events: none; opacity: 0;
797
+ background: radial-gradient(circle at 76% 72%, rgba(158,32,16,.95) 0%, rgba(36,23,16,.62) 40%, transparent 72%);
798
+ }
799
+ @keyframes strike-flash { 0%, 42% { opacity: 0; } 50% { opacity: .92; } 100% { opacity: 0; } }
800
+ @keyframes fox-pounce {
801
+ 0% { transform: translateX(0) scale(1); }
802
+ 16% { transform: translateX(-7px) scale(.94); }
803
+ 54% { transform: translateX(var(--pounce-x, 180px)) scale(1.48); }
804
+ 100% { transform: translateX(var(--pounce-x, 180px)) scale(1.44); }
805
+ }
806
+ @keyframes prey-taken {
807
+ 0%, 40% { transform: none; opacity: 1; }
808
+ 47% { transform: translateX(-6px) rotate(-8deg); }
809
+ 54% { transform: translateX(6px) rotate(7deg); }
810
+ 61% { transform: translateX(-4px) rotate(-4deg); }
811
+ 100% { transform: translateX(3px) rotate(4deg) scale(.82); opacity: 0; }
812
+ }
813
+ .room-view.pounce .sprite-card.lunging { position: relative; z-index: 3; transform-origin: 50% 100%; animation: fox-pounce 1.05s cubic-bezier(.6,0,.85,.4) forwards; }
814
+ .room-view.pounce .floor-self { transform-origin: 50% 100%; animation: prey-taken 1.05s cubic-bezier(.4,0,.2,1) forwards; }
815
+ .room-view.pounce .strike-flash { animation: strike-flash 1.05s ease-out forwards; }
816
+ .room-view.pounce .bubbles, .room-view.pounce .wall-band { opacity: .35; }
817
+ @keyframes fate-drop { from { opacity: 0; transform: translateY(-7px); } to { opacity: 1; transform: none; } }
818
+ .pane-fate { animation: fate-drop .3s ease-out; }
819
+ @media (prefers-reduced-motion: reduce) {
820
+ .room-view, .pane-columns { transition: none; }
821
+ .pane-fate { animation: none; }
822
+ .room-view.pounce .sprite-card.lunging, .room-view.pounce .floor-self, .room-view.pounce .strike-flash { animation: none; }
823
+ }
824
+
825
+ /* ---- edit mode ----
826
+ One page, two stages. Editing hides the panes AND the deck's own live
827
+ survey: the survey in here is drawn from the edit buffer, and two boards
828
+ showing two different burrows side by side would just be a puzzle. The deck
829
+ itself stays, because the way back out is a button on it. */
830
+ .edit-stage { display: none; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 1rem; align-items: start; }
831
+ body.editing .mud-stage, body.editing .deck-row .world-map { display: none; }
832
+ body.editing .deck-row { grid-template-columns: 1fr; }
833
+ body.editing .edit-stage { display: grid; }
834
+ #editModeBtn[aria-pressed="true"] { background: var(--burrow-glow); border-color: var(--burrow-glow); }
835
+
836
+ .edit-text, .edit-panel {
837
+ background: var(--parchment); border: 1px solid var(--soil-mid); border-radius: 4px;
838
+ box-shadow: 0 2px 0 rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.35);
839
+ padding: .7rem .8rem; min-width: 0;
840
+ }
841
+ .edit-text { display: flex; flex-direction: column; gap: .45rem; }
842
+ .edit-text h2, .edit-panel h2 {
843
+ font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em;
844
+ color: var(--soil-mid); margin: 0;
845
+ }
846
+ .edit-lede { margin: 0; font-size: .74rem; color: var(--soil-mid); max-width: 62ch; }
847
+ #editorText {
848
+ width: 100%; box-sizing: border-box; min-height: 26rem; flex: 1 1 auto; resize: vertical;
849
+ font-family: ${MONO_STACK}; font-size: .74rem; line-height: 1.6;
850
+ color: var(--mud-ink); background: rgba(255,255,255,.6);
851
+ border: 1px solid var(--soil-mid); border-radius: 3px; padding: .55rem .6rem;
852
+ }
853
+ /* Three states, three colours, and the middle one is the important one: a
854
+ document that does not yet parse is holding its retractions back, and has to
855
+ look different from one that synced clean. */
856
+ .edit-status { margin: 0; font-family: ${MONO_STACK}; font-size: .62rem; min-height: 1.2em; color: var(--soil-mid); }
857
+ .edit-status.pending { color: #9A5B12; }
858
+ .edit-status.ok { color: #4E6B2E; }
859
+
860
+ .edit-side { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
861
+ .edit-map .world-map-board { min-height: 190px; }
862
+ .edit-map .burrow .room { cursor: pointer; }
863
+ .edit-map .burrow .room.here rect { stroke: var(--burrow-glow); stroke-width: 1.8; }
864
+ .edit-panel { display: flex; flex-direction: column; gap: .35rem; }
865
+ .edit-caption { margin: 0; font-size: .74rem; color: var(--soil-mid); }
866
+ .room-detail-cast, .edit-legend { display: flex; flex-wrap: wrap; gap: .4rem; }
867
+ .edit-legend .sprite-card, .room-detail-cast .sprite-card { max-width: 58px; }
868
+ .edit-legend .sprite-frame, .room-detail-cast .sprite-frame {
869
+ width: 34px; padding: 2px; box-sizing: border-box; background: rgba(255,255,255,.6);
870
+ border: 1px solid var(--soil-mid); border-radius: 2px;
871
+ }
872
+ .edit-legend .sprite-frame svg, .room-detail-cast .sprite-frame svg { width: 100%; display: block; }
873
+ .edit-empty { font-size: .74rem; font-style: italic; color: var(--soil-mid); }
874
+
875
+ /* Narrow, every pane stacks and every count reads the same — including the
876
+ single-pane one, whose centred column and extra height only buy anything
877
+ when there is width to spare. */
878
+ @media (max-width: 900px) {
879
+ .deck-row { grid-template-columns: 1fr; }
880
+ .mud-stage, .mud-stage[data-panes="1"] {
881
+ grid-template-columns: 1fr; justify-content: stretch;
882
+ --pane-height: 596px; --room-height: 152px;
883
+ }
884
+ .world-map-board { min-height: 150px; }
885
+ .edit-stage { grid-template-columns: 1fr; }
886
+ #editorText { min-height: 18rem; }
887
+ }
888
+ `;
889
+
890
+ /** The inlined page script, as a plain function body handed to
891
+ * embedScriptText — mirrors spider-fly-viz.mjs's own `(function () {
892
+ * "use strict"; ... })()` IIFE shape, with every spliced helper listed at
893
+ * the top of the closure exactly like that page's own const bindings. */
894
+ function pageScript() {
895
+ return `(function () {
896
+ "use strict";
897
+ const DATA = MUD_PAGE_DATA;
898
+ const createTicker = ${createTicker.toString()};
899
+ const escapeHtml = ${escapeHtml.toString()};
900
+ const esc = escapeHtml;
901
+ const paneMarkup = ${paneMarkup.toString()};
902
+ const speciesOfCharacter = ${speciesOfCharacter.toString()};
903
+ const mudRoomSceneObjects = ${mudRoomSceneObjects.toString()};
904
+ const carriedItemsFor = ${carriedItemsFor.toString()};
905
+ const levelsOf = ${levelsOf.toString()};
906
+ const charactersInRoom = ${charactersInRoom.toString()};
907
+ const burrowGraph = ${burrowGraph.toString()};
908
+ const diggableDirections = ${diggableDirections.toString()};
909
+ const itemLabel = ${itemLabel.toString()};
910
+ const isCreature = ${isCreature.toString()};
911
+ const roomSceneObjects = ${roomSceneObjects.toString()};
912
+ const scenePlacement = ${scenePlacement.toString()};
913
+ const spriteClassForObject = ${spriteClassForObject.toString()};
914
+ const spriteAncestryRows = ${spriteAncestryRows.toString()};
915
+ const factsForSubject = ${factsForSubject.toString()};
916
+ const visibleRoomOf = ${visibleRoomOf.toString()};
917
+ const roomKindForRoom = ${roomKindForRoom.toString()};
918
+ const allRoomIds = ${allRoomIds.toString()};
919
+ const renderMudEditorText = ${renderMudEditorText.toString()};
920
+ const wordBeforeCursor = ${wordBeforeCursor.toString()};
921
+
922
+ const el = (id) => document.getElementById(id);
923
+ const roster = DATA.characters.map(function (c) { return c.id; });
924
+ let slots = [];
925
+ const ACTOR_COLORS = ["var(--actor-a)", "var(--actor-b)", "var(--actor-c)", "var(--actor-d)"];
926
+ const NPC_COLOR = "var(--chalk)";
927
+ const COMPASS = ["north", "south", "east", "west", "up", "down"];
928
+ const DIR_GLYPH = { north: "\\u25B2 N", south: "\\u25BC S", west: "\\u25C0 W", east: "E \\u25B6", up: "\\u21E1", down: "\\u21E3" };
929
+ const reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
930
+
931
+ // ---- the one shared tick queue -----------------------------------------
932
+ // Every pane's ticker calls into this instead of session.windows[...]
933
+ // directly, so two characters' turns can never interleave their reads and
934
+ // writes of the one shared memoryDir, and the global turn counter always
935
+ // increments in real execution order.
936
+ let tickChain = Promise.resolve();
937
+ function serializeTick(fn) {
938
+ const run = tickChain.then(fn, fn);
939
+ tickChain = run.catch(function () {});
940
+ return run;
941
+ }
942
+
943
+ let session = null;
944
+ let cast = [];
945
+ // The animals with no pane. They act through the same session windows and
946
+ // the same tick queue as the cast; every list below that says "everyone"
947
+ // means both, and every list that says "cast" means the panes alone.
948
+ let npcs = [];
949
+ let slotOf = {};
950
+ let globalTurn = 0;
951
+ let turnsTaken = {};
952
+ let finished = {};
953
+ let maxTurns = DATA.defaultMaxTurns;
954
+ let delayMs = DATA.defaultDelayMs;
955
+ const wait = function (ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); };
956
+ const liveWait = function () { return wait(delayMs); };
957
+
958
+ let freshlyDugRoom = null;
959
+ // The last omniscient read, kept so the pounce can draw the predator's own
960
+ // den after the engine has already moved the character out of every room.
961
+ let lastSnapshot = null;
962
+ const speechBubbles = new Map(); // room -> [{ speaker, text, expiresAtTurn }]
963
+
964
+ let tickers = {};
965
+ let autoOn = false;
966
+
967
+ function hasNext() { return globalTurn < maxTurns; }
968
+ function everyone() { return cast.concat(npcs); }
969
+ function hasPane(character) { return Boolean(slotOf[character]); }
970
+ function paneIdFor(character) { return "window-" + slotOf[character]; }
971
+ function colorFor(character) {
972
+ const seat = cast.indexOf(character);
973
+ return seat === -1 ? NPC_COLOR : ACTOR_COLORS[seat % ACTOR_COLORS.length];
974
+ }
975
+
976
+ // The engine keeps each character's own tally (its scripted ticks AND its
977
+ // typed commands), which is the only one that can be right: a typed "dig
978
+ // north" is a turn the page never drove. The page's own count of the ticks it
979
+ // fired is the fallback, so a pane never falls back to showing the shared
980
+ // global figure as if it were its own.
981
+ function turnsFor(character) {
982
+ const w = session && session.windows[character];
983
+ const own = w && typeof w.turnsTaken === "function" ? w.turnsTaken() : null;
984
+ return typeof own === "number" ? own : (turnsTaken[character] || 0);
985
+ }
986
+
987
+ async function runOneTurn(character) {
988
+ // A tick already queued when a reset lands belongs to a world that no
989
+ // longer exists, and a dozen animals make that overlap ordinary rather
990
+ // than rare.
991
+ const bootAt = bootSeq;
992
+ return serializeTick(async function () {
993
+ if (bootAt !== bootSeq || finished[character]) return { outOfPlay: true };
994
+ globalTurn += 1;
995
+ const result = await session.windows[character].autoplayTick(globalTurn);
996
+ if (!result.outOfPlay) turnsTaken[character] = (turnsTaken[character] || 0) + 1;
997
+ afterEngineTurn(character, result);
998
+ return result;
999
+ });
1000
+ }
1001
+
1002
+ // A character whose run has ended keeps its pane, its final turn count and
1003
+ // its chat log, and loses everything that would advance it — the engine
1004
+ // declines its turns from here on, so offering the controls would promise a
1005
+ // turn that never runs. An NPC has none of that to lose: it stops ticking and
1006
+ // the survey stops drawing it, which is the whole of its ending. WHICH ending
1007
+ // it was is never cached here — every reader asks the engine fresh, so a pane
1008
+ // that says "eaten" over an animal that quietly starved is never possible.
1009
+ function markOutOfPlay(character, reason, note) {
1010
+ if (finished[character]) return;
1011
+ finished[character] = true;
1012
+ const ticker = tickers[character];
1013
+ if (ticker) ticker.pause();
1014
+ if (!hasPane(character)) return;
1015
+ const w = paneIdFor(character);
1016
+ el(w + "-play").disabled = true;
1017
+ el(w + "-step").disabled = true;
1018
+ el(w + "-chatq").disabled = true;
1019
+ el(w + "-chatpills").innerHTML = "";
1020
+ const fate = reason === "starved" ? "starved" : "eaten";
1021
+ // A run that ended on a TYPED command carries no note of its own — the
1022
+ // world's ending sentence comes from the engine either way, never a second
1023
+ // wording of the same event.
1024
+ appendChat(character, "a", note
1025
+ || (window.tmctMud.outOfPlayPhrase(character, fate) + ". It takes no more turns."));
1026
+ playFateScene(character, fate);
1027
+ }
1028
+
1029
+ // Whichever individual the WORLD marks dangerous, never a species this page
1030
+ // hardcodes — the same fact adventure.mjs's own predator check reads.
1031
+ function predatorInRows(rows) {
1032
+ for (const row of rows) {
1033
+ if (row.predicate === "mgx:is-predator" && row.object === "true") return row.subject;
1034
+ }
1035
+ return null;
1036
+ }
1037
+
1038
+ // The pane holds on the den for the length of one pounce before it grays out.
1039
+ // Cutting straight to the banner throws away the only moment this demo has to
1040
+ // show what took the character: the character's last drawn room is the one it
1041
+ // walked out OF, so the scene is redrawn against the den first, then the fox
1042
+ // crosses it. An animal that ran out of mass was taken by nothing, so it
1043
+ // settles straight into its banner — as does reduced motion, or a world with
1044
+ // no marked predator.
1045
+ function playFateScene(character, fate) {
1046
+ const w = paneIdFor(character);
1047
+ const settle = function () {
1048
+ el(w).classList.add("out-of-play");
1049
+ const banner = el(w + "-fate");
1050
+ banner.hidden = false;
1051
+ banner.textContent = fate + " \\u00b7 " + turnsFor(character) + " turns";
1052
+ };
1053
+ const predator = lastSnapshot ? predatorInRows(lastSnapshot.rows) : null;
1054
+ const den = predator ? (lastSnapshot.state.placements.get(predator) || {}).object : null;
1055
+ if (fate === "starved" || reduceMotion || !den) { settle(); return; }
1056
+
1057
+ const rows = lastSnapshot.rows;
1058
+ const state = lastSnapshot.state;
1059
+ renderRoomView(character, rows, state, den, allRoomIds(rows),
1060
+ window.tmctMud.castInRoom(rows, state, den, character));
1061
+
1062
+ const room = el(w + "-room");
1063
+ const lunger = room.querySelector('.floor-others .sprite-card[data-subject="' + predator + '"]');
1064
+ const prey = el(w + "-self");
1065
+ if (!lunger || !prey) { settle(); return; }
1066
+ const gap = prey.getBoundingClientRect().left - lunger.getBoundingClientRect().left;
1067
+ lunger.style.setProperty("--pounce-x", Math.max(0, Math.round(gap) - 8) + "px");
1068
+ room.classList.add("pounce");
1069
+ lunger.classList.add("lunging");
1070
+ setTimeout(settle, 1000);
1071
+ }
1072
+
1073
+ // A bubble carries what was SAID, not the sentence: the answer names a
1074
+ // thing, and the thing's own display label is only resolvable against the
1075
+ // fact rows a redraw has and a finished turn does not.
1076
+ function noteSpeech(room, speaker, bubble) {
1077
+ if (!speechBubbles.has(room)) speechBubbles.set(room, []);
1078
+ speechBubbles.get(room).push(Object.assign({ speaker: speaker, expiresAtTurn: globalTurn + 2 }, bubble));
1079
+ }
1080
+
1081
+ function afterEngineTurn(character, result) {
1082
+ if (result && result.outOfPlay) {
1083
+ markOutOfPlay(character, result.outOfPlayReason, result.text);
1084
+ renderSoon();
1085
+ return;
1086
+ }
1087
+ if (!result || !result.room) { renderSoon(); return; }
1088
+ if (result.roomAfter && result.roomAfter !== result.room) freshlyDugRoom = result.roomAfter;
1089
+ for (const action of result.actions || []) {
1090
+ if (action.kind !== "ask" && action.kind !== "talk") continue;
1091
+ const other = action.teller || action.target || action.object;
1092
+ noteSpeech(result.room, character, { text: "what food do you know about?" });
1093
+ if (other && action.thing) noteSpeech(result.room, other, { thing: action.thing });
1094
+ else if (other && action.text) noteSpeech(result.room, other, { text: action.text });
1095
+ }
1096
+ renderSoon();
1097
+ }
1098
+
1099
+ function ensureTicker(character) {
1100
+ if (tickers[character]) return tickers[character];
1101
+ const playBtn = hasPane(character) ? el(paneIdFor(character) + "-play") : null;
1102
+ tickers[character] = createTicker({
1103
+ onTick: function () { return runOneTurn(character); },
1104
+ onRender: function (state) {
1105
+ if (playBtn) playBtn.textContent = state.playing ? "\\u23F8 pause" : "\\u25B6 play";
1106
+ },
1107
+ hasNext: hasNext,
1108
+ wait: liveWait,
1109
+ });
1110
+ return tickers[character];
1111
+ }
1112
+
1113
+ // ---- chat docks ---------------------------------------------------------
1114
+ // The line's own text is never shortened, only its rendered height — the whole
1115
+ // thing stays readable through the popup, and stays in the DOM for anything
1116
+ // reading the log.
1117
+ function appendChat(character, cls, text) {
1118
+ const log = el(paneIdFor(character) + "-chatlog");
1119
+ const d = document.createElement("div");
1120
+ d.className = cls;
1121
+ d.textContent = text;
1122
+ log.appendChild(d);
1123
+ // A line-clamped box reports scrollHeight EQUAL to its clamped height, so
1124
+ // the overflow has to be read against the same box with the clamp lifted.
1125
+ d.classList.add("unclamped");
1126
+ const wholeHeight = d.scrollHeight;
1127
+ d.classList.remove("unclamped");
1128
+ if (wholeHeight - d.clientHeight > 2) {
1129
+ d.classList.add("clipped");
1130
+ d.setAttribute("role", "button");
1131
+ d.setAttribute("tabindex", "0");
1132
+ d.setAttribute("title", "read the whole line");
1133
+ }
1134
+ log.scrollTop = log.scrollHeight;
1135
+ }
1136
+
1137
+ function openLogPopup(slot, text) {
1138
+ const w = "window-" + slot;
1139
+ el(w + "-logpop-text").textContent = text;
1140
+ el(w + "-logpop").hidden = false;
1141
+ }
1142
+
1143
+ function closeLogPopup(slot) {
1144
+ el("window-" + slot + "-logpop").hidden = true;
1145
+ }
1146
+
1147
+ function sendCommand(character, line) {
1148
+ appendChat(character, "u", line);
1149
+ return serializeTick(function () { return session.windows[character].turn(line); }).then(function (res) {
1150
+ appendChat(character, "a", res.answer);
1151
+ renderSoon();
1152
+ return res;
1153
+ });
1154
+ }
1155
+
1156
+ // Wired as the pane is built. The stage is rebuilt on every boot, so a
1157
+ // pane's listeners live and die with it; each one still reads its slot's
1158
+ // character at click time, because the cast is bound after the markup.
1159
+ function wirePane(slot) {
1160
+ const w = "window-" + slot;
1161
+ const input = el(w + "-chatq");
1162
+ el(w + "-chatform").addEventListener("submit", function (e) {
1163
+ e.preventDefault();
1164
+ const character = characterInSlot(slot);
1165
+ const line = input.value.trim();
1166
+ if (!character || !line) return;
1167
+ input.value = "";
1168
+ sendCommand(character, line);
1169
+ });
1170
+ el(w + "-play").addEventListener("click", function () {
1171
+ const character = characterInSlot(slot);
1172
+ if (!character) return;
1173
+ const ticker = ensureTicker(character);
1174
+ if (ticker.getState().playing) ticker.pause(); else ticker.play();
1175
+ });
1176
+ el(w + "-step").addEventListener("click", function () {
1177
+ const character = characterInSlot(slot);
1178
+ if (character) ensureTicker(character).stepOnce();
1179
+ });
1180
+
1181
+ const log = el(w + "-chatlog");
1182
+ log.addEventListener("click", function (e) {
1183
+ const line = e.target.closest(".clipped");
1184
+ if (line) openLogPopup(slot, line.textContent);
1185
+ });
1186
+ log.addEventListener("keydown", function (e) {
1187
+ if (e.key !== "Enter" && e.key !== " ") return;
1188
+ const line = e.target.closest(".clipped");
1189
+ if (!line) return;
1190
+ e.preventDefault();
1191
+ openLogPopup(slot, line.textContent);
1192
+ });
1193
+ el(w + "-logpop-close").addEventListener("click", function () { closeLogPopup(slot); });
1194
+ el(w).addEventListener("keydown", function (e) {
1195
+ if (e.key === "Escape") closeLogPopup(slot);
1196
+ });
1197
+
1198
+ // Clicking a drawn thing runs the same grounded command its pill would, so
1199
+ // the room view is a second way into one action set rather than its own.
1200
+ // A sprite the room grants no action on carries no command and does nothing.
1201
+ el(w + "-room").addEventListener("click", function (e) {
1202
+ const card = e.target.closest(".sprite-card[data-command]");
1203
+ const character = characterInSlot(slot);
1204
+ if (!card || !character || finished[character]) return;
1205
+ sendCommand(character, card.getAttribute("data-command"));
1206
+ });
1207
+ el(w + "-room").addEventListener("keydown", function (e) {
1208
+ if (e.key !== "Enter" && e.key !== " ") return;
1209
+ const card = e.target.closest(".sprite-card[data-command]");
1210
+ const character = characterInSlot(slot);
1211
+ if (!card || !character || finished[character]) return;
1212
+ e.preventDefault();
1213
+ sendCommand(character, card.getAttribute("data-command"));
1214
+ });
1215
+ }
1216
+
1217
+ function characterInSlot(slot) {
1218
+ return cast[slots.indexOf(slot)] || null;
1219
+ }
1220
+
1221
+ // ---- room-view rendering -------------------------------------------------
1222
+ function hashOf(text) {
1223
+ let h = 0;
1224
+ for (let i = 0; i < String(text).length; i += 1) h = (h * 31 + String(text).charCodeAt(i)) % 100000;
1225
+ return h;
1226
+ }
1227
+
1228
+ function spriteSvgFor(species, rows, instanceKey) {
1229
+ if (window.tmctMud && window.tmctMud.resolveSpriteAsset) {
1230
+ return window.tmctMud.resolveSpriteAsset(species, rows, [], DATA.spriteTemplates, window.tmctMud.SPRITE_REGISTRY, { instanceKey: instanceKey });
1231
+ }
1232
+ return "";
1233
+ }
1234
+
1235
+ // A wall-hung object resolves through the SAME property-aware, large-tier
1236
+ // resolver the characters do, keyed on the object's OWN name (via
1237
+ // spriteAncestryRows' synthetic subClassOf edge to its declared class), so
1238
+ // a carrot draws a carrot and only a class with no template anywhere falls
1239
+ // back. The fallback root is "portable", not the resolver's default
1240
+ // "animal": everything hung on this wall is a thing, and a thing with no
1241
+ // sprite should read as a plain parcel rather than a creature.
1242
+ function objectSvgFor(subject, rows) {
1243
+ if (window.tmctMud && window.tmctMud.resolveSpriteAsset) {
1244
+ return window.tmctMud.resolveSpriteAsset(
1245
+ subject, spriteAncestryRows(rows, subject), factsForSubject(rows, subject),
1246
+ DATA.spriteTemplates, window.tmctMud.SPRITE_REGISTRY,
1247
+ { rootFallback: "portable", instanceKey: subject },
1248
+ );
1249
+ }
1250
+ return "";
1251
+ }
1252
+
1253
+ // The backdrop is a drawing of what the digest already says about the room:
1254
+ // turf and sky above ground, packed strata and pebbles below it. Every
1255
+ // stroke is placed off a hash of the room's own id, so a room looks the
1256
+ // same every redraw and two rooms never look alike.
1257
+ function drawRoomBackdrop(canvas, kind, room) {
1258
+ const ctx = canvas.getContext("2d");
1259
+ const w = canvas.width, h = canvas.height;
1260
+ ctx.clearRect(0, 0, w, h);
1261
+ const grad = ctx.createLinearGradient(0, 0, 0, h);
1262
+ if (kind === "outdoor") {
1263
+ grad.addColorStop(0, "#A9BE83"); grad.addColorStop(0.42, "#7E9159"); grad.addColorStop(1, "#5C6B41");
1264
+ } else {
1265
+ grad.addColorStop(0, "#63482F"); grad.addColorStop(0.55, "#42301F"); grad.addColorStop(1, "#241710");
1266
+ }
1267
+ ctx.fillStyle = grad;
1268
+ ctx.fillRect(0, 0, w, h);
1269
+
1270
+ const seed = hashOf(room);
1271
+ ctx.save();
1272
+ if (kind === "outdoor") {
1273
+ ctx.strokeStyle = "rgba(45,62,30,.5)";
1274
+ ctx.lineWidth = 1.4;
1275
+ for (let i = 0; i < 26; i += 1) {
1276
+ const x = ((seed * (i + 3)) % w);
1277
+ const base = h - 6 - ((seed >> (i % 5)) % 8);
1278
+ ctx.beginPath();
1279
+ ctx.moveTo(x, base);
1280
+ ctx.lineTo(x + (i % 2 ? 3 : -3), base - 9 - (i % 4) * 2);
1281
+ ctx.stroke();
1282
+ }
1283
+ } else {
1284
+ ctx.strokeStyle = "rgba(0,0,0,.22)";
1285
+ ctx.lineWidth = 1;
1286
+ for (let i = 1; i < 4; i += 1) {
1287
+ const y = (h / 4) * i + ((seed >> i) % 6) - 3;
1288
+ ctx.beginPath();
1289
+ ctx.moveTo(0, y);
1290
+ for (let x = 0; x <= w; x += 30) ctx.lineTo(x, y + (((seed + x) % 7) - 3));
1291
+ ctx.stroke();
1292
+ }
1293
+ ctx.fillStyle = "rgba(255,255,255,.07)";
1294
+ for (let i = 0; i < 14; i += 1) {
1295
+ const x = ((seed * (i + 7)) % w);
1296
+ const y = ((seed * (i + 2)) % h);
1297
+ ctx.beginPath();
1298
+ ctx.arc(x, y, 1 + (i % 3), 0, Math.PI * 2);
1299
+ ctx.fill();
1300
+ }
1301
+ }
1302
+ ctx.restore();
1303
+ const floor = ctx.createLinearGradient(0, h - 26, 0, h);
1304
+ floor.addColorStop(0, "rgba(0,0,0,0)");
1305
+ floor.addColorStop(1, "rgba(0,0,0,.35)");
1306
+ ctx.fillStyle = floor;
1307
+ ctx.fillRect(0, h - 26, w, 26);
1308
+ }
1309
+
1310
+ // The world's own hasMass fold, the same one the pouch's stat line reads.
1311
+ // Null for anything the world never weighed, which draws no figure at all
1312
+ // rather than a guessed one.
1313
+ function massOf(state, subject) {
1314
+ const mass = state.masses.get(subject);
1315
+ return mass ? mass.value : null;
1316
+ }
1317
+
1318
+ // A sprite card: adventure.html's own frame-plus-nameplate, carrying the one
1319
+ // command this room currently grants on its subject (none, for a thing the
1320
+ // room offers nothing on) and the subject's own mass under the name.
1321
+ function spriteCardHtml(subject, label, inner, command, extraClass, mass) {
1322
+ const action = command
1323
+ ? ' actionable" data-command="' + esc(command) + '" role="button" tabindex="0" title="' + esc(command) + '"'
1324
+ : '" title="' + esc(label) + '"';
1325
+ return '<div class="sprite-card ' + (extraClass || "") + action + ' data-subject="' + esc(subject) + '">'
1326
+ + inner + '<div class="sprite-label">' + esc(label) + "</div>"
1327
+ + (mass === null || mass === undefined ? "" : '<div class="sprite-mass">mass ' + esc(mass) + "</div>")
1328
+ + "</div>";
1329
+ }
1330
+
1331
+ function renderRoomView(character, rows, state, here, roomIds, roomMates) {
1332
+ const w = paneIdFor(character);
1333
+ drawRoomBackdrop(el(w + "-canvas"), roomKindForRoom(rows, here), here);
1334
+ const commands = commandsBySubject(affordancesFor(rows, state, here, character));
1335
+
1336
+ el(w + "-self").innerHTML = spriteCardHtml(
1337
+ character, character,
1338
+ '<div class="sprite">' + spriteSvgFor(speciesOfCharacter(character), rows, character) + "</div>",
1339
+ null, "self", massOf(state, character),
1340
+ );
1341
+
1342
+ const others = roomMates.map(function (mate) {
1343
+ return spriteCardHtml(mate, mate,
1344
+ '<div class="sprite">' + spriteSvgFor(speciesOfCharacter(mate), rows, mate) + "</div>",
1345
+ commands[mate], "", massOf(state, mate));
1346
+ });
1347
+ const wall = [];
1348
+ for (const obj of mudRoomSceneObjects(rows, state, here, character)) {
1349
+ if (isCreature(state, obj.subject)) continue;
1350
+ const plane = scenePlacement(rows, state, obj.subject).plane;
1351
+ const hang = plane === "ceiling" || plane === "wall" ? "hangs-high" : "hangs-low";
1352
+ // The caption is always the THING'S own name, never the class whose
1353
+ // sprite ended up drawn — an item wearing an ancestor's icon because
1354
+ // its own class has no sprite yet still has to say what it is.
1355
+ wall.push(spriteCardHtml(obj.subject, labelForItem(rows, obj.subject, roomIds),
1356
+ '<div class="hook"></div><div class="sprite-frame">' + objectSvgFor(obj.subject, rows) + "</div>",
1357
+ commands[obj.subject], "wall-item " + hang, massOf(state, obj.subject)));
1358
+ }
1359
+ const othersEl = el(w + "-others");
1360
+ othersEl.innerHTML = others.join("");
1361
+ const floorForMates = el(w + "-room").clientWidth - 68;
1362
+ othersEl.style.setProperty("--mate-width",
1363
+ Math.max(18, Math.min(76, Math.floor(floorForMates / Math.max(1, others.length)) - 6)) + "px");
1364
+ el(w + "-wall").innerHTML = wall.join("");
1365
+
1366
+ const live = (speechBubbles.get(here) || []).filter(function (b) { return b.expiresAtTurn >= globalTurn; });
1367
+ el(w + "-bubbles").innerHTML = live.map(function (b) {
1368
+ const side = b.speaker === character ? "from-self" : "from-other";
1369
+ const said = b.thing ? "there's " + labelForItem(rows, b.thing, roomIds) + ", if you can reach it." : b.text;
1370
+ return '<div class="bubble ' + side + '">' + esc(said) + "</div>";
1371
+ }).join("");
1372
+
1373
+ const flourishEl = el(w + "-flourish");
1374
+ if (freshlyDugRoom && freshlyDugRoom === here && !reduceMotion) {
1375
+ flourishEl.hidden = false;
1376
+ flourishEl.classList.add("shown");
1377
+ setTimeout(function () { flourishEl.classList.remove("shown"); flourishEl.hidden = true; }, 900);
1378
+ }
1379
+
1380
+ el(w + "-caption").textContent = roomCaptionFor(rows, state, here, character, roomMates);
1381
+ }
1382
+
1383
+ // The digest's own room sentences say what the ROOM is, and nothing about who
1384
+ // is standing in it — a "Badger-2 is in the sett-1" row is filed under the
1385
+ // badger, not the sett, so filtering the view to this room drops every mate.
1386
+ // The cast sentence comes from the same list the talk pills and the floor
1387
+ // sprites read, so the description can never name a different set of animals
1388
+ // than the pane offers to talk to.
1389
+ function roomCaptionFor(rows, state, here, character, roomMates) {
1390
+ const parts = [];
1391
+ const view = window.tmctMud.worldDigestRows(rows, state, character);
1392
+ const lines = view.filter(function (r) { return r.subject.toLowerCase() === here.toLowerCase(); })
1393
+ .map(function (r) { return r.subject + " " + r.predicate + " " + r.object + "."; });
1394
+ parts.push(lines.length ? lines.join(" ") : "You are in the " + here + ".");
1395
+ if (roomMates.length) {
1396
+ parts.push("Here with you: " + roomMates.map(function (mate) {
1397
+ const mass = massOf(state, mate);
1398
+ return "the " + mate + (mass === null ? "" : " (mass " + mass + ")");
1399
+ }).join(", ") + ".");
1400
+ }
1401
+ return parts.join(" ");
1402
+ }
1403
+
1404
+ // ---- the compass ring ----------------------------------------------------
1405
+ // One button per direction, in the slot that direction points at: the "go"
1406
+ // a written exit already allows, or the "dig" that would open one. A
1407
+ // direction with neither draws nothing, so the ring never offers a command
1408
+ // the world would refuse.
1409
+ function diggableFor(rows, state, here) {
1410
+ const fromEngine = window.tmctMud && window.tmctMud.diggableDirections;
1411
+ return fromEngine ? fromEngine(rows, state, here) : diggableDirections(rows, state, here);
1412
+ }
1413
+
1414
+ function renderDirections(character, rows, state, here) {
1415
+ const w = paneIdFor(character);
1416
+ const exits = state.exits.get(here);
1417
+ const diggable = diggableFor(rows, state, here);
1418
+ for (const direction of COMPASS) {
1419
+ const slot = el(w + "-dir-" + direction);
1420
+ const open = exits && exits.has(direction);
1421
+ const command = open ? "go " + direction : (diggable.indexOf(direction) !== -1 ? "dig " + direction : null);
1422
+ if (!command) { slot.innerHTML = ""; continue; }
1423
+ const cls = "dir-pill " + (open ? "go" : "dig") + (direction === "up" || direction === "down" ? " vertical" : "");
1424
+ slot.innerHTML = '<button type="button" class="' + cls + '" data-command="' + esc(command) + '" title="' + esc(command)
1425
+ + '" aria-label="' + esc(command) + '">' + DIR_GLYPH[direction] + "</button>";
1426
+ slot.querySelector("button").addEventListener("click", function () { sendCommand(character, command); });
1427
+ }
1428
+ }
1429
+
1430
+ // ---- the pill row --------------------------------------------------------
1431
+ // Every action the room actually grants comes from the engine's own
1432
+ // roomAffordances — the SAME list the "You can:" line in a look reply is built
1433
+ // from — so nothing the reply above offers can be missing from the row beneath
1434
+ // it. Exits appear in both the row and the compass ring on purpose: the ring
1435
+ // says WHERE a way out points, the row says what it is called.
1436
+ function affordancesFor(rows, state, here, character) {
1437
+ const fromEngine = window.tmctMud && window.tmctMud.roomAffordances;
1438
+ return fromEngine ? fromEngine(rows, state, here, character) : [];
1439
+ }
1440
+
1441
+ function subjectOfAction(action) {
1442
+ return action.indexOf("talk to ") === 0 ? action.slice(8) : action.slice(action.indexOf(" ") + 1);
1443
+ }
1444
+
1445
+ function commandsBySubject(actions) {
1446
+ const bySubject = {};
1447
+ for (const action of actions) {
1448
+ if (action.indexOf("go ") === 0) continue;
1449
+ bySubject[subjectOfAction(action)] = action;
1450
+ }
1451
+ return bySubject;
1452
+ }
1453
+
1454
+ function renderChatPills(character, rows, state, here, roomIds) {
1455
+ const w = paneIdFor(character);
1456
+ const pillsEl = el(w + "-chatpills");
1457
+ const pills = [
1458
+ { command: "look", label: "look", cls: "" },
1459
+ { command: "what do you know about food", label: "what do you know about food", cls: "" },
1460
+ ];
1461
+ for (const action of affordancesFor(rows, state, here, character)) {
1462
+ if (action.indexOf("go ") === 0) { pills.push({ command: action, label: action, cls: " affordance way" }); continue; }
1463
+ const subject = subjectOfAction(action);
1464
+ const label = action.slice(0, action.length - subject.length) + labelForItem(rows, subject, roomIds);
1465
+ pills.push({ command: action, label: label, cls: " affordance" });
1466
+ }
1467
+ pillsEl.innerHTML = pills.map(function (p) {
1468
+ return '<button type="button" class="pill' + p.cls + '" data-command="' + esc(p.command)
1469
+ + '" title="' + esc(p.command) + '">' + esc(p.label) + "</button>";
1470
+ }).join("");
1471
+ for (const pill of pillsEl.querySelectorAll(".pill")) {
1472
+ pill.addEventListener("click", function () {
1473
+ sendCommand(character, pill.getAttribute("data-command"));
1474
+ });
1475
+ }
1476
+ }
1477
+
1478
+ function renderPouch(character, rows, state, roomIds) {
1479
+ const w = paneIdFor(character);
1480
+ const items = carriedItemsFor(rows, state, character);
1481
+ el(w + "-pouch-list").innerHTML = items.map(function (i) {
1482
+ return '<li title="' + esc(i.subject) + '">' + esc(labelForItem(rows, i.subject, roomIds)) + "</li>";
1483
+ }).join("");
1484
+ const mass = state.masses.get(character);
1485
+ const drain = DATA.mudConfig[speciesOfCharacter(character) + "MassDecrementPerTurn"];
1486
+ el(w + "-stats").textContent = "mass " + (mass ? mass.value : "?") + (drain !== undefined ? " \\u00b7 drain/turn " + drain : "");
1487
+ }
1488
+
1489
+ // The world mints a dug object with its own display-name fact, so the engine
1490
+ // can say "carrot" while every verb still resolves the distinct id
1491
+ // ("carrot-1"). itemLabel is the fallback for an id carrying no such fact.
1492
+ function labelForItem(rows, subject, roomIds) {
1493
+ const fromEngine = window.tmctMud && window.tmctMud.displayNameOf;
1494
+ const name = fromEngine ? fromEngine(rows, subject) : null;
1495
+ return name && name !== subject ? name : itemLabel(subject, roomIds);
1496
+ }
1497
+
1498
+ // ---- the burrow survey ---------------------------------------------------
1499
+ // One routine, drawn twice: the omniscient board in the top row (every room
1500
+ // the world has) and each pane's own known ground (only what that character
1501
+ // has dug or walked, with a fading stub where it knows a way carries on).
1502
+ function burrowSvg(graph, options) {
1503
+ if (!graph.nodes.length) return "";
1504
+ const opts = options || {};
1505
+ const compact = !!opts.compact;
1506
+ const cellX = compact ? 44 : 76, cellY = compact ? 26 : 54;
1507
+ const roomW = compact ? 34 : 62, roomH = compact ? 14 : 26;
1508
+ const maxX = Math.max.apply(null, graph.nodes.map(function (n) { return n.x; }));
1509
+ const maxY = Math.max.apply(null, graph.nodes.map(function (n) { return n.y; }));
1510
+ const w = (maxX + 1) * cellX, h = (maxY + 1) * cellY;
1511
+ const byRoom = {};
1512
+ for (const n of graph.nodes) byRoom[n.id] = n;
1513
+ const cx = function (n) { return (n.x + 0.5) * cellX; };
1514
+ const cy = function (n) { return (n.y + 0.5) * cellY; };
1515
+
1516
+ // The turf band is only drawn when the layout genuinely separates the
1517
+ // surface from the soil — a lateral dig can push a surface room onto a
1518
+ // lower row, and a ground line above it would say something false. The
1519
+ // room fills carry the same information either way, which is all the
1520
+ // thumbnail-sized copy has room for.
1521
+ const surfaceRows = graph.nodes.filter(function (n) { return n.level === 0; }).map(function (n) { return n.y; });
1522
+ const soilRows = graph.nodes.filter(function (n) { return n.level !== 0; }).map(function (n) { return n.y; });
1523
+ let turf = "";
1524
+ if (!compact && surfaceRows.length && soilRows.length && Math.max.apply(null, surfaceRows) < Math.min.apply(null, soilRows)) {
1525
+ const groundY = (Math.max.apply(null, surfaceRows) + 0.5) * cellY + roomH / 2 + (compact ? 3 : 7);
1526
+ turf = '<rect class="turf" x="0" y="0" width="' + w + '" height="' + groundY + '"></rect>'
1527
+ + '<line class="ground-line" x1="0" y1="' + groundY + '" x2="' + w + '" y2="' + groundY + '"></line>';
1528
+ }
1529
+
1530
+ const tunnels = graph.edges.map(function (e) {
1531
+ const a = byRoom[e.from], b = byRoom[e.to];
1532
+ if (!a || !b) return "";
1533
+ const shaft = e.direction === "up" || e.direction === "down" ? " shaft" : "";
1534
+ return '<line class="tunnel' + shaft + '" x1="' + cx(a) + '" y1="' + cy(a) + '" x2="' + cx(b) + '" y2="' + cy(b) + '"></line>';
1535
+ }).join("");
1536
+
1537
+ const HINT_DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
1538
+ const hints = graph.hints.map(function (hi) {
1539
+ const from = byRoom[hi.from];
1540
+ if (!from) return "";
1541
+ const d = HINT_DELTA[hi.direction] || [0, 0];
1542
+ return '<circle class="hint" cx="' + (cx(from) + d[0] * cellX * 0.36) + '" cy="' + (cy(from) + d[1] * cellY * 0.36) + '" r="' + (compact ? 2 : 3.2) + '"></circle>';
1543
+ }).join("");
1544
+
1545
+ const occupants = opts.occupants || {};
1546
+ const rooms = graph.nodes.map(function (n) {
1547
+ const here = opts.here === n.id ? " here" : "";
1548
+ const fresh = opts.fresh === n.id ? " freshly-dug" : "";
1549
+ const surface = n.level === 0 ? " surface" : "";
1550
+ const label = esc(n.id);
1551
+ const fit = label.length * (compact ? 3.6 : 4.3) > roomW - 6
1552
+ ? ' textLength="' + (roomW - 6) + '" lengthAdjust="spacingAndGlyphs"' : "";
1553
+ const cast = occupants[n.id] || [];
1554
+ // The room's own width is the budget: a dozen animals down one hole must
1555
+ // still read as a dozen dots inside that room rather than a line running
1556
+ // out of it, so the gap closes as the crowd grows.
1557
+ const step = Math.min(compact ? 6 : 9, (roomW - 6) / Math.max(1, cast.length));
1558
+ const dots = cast.map(function (c, i) {
1559
+ const spread = (i - (cast.length - 1) / 2) * step;
1560
+ return '<circle class="occupant" cx="' + (cx(n) + spread) + '" cy="' + (cy(n) + roomH / 2) + '" r="' + (compact ? 2.4 : 3.6)
1561
+ + '" fill="' + c.color + '"><title>' + esc(c.character) + "</title></circle>";
1562
+ }).join("");
1563
+ return '<g class="room' + surface + here + fresh + '" data-room="' + label + '"><rect x="' + (cx(n) - roomW / 2) + '" y="' + (cy(n) - roomH / 2)
1564
+ + '" width="' + roomW + '" height="' + roomH + '" rx="3"></rect><text x="' + cx(n) + '" y="' + (cy(n) + (compact ? 2 : 2.5))
1565
+ + '"' + fit + ">" + label + "</text>" + dots + "</g>";
1566
+ }).join("");
1567
+
1568
+ return '<div class="burrow"><svg viewBox="0 0 ' + w + " " + h + '" preserveAspectRatio="xMidYMid meet" role="img" aria-label="'
1569
+ + esc(opts.label || "the burrow") + '">' + turf + tunnels + hints + rooms + "</svg></div>";
1570
+ }
1571
+
1572
+ function renderMinimap(character, rows, state, here) {
1573
+ const visited = session.windows[character].visitedRoomIds();
1574
+ const graph = burrowGraph(state, visited, DATA.rootRoom);
1575
+ el(paneIdFor(character) + "-minimap").innerHTML = burrowSvg(graph, {
1576
+ compact: true, here: here, label: "the ground " + character + " has covered",
1577
+ });
1578
+ }
1579
+
1580
+ function renderWorldMap(rows, state, roomIds) {
1581
+ const graph = burrowGraph(state, roomIds, DATA.rootRoom);
1582
+ const occupants = {};
1583
+ for (const room of roomIds) {
1584
+ const here = charactersInRoom(state, room, everyone());
1585
+ if (!here.length) continue;
1586
+ occupants[room] = here.map(function (c) { return { character: c, color: colorFor(c) }; });
1587
+ }
1588
+ el("worldMapBoard").innerHTML = burrowSvg(graph, {
1589
+ occupants: occupants, fresh: freshlyDugRoom, label: "every room in the burrow, and who stands where",
1590
+ });
1591
+ // One key entry per pane, because a pane is what a colour identifies. The
1592
+ // npcs share the last entry: naming ten of them would take more room than
1593
+ // the survey they annotate, and each one's own dot already carries its name.
1594
+ const keys = cast.map(function (c) {
1595
+ const room = state.placements.get(c) ? state.placements.get(c).object : "?";
1596
+ const fate = window.tmctMud.outOfPlayReasonOf(state, c);
1597
+ const where = fate ? fate : "in " + esc(room);
1598
+ return '<span class="key-actor"><span class="key-dot" style="background:' + colorFor(c) + '"></span>'
1599
+ + esc(speciesOfCharacter(c)) + " " + where + "</span>";
1600
+ });
1601
+ if (npcs.length) {
1602
+ const live = npcs.filter(function (c) { return !finished[c]; }).length;
1603
+ const tally = live === npcs.length ? String(npcs.length) : live + " of " + npcs.length;
1604
+ keys.push('<span class="key-npcs"><span class="key-dot" style="background:' + NPC_COLOR + '"></span>'
1605
+ + tally + (live === 1 ? " npc" : " npcs") + " digging</span>");
1606
+ }
1607
+ el("worldMapKey").innerHTML = keys.join("");
1608
+ el("worldMapTurn").textContent = "turn " + globalTurn;
1609
+ }
1610
+
1611
+ // A whole redraw per turn is one thing at two characters and quite another at
1612
+ // fourteen, where the ticks land faster than a snapshot can be read. Requests
1613
+ // arriving mid-render collapse into a single follow-up, so the page always
1614
+ // ends up drawing the newest state and never queues a backlog of stale ones.
1615
+ let rendering = false;
1616
+ let renderAgain = false;
1617
+ async function renderSoon() {
1618
+ if (rendering) { renderAgain = true; return; }
1619
+ rendering = true;
1620
+ try {
1621
+ await renderAll();
1622
+ } finally {
1623
+ rendering = false;
1624
+ }
1625
+ if (renderAgain) { renderAgain = false; renderSoon(); }
1626
+ }
1627
+
1628
+ // A character eaten on its OWN turn is out of play the moment that turn
1629
+ // returns, one whole tick before autoplayTick would report it — so every
1630
+ // character's fate is read from the engine every redraw, not only off a tick
1631
+ // result. It reads against the snapshot this redraw already holds: a
1632
+ // per-window call would re-fold the whole world once per animal.
1633
+ async function renderAll() {
1634
+ if (!session) return;
1635
+ el("globalTurnCount").textContent = "turns: " + globalTurn;
1636
+ const snap = await session.snapshot();
1637
+ lastSnapshot = snap;
1638
+ const roomIds = allRoomIds(snap.rows);
1639
+ for (const character of everyone()) {
1640
+ const reason = window.tmctMud.outOfPlayReasonOf(snap.state, character);
1641
+ if (reason) markOutOfPlay(character, reason, null);
1642
+ }
1643
+ renderWorldMap(snap.rows, snap.state, roomIds);
1644
+ for (const character of cast) {
1645
+ el(paneIdFor(character) + "-turn").textContent = "turn " + turnsFor(character);
1646
+ if (finished[character]) continue;
1647
+ const place = snap.state.placements.get(character);
1648
+ const here = place ? place.object : null;
1649
+ if (!here) continue;
1650
+ const roomMates = window.tmctMud.castInRoom(snap.rows, snap.state, here, character);
1651
+ renderRoomView(character, snap.rows, snap.state, here, roomIds, roomMates);
1652
+ renderDirections(character, snap.rows, snap.state, here);
1653
+ renderChatPills(character, snap.rows, snap.state, here, roomIds);
1654
+ renderPouch(character, snap.rows, snap.state, roomIds);
1655
+ renderMinimap(character, snap.rows, snap.state, here);
1656
+ }
1657
+ freshlyDugRoom = null;
1658
+ }
1659
+
1660
+ // ---- edit mode -----------------------------------------------------------
1661
+ // The textarea is seeded from renderMudEditorText over the world's OWN facts.
1662
+ // Reads are scoped to this world's provenance tag the same way session
1663
+ // .applyEdit scopes its writes: the live store also carries the default
1664
+ // persona's background corpus, and this page must never show, or risk
1665
+ // retracting, a fact that is not part of the burrow.
1666
+ //
1667
+ // Typing debounces two things at two paces. The suggestion pills are cheap
1668
+ // in-memory lookups and can chase the cursor (~180ms). The store sync is the
1669
+ // one that writes, so it waits for the typing to settle (~450ms) — a
1670
+ // half-finished word must never be read as what somebody meant.
1671
+ let editRows = [];
1672
+ let editState = { placements: new Map(), openness: new Map(), masses: new Map(), exits: new Map(), turnCount: 0 };
1673
+ let allStoreRows = [];
1674
+ let selectedRoomId = null;
1675
+ let editing = false;
1676
+
1677
+ function worldOnlyRows(rows) {
1678
+ const prefix = "world:" + DATA.worldPayload.name;
1679
+ return (rows || []).filter(function (r) {
1680
+ return typeof r.provenance === "string" && r.provenance.indexOf(prefix) === 0;
1681
+ });
1682
+ }
1683
+
1684
+ function renderEditMap() {
1685
+ const graph = burrowGraph(editState, allRoomIds(editRows), DATA.rootRoom);
1686
+ el("editMapBoard").innerHTML = graph.nodes.length
1687
+ ? burrowSvg(graph, { here: selectedRoomId, label: "every room this world writes" })
1688
+ : '<span class="edit-empty">no rooms written yet</span>';
1689
+ }
1690
+
1691
+ // A class card, the legend's and the room detail's shared unit — the sprite
1692
+ // frame the panes already use, shrunk, with the subject's own name under it.
1693
+ function editCardHtml(subject, label, svg) {
1694
+ return '<div class="sprite-card" title="' + esc(subject) + '"><div class="sprite-frame">' + svg
1695
+ + '</div><div class="sprite-label">' + esc(label) + "</div></div>";
1696
+ }
1697
+
1698
+ // Every class this world actually puts in a room, by its own icon. A creature
1699
+ // resolves on its species (the same read every pane's own sprite makes), a
1700
+ // prop on its declared class — so the legend shows what the survey shows.
1701
+ function renderEditLegend() {
1702
+ const classes = {};
1703
+ for (const subject of editState.placements.keys()) {
1704
+ const cls = isCreature(editState, subject) ? speciesOfCharacter(subject) : spriteClassForObject(editRows, subject);
1705
+ if (cls) classes[cls] = true;
1706
+ }
1707
+ const names = Object.keys(classes).sort();
1708
+ el("editLegend").innerHTML = names.length
1709
+ ? names.map(function (cls) { return editCardHtml(cls, cls, spriteSvgFor(cls, editRows, "legend-" + cls)); }).join("")
1710
+ : '<span class="edit-empty">nothing placed yet</span>';
1711
+ }
1712
+
1713
+ function renderRoomDetail() {
1714
+ const titleEl = el("roomDetailTitle");
1715
+ const castEl = el("roomDetailCast");
1716
+ const captionEl = el("roomDetailCaption");
1717
+ if (!selectedRoomId) {
1718
+ titleEl.textContent = "click a room";
1719
+ castEl.innerHTML = "";
1720
+ captionEl.textContent = "Click a room on the survey to see what stands in it.";
1721
+ return;
1722
+ }
1723
+ titleEl.textContent = selectedRoomId;
1724
+ const roomIds = allRoomIds(editRows);
1725
+ const here = [];
1726
+ for (const subject of editState.placements.keys()) {
1727
+ if (visibleRoomOf(editRows, editState, subject) !== selectedRoomId) continue;
1728
+ const creature = isCreature(editState, subject);
1729
+ here.push({
1730
+ subject: subject,
1731
+ label: creature ? subject : labelForItem(editRows, subject, roomIds),
1732
+ svg: creature ? spriteSvgFor(speciesOfCharacter(subject), editRows, subject) : objectSvgFor(subject, editRows),
1733
+ });
1734
+ }
1735
+ here.sort(function (a, b) { return a.subject.localeCompare(b.subject); });
1736
+ castEl.innerHTML = here.map(function (t) { return editCardHtml(t.subject, t.label, t.svg); }).join("");
1737
+ const kind = roomKindForRoom(editRows, selectedRoomId);
1738
+ const ways = [...(editState.exits.get(selectedRoomId) || new Map()).keys()].sort();
1739
+ captionEl.textContent = "An " + kind + " room. "
1740
+ + (ways.length ? "Ways out: " + ways.join(", ") + ". " : "No way out written yet. ")
1741
+ + (here.length ? here.length + " thing" + (here.length === 1 ? "" : "s") + " placed here." : "Nothing placed here.");
1742
+ }
1743
+
1744
+ function refreshEditPanels() {
1745
+ renderEditMap();
1746
+ renderEditLegend();
1747
+ renderRoomDetail();
1748
+ }
1749
+
1750
+ el("editMapBoard").addEventListener("click", function (e) {
1751
+ const g = e.target.closest("[data-room]");
1752
+ if (!g) return;
1753
+ selectedRoomId = g.getAttribute("data-room");
1754
+ renderEditMap();
1755
+ renderRoomDetail();
1756
+ });
1757
+
1758
+ // The lateral SKOS neighbourhood plus the vertical is-a chain for whatever
1759
+ // word the cursor sits behind. Read over the FULL store, not the world's own
1760
+ // rows: a term's synonyms mostly live in the background corpus, not in the
1761
+ // burrow's own vocabulary. Nothing found is nothing shown — an honest miss.
1762
+ function renderSuggestionPills() {
1763
+ const box = el("editorPills");
1764
+ const term = wordBeforeCursor(el("editorText").value, el("editorText").selectionStart);
1765
+ if (!term || !window.tmctMud) { box.innerHTML = ""; return; }
1766
+ const related = window.tmctMud.relatedForTerm ? window.tmctMud.relatedForTerm(allStoreRows, term) : null;
1767
+ const chain = window.tmctMud.classAncestorChain ? window.tmctMud.classAncestorChain(term, allStoreRows) : [];
1768
+ const seen = { };
1769
+ seen[term] = true;
1770
+ const out = [];
1771
+ const push = function (label) { if (label && !seen[label]) { seen[label] = true; out.push(label); } };
1772
+ if (related) {
1773
+ related.synonyms.forEach(push);
1774
+ related.related.forEach(function (r) { push(r.prefLabel); });
1775
+ }
1776
+ chain.slice(1).forEach(push);
1777
+ box.innerHTML = out.slice(0, 8).map(function (s) {
1778
+ return '<button type="button" class="pill" data-insert="' + esc(s) + '">' + esc(s) + "</button>";
1779
+ }).join("");
1780
+ }
1781
+
1782
+ el("editorPills").addEventListener("click", function (e) {
1783
+ const btn = e.target.closest(".pill");
1784
+ if (!btn) return;
1785
+ const area = el("editorText");
1786
+ const pos = area.selectionStart;
1787
+ const word = wordBeforeCursor(area.value, pos);
1788
+ const insert = btn.getAttribute("data-insert");
1789
+ area.value = area.value.slice(0, pos - word.length) + insert + area.value.slice(pos);
1790
+ const next = pos - word.length + insert.length;
1791
+ area.setSelectionRange(next, next);
1792
+ area.focus();
1793
+ onEditorChanged();
1794
+ });
1795
+
1796
+ async function applyEditorText() {
1797
+ if (!session) return;
1798
+ const status = el("editorStatus");
1799
+ status.className = "edit-status pending";
1800
+ status.textContent = "reading the burrow\\u2026";
1801
+ const result = await serializeTick(function () { return session.applyEdit(el("editorText").value); });
1802
+ const snap = await session.snapshot();
1803
+ allStoreRows = snap.rows;
1804
+ editRows = worldOnlyRows(snap.rows);
1805
+ editState = window.tmctMud.foldWorldState(window.tmctMud.worldActionRows(editRows));
1806
+ if (result.unrecognized.length) {
1807
+ const lines = result.unrecognized.map(function (u) { return u.line; }).join(", ");
1808
+ status.className = "edit-status pending";
1809
+ status.textContent = result.unrecognized.length + " line"
1810
+ + (result.unrecognized.length === 1 ? "" : "s") + " not understood yet (line " + lines
1811
+ + ") \\u2014 nothing is retracted until they are.";
1812
+ } else {
1813
+ status.className = "edit-status ok";
1814
+ status.textContent = (result.added || result.removed)
1815
+ ? "synced \\u2014 " + result.added + " fact(s) written, " + result.removed + " retracted."
1816
+ : "synced \\u2014 no change.";
1817
+ }
1818
+ refreshEditPanels();
1819
+ }
1820
+
1821
+ let suggestTimer = null;
1822
+ let syncTimer = null;
1823
+ function scheduleSuggestions() { clearTimeout(suggestTimer); suggestTimer = setTimeout(renderSuggestionPills, 180); }
1824
+ function scheduleSync() { clearTimeout(syncTimer); syncTimer = setTimeout(applyEditorText, 450); }
1825
+ function onEditorChanged() { scheduleSuggestions(); scheduleSync(); }
1826
+ el("editorText").addEventListener("input", onEditorChanged);
1827
+ el("editorText").addEventListener("click", scheduleSuggestions);
1828
+ el("editorText").addEventListener("keyup", function (e) {
1829
+ if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].indexOf(e.key) !== -1) scheduleSuggestions();
1830
+ });
1831
+
1832
+ // Nothing may tick while the world is being rewritten: a turn and an edit both
1833
+ // write the same store, and a turn landing mid-sync would fold against rows
1834
+ // the textarea no longer describes. So every ticker stops and every control
1835
+ // that could start one is disabled for as long as the editor is open.
1836
+ function setPlayControlsEnabled(on) {
1837
+ el("autoToggle").disabled = !on;
1838
+ el("playerCountSlider").disabled = !on;
1839
+ el("resetBtn").disabled = !on;
1840
+ for (const character of cast) {
1841
+ if (finished[character]) continue;
1842
+ el(paneIdFor(character) + "-play").disabled = !on;
1843
+ el(paneIdFor(character) + "-step").disabled = !on;
1844
+ }
1845
+ }
1846
+
1847
+ async function enterEditMode() {
1848
+ editing = true;
1849
+ autoOn = false;
1850
+ const playBtn = el("autoToggle");
1851
+ playBtn.setAttribute("aria-pressed", "false");
1852
+ playBtn.textContent = "\\u25B6 play";
1853
+ for (const character of cast) {
1854
+ const ticker = tickers[character];
1855
+ if (ticker) ticker.pause();
1856
+ }
1857
+ setPlayControlsEnabled(false);
1858
+ const snap = await session.snapshot();
1859
+ allStoreRows = snap.rows;
1860
+ editRows = worldOnlyRows(snap.rows);
1861
+ editState = window.tmctMud.foldWorldState(window.tmctMud.worldActionRows(editRows));
1862
+ el("editorText").value = renderMudEditorText(editRows, editState);
1863
+ el("editorStatus").className = "edit-status";
1864
+ el("editorStatus").textContent = "";
1865
+ el("editorPills").innerHTML = "";
1866
+ selectedRoomId = null;
1867
+ document.body.classList.add("editing");
1868
+ el("editModeBtn").textContent = "back to playing";
1869
+ el("editModeBtn").setAttribute("aria-pressed", "true");
1870
+ refreshEditPanels();
1871
+ }
1872
+
1873
+ async function exitEditMode() {
1874
+ editing = false;
1875
+ clearTimeout(syncTimer);
1876
+ // One last sync on the way out, so a change typed in the final half-second
1877
+ // before the button is clicked reaches the world like every other one.
1878
+ await applyEditorText();
1879
+ document.body.classList.remove("editing");
1880
+ el("editModeBtn").textContent = "edit";
1881
+ el("editModeBtn").setAttribute("aria-pressed", "false");
1882
+ setPlayControlsEnabled(true);
1883
+ await renderAll();
1884
+ }
1885
+
1886
+ // ---- the control deck ----------------------------------------------------
1887
+ // The players slider runs on detents (1, 2, 4), so its own value is the
1888
+ // index of the count, never the count.
1889
+ function chosenPlayerCount() {
1890
+ const picked = DATA.playerCounts[Number(el("playerCountSlider").value)];
1891
+ return picked || DATA.defaultPlayerCount;
1892
+ }
1893
+
1894
+ function showPlayerCount(count) {
1895
+ el("playerCountValue").textContent = String(count);
1896
+ el("playerCountSlider").setAttribute("aria-valuetext", count + (count === 1 ? " player" : " players"));
1897
+ }
1898
+
1899
+ // The npcs slider carries its own count, so it needs no detent table.
1900
+ function chosenNpcCount() {
1901
+ const picked = Number(el("npcCountSlider").value);
1902
+ return Number.isFinite(picked) ? picked : DATA.defaultNpcCount;
1903
+ }
1904
+
1905
+ function showNpcCount(count) {
1906
+ el("npcCountValue").textContent = String(count);
1907
+ el("npcCountSlider").setAttribute("aria-valuetext", count + (count === 1 ? " npc" : " npcs"));
1908
+ }
1909
+
1910
+ function wireDeck() {
1911
+ const playBtn = el("autoToggle");
1912
+ const delaySlider = el("delaySlider");
1913
+ const maxTurnsSlider = el("maxTurnsSlider");
1914
+ const playerCountSlider = el("playerCountSlider");
1915
+ const npcCountSlider = el("npcCountSlider");
1916
+ playBtn.addEventListener("click", function () {
1917
+ if (!session) return;
1918
+ autoOn = !autoOn;
1919
+ playBtn.setAttribute("aria-pressed", autoOn ? "true" : "false");
1920
+ playBtn.textContent = autoOn ? "\\u23F8 pause" : "\\u25B6 play";
1921
+ // Every animal in the world, pane or none: the deck's play control is the
1922
+ // world running, and an npc has no other control that could start it.
1923
+ for (const character of everyone()) {
1924
+ const ticker = tickers[character];
1925
+ if (!ticker) continue;
1926
+ if (autoOn) ticker.play(); else ticker.pause();
1927
+ }
1928
+ });
1929
+ delaySlider.addEventListener("input", function () {
1930
+ delayMs = Number(delaySlider.value);
1931
+ el("delayValue").textContent = delayMs + "ms";
1932
+ });
1933
+ maxTurnsSlider.addEventListener("input", function () {
1934
+ maxTurns = Number(maxTurnsSlider.value);
1935
+ el("maxTurnsValue").textContent = String(maxTurns);
1936
+ });
1937
+ // The readout follows the thumb, but a new cast waits for the drag to
1938
+ // settle: booting on every intermediate detent would open, and throw
1939
+ // away, a whole shared world per step.
1940
+ playerCountSlider.addEventListener("input", function () { showPlayerCount(chosenPlayerCount()); });
1941
+ playerCountSlider.addEventListener("change", function () { boot(); });
1942
+ npcCountSlider.addEventListener("input", function () { showNpcCount(chosenNpcCount()); });
1943
+ npcCountSlider.addEventListener("change", function () { boot(); });
1944
+ el("resetBtn").addEventListener("click", function () { boot(); });
1945
+ el("editModeBtn").addEventListener("click", function () {
1946
+ if (!session) return;
1947
+ if (editing) exitEditMode(); else enterEditMode();
1948
+ });
1949
+
1950
+ const infoBtn = el("deckInfoBtn");
1951
+ const infoPopup = el("deckInfoPopup");
1952
+ function openDeckInfo() {
1953
+ infoPopup.hidden = false;
1954
+ infoBtn.setAttribute("aria-expanded", "true");
1955
+ }
1956
+ function closeDeckInfo() {
1957
+ infoPopup.hidden = true;
1958
+ infoBtn.setAttribute("aria-expanded", "false");
1959
+ }
1960
+ infoBtn.addEventListener("click", function () { if (infoPopup.hidden) openDeckInfo(); else closeDeckInfo(); });
1961
+ el("deckInfoClose").addEventListener("click", closeDeckInfo);
1962
+ document.addEventListener("keydown", function (e) { if (e.key === "Escape" && !infoPopup.hidden) closeDeckInfo(); });
1963
+ document.addEventListener("click", function (e) {
1964
+ if (infoPopup.hidden) return;
1965
+ if (e.target === infoBtn || infoPopup.contains(e.target)) return;
1966
+ closeDeckInfo();
1967
+ });
1968
+ }
1969
+
1970
+ // The stage is the cast's, so it is rebuilt for the cast: one paneMarkup
1971
+ // call per animal, the count on the stage itself for the grid to lay out
1972
+ // against, and a fresh set of listeners for the panes that just appeared.
1973
+ function renderStage() {
1974
+ const stage = el("mudStage");
1975
+ stage.setAttribute("data-panes", String(slots.length));
1976
+ stage.innerHTML = slots.map(function (slot) { return paneMarkup(slot); }).join("");
1977
+ for (const slot of slots) wirePane(slot);
1978
+ }
1979
+
1980
+ function bindPanes() {
1981
+ for (let i = 0; i < slots.length; i += 1) {
1982
+ const w = "window-" + slots[i];
1983
+ const character = cast[i];
1984
+ const pane = el(w);
1985
+ pane.setAttribute("data-character", character);
1986
+ pane.setAttribute("aria-label", speciesOfCharacter(character) + "'s own view of the shared world");
1987
+ el(w + "-name").innerHTML = esc(speciesOfCharacter(character)) + '<span class="mono char-id">' + esc(character) + "</span>";
1988
+ el(w + "-chatq").setAttribute("aria-label", "type a command for " + character);
1989
+ }
1990
+ }
1991
+
1992
+ // Booting builds the shared world and draws the opening state. It never
1993
+ // starts a turn: nothing ticks until the deck's own play control (or a
1994
+ // pane's) is clicked. Opening the world is asynchronous and the controls
1995
+ // that boot are not, so a boot overtaken by a later one drops its own
1996
+ // session on the floor rather than binding it to the stage the newer boot
1997
+ // has already drawn.
1998
+ let bootSeq = 0;
1999
+ async function boot() {
2000
+ const seq = bootSeq += 1;
2001
+ autoOn = false;
2002
+ const playBtn = el("autoToggle");
2003
+ playBtn.setAttribute("aria-pressed", "false");
2004
+ playBtn.textContent = "\\u25B6 play";
2005
+ for (const character of Object.keys(tickers)) tickers[character].pause();
2006
+ tickers = {};
2007
+ globalTurn = 0;
2008
+ freshlyDugRoom = null;
2009
+ lastSnapshot = null;
2010
+ speechBubbles.clear();
2011
+ tickChain = Promise.resolve();
2012
+ // Rebuilt rather than pruned: a character that had a pane last boot can be
2013
+ // an npc in this one, and a stale seat would send its ending to a pane now
2014
+ // showing somebody else.
2015
+ finished = {};
2016
+ turnsTaken = {};
2017
+ slotOf = {};
2018
+
2019
+ // ONE draw, the engine's own, and the same list the session is built from:
2020
+ // a second independent draw here would leave the page showing animals the
2021
+ // shared world was never opened for. The stage follows the draw rather
2022
+ // than the slider, so a roster too small for the chosen count draws every
2023
+ // animal it has and no empty pane. The npcs draw from what the panes left,
2024
+ // out of a roster grown to hold everyone, so no animal is ever both.
2025
+ const npcCount = chosenNpcCount();
2026
+ cast = window.tmctMud.pickMudRoster(roster, { count: chosenPlayerCount() });
2027
+ const pool = window.tmctMud.expandMudRoster(roster, cast.length + npcCount)
2028
+ .filter(function (id) { return cast.indexOf(id) === -1; });
2029
+ npcs = window.tmctMud.pickMudRoster(pool, { count: npcCount });
2030
+ slots = DATA.paneSlots.slice(0, cast.length);
2031
+ showPlayerCount(cast.length);
2032
+ showNpcCount(npcs.length);
2033
+ renderStage();
2034
+ const opened = await window.tmctMud.createMudSession(DATA.worldPayload, { characters: everyone() });
2035
+ if (seq !== bootSeq) return;
2036
+ session = opened;
2037
+ for (let i = 0; i < cast.length; i += 1) slotOf[cast[i]] = slots[i];
2038
+ bindPanes();
2039
+ for (const character of cast) {
2040
+ const w = paneIdFor(character);
2041
+ el(w + "-play").disabled = false;
2042
+ el(w + "-step").disabled = false;
2043
+ el(w + "-chatq").disabled = false;
2044
+ }
2045
+ for (const character of everyone()) {
2046
+ turnsTaken[character] = 0;
2047
+ ensureTicker(character);
2048
+ }
2049
+ renderSoon();
2050
+ }
2051
+
2052
+ wireDeck();
2053
+ boot();
2054
+ })();`;
2055
+ }