@polycode-projects/the-mechanical-code-talker 3.3.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.
@@ -1,10 +1,29 @@
1
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. Four
3
- // burrowing animals (mole-1, vole-1, badger-2, groundhog-1 mud-garden.jsonl
4
- // ships all four) each get their own window over ONE shared live world
5
- // (mud-browser-entry.mjs's createMudSession); a fifth, omniscient view — the
6
- // soil cross-section world map sits in the middle with no fog of war at
7
- // all, the one deliberate exception PLAN_MUD.md names.
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.
8
27
  //
9
28
  // Shaped after adventure-viz.mjs/spider-fly-viz.mjs: one inlined <style>
10
29
  // over viz-theme.mjs's shared tokens, behaviour as an inlined IIFE, the
@@ -15,42 +34,52 @@
15
34
  // provider and embedded as page data). `createTicker` and a small set of
16
35
  // self-contained, `.toString()`-splice-safe room-scene helpers are spliced
17
36
  // into the inline script exactly the way spider-fly-viz.mjs splices its own
18
- // render-glue — `roomSceneObjects`/`spriteClassForObject`/`visibleRoomOf`/
19
- // `roomKindForRoom` are REUSED directly from adventure-viz.mjs rather than
20
- // re-derived: mud-garden ships no individual named "player" (the whole point
21
- // of the multi-character demo), so those functions' own hardcoded "player"
22
- // exclusion never fires for a mud character — every character reads back as
23
- // an ordinary visible object of its room until this page's own code filters
24
- // the CURRENT viewing character out by name (mudRoomSceneObjects, below).
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).
25
46
  //
26
47
  // A room's graphic is a rendering of worldDigestRows'/roomAffordances' own
27
48
  // text digest, never a replacement for it (PLAN_MUD.md's own "Room view"
28
- // spec) this page keeps the layout intentionally simpler than Ashcombe
29
- // Hall's wall/floor stacking (adventure-viz.mjs's roomSceneLayout): a soil-
30
- // toned canvas backdrop (roomKindForRoom picks outdoor/underground), the
31
- // viewing character's own sprite drawn center-low, every room-mate and loose
32
- // object laid out as a plain wrapped tray of sprites above it. PLAN_MUD.md
33
- // specifies the canvas+sprite-layer TECHNIQUE and the ground-truth-vs-
34
- // rendering relationship, not stacking physics, so this is this page's own
35
- // engineering call on the gap PLAN_MUD.md leaves open, not a cut from its
36
- // spec.
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).
37
64
  //
38
- // FIVE ticker instances (viz-ticker.mjs's createTicker): one per window, so
39
- // each can play/pause/step independently, plus the rail's own "auto" control
40
- // that just calls .play()/.pause() on the four window tickers at once. Every
41
- // tick, from ANY window, is funneled through one shared async queue
42
- // (serializeTick, spliced below) so two characters' turns can never
43
- // interleave their reads/writes of the one shared memoryDir — mud-browser-
44
- // entry.mjs's own header names this as the caller's responsibility, and this
45
- // is where that responsibility is discharged. The queue is also what makes
46
- // the rail's GLOBAL turn counter well-defined: it increments in the exact
47
- // order turns actually executed, never a race between windows.
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.
48
76
  import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
49
77
  import { createTicker } from "./viz-ticker.mjs";
50
78
  import {
51
- roomSceneObjects, scenePlacement, spriteClassForObject, spriteAncestryRows,
52
- visibleRoomOf, roomKindForRoom, factsForSubject,
79
+ roomSceneObjects, scenePlacement, spriteClassForObject, spriteAncestryRows, factsForSubject,
80
+ visibleRoomOf, roomKindForRoom, allRoomIds,
53
81
  } from "./adventure-viz.mjs";
82
+ import { renderMudEditorText, wordBeforeCursor } from "./mud-editor.mjs";
54
83
  import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
55
84
 
56
85
  const DEFAULT_TITLE = "tmct — the mud";
@@ -64,13 +93,23 @@ const DISPLAY_STACK = SERIF_STACK;
64
93
  const SANS_STACK = `"IBM Plex Sans", "Inter", -apple-system, BlinkMacSystemFont, sans-serif`;
65
94
 
66
95
  const ROOT_ROOM = "garden";
67
- const SLOTS = ["nw", "ne", "sw", "se"];
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;
68
107
  const DEFAULT_DELAY_MS = 650;
69
108
  const DEFAULT_MAX_TURNS = 400;
70
109
 
71
110
  const MUD_NOTE_LINES = [
72
- "Four burrowing animals share one world here. Each one only knows what it has dug up, asked about, or been told. Nobody sees the whole map except you, watching from the middle.",
73
- `This is a MUD, short for Multi Underground creature Dig. The name nods to 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.`,
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.`,
74
113
  ];
75
114
 
76
115
  /** A character id's species — "mole-1" -> "mole", "groundhog-1" ->
@@ -104,11 +143,10 @@ export function carriedItemsFor(rows, state, character) {
104
143
 
105
144
  /** Every room's depth level, `Map<roomId, number>`, BFS from `root` at level
106
145
  * 0: an "up"/"down" exit moves the level by ∓ 1, any other direction
107
- * keeps the level unchanged — the soil cross-section's own row assignment,
108
- * and each window's own minimap filter ("this character's own level only").
109
- * A room unreachable from `root` (should not happen every dug room writes
110
- * a two-way exit back) is simply absent from the map rather than guessed
111
- * at. Pure, self-contained. */
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. */
112
150
  export function levelsOf(state, root = "garden") {
113
151
  const levels = new Map([[root, 0]]);
114
152
  const queue = [root];
@@ -125,48 +163,131 @@ export function levelsOf(state, root = "garden") {
125
163
  return levels;
126
164
  }
127
165
 
128
- /** `levels` grouped into `{ level, rooms: [roomId...] }` rows, deepest last
129
- * so a caller can lay the soil cross-section out top (level 0) to bottom —
130
- * rooms within a level sort by id for a stable, deterministic column order.
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.
131
168
  * Pure. */
132
- export function levelBands(levels) {
133
- const byLevel = new Map();
134
- for (const [room, level] of levels) {
135
- if (!byLevel.has(level)) byLevel.set(level, []);
136
- byLevel.get(level).push(room);
137
- }
138
- return [...byLevel.entries()]
139
- .sort((a, b) => b[0] - a[0])
140
- .map(([level, rooms]) => ({ level, rooms: rooms.sort() }));
141
- }
142
-
143
- /** Which characters currently stand in `room` — the omniscient world map's
144
- * own per-room roster, and a per-window minimap's own (visited-only)
145
- * roster. Pure. */
146
169
  export function charactersInRoom(state, room, characters) {
147
170
  return characters.filter((c) => state.placements.get(c)?.object === room);
148
171
  }
149
172
 
150
- function slugForSlot(slot) {
151
- return `window-${slot}`;
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";
152
270
  }
153
271
 
154
272
  /** The self-contained mud.html page. Pure — identical output for identical
155
273
  * input; every other piece of state (the live world, chat, ticks) is
156
274
  * computed in the browser once the sibling bundle loads.
157
- * `characters` is `[{ id, slot, species }]`, one entry per window, in NW,
158
- * NE, SW, SE order. `worldPayload` is `{ name, facts, rules, opening }`,
159
- * read once at build time through the real worlds-pack provider (see this
160
- * module's own header). `spriteTemplates` is the large-tier sprite set
161
- * (data/sprites-large/*.toml) so mole/vole/badger/groundhog/meerkat all
162
- * resolve their own art instead of falling back to the flat animal icon.
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.
163
286
  * `mudConfig` defaults to game-config.mjs's own DEFAULT_GAME_CONFIG.mud —
164
- * the per-species mass/speed/dig-reach reference table the Creature Stats
165
- * panel reads for flavor (see this module's header on why the live
166
- * simulation does not yet act on speed/dig-reach itself).
167
- * `engineBundleJs` inlines the built mud-browser bundle instead of the
168
- * sibling `<script src>`, mirroring spider-fly-viz.mjs's own standalone-
169
- * export knob; default empty keeps the site build's sibling-file
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
170
291
  * arrangement unchanged. */
171
292
  export function renderMudHtml({
172
293
  title = DEFAULT_TITLE,
@@ -176,14 +297,19 @@ export function renderMudHtml({
176
297
  mudConfig = DEFAULT_GAME_CONFIG.mud,
177
298
  engineBundleJs = "",
178
299
  } = {}) {
300
+ const openingCount = Math.max(1, Math.min(DEFAULT_PLAYER_COUNT, characters.length || DEFAULT_PLAYER_COUNT));
179
301
  const pageData = embedJson({
180
302
  worldPayload, characters, spriteTemplates, mudConfig,
303
+ paneSlots: PANE_SLOTS,
304
+ playerCounts: PLAYER_COUNTS,
305
+ defaultPlayerCount: DEFAULT_PLAYER_COUNT,
306
+ defaultNpcCount: DEFAULT_NPC_COUNT,
181
307
  rootRoom: ROOT_ROOM,
182
308
  defaultDelayMs: DEFAULT_DELAY_MS,
183
309
  defaultMaxTurns: DEFAULT_MAX_TURNS,
184
310
  });
185
311
 
186
- const windowHtml = characters.map((c) => windowMarkup(c)).join("\n");
312
+ const paneHtml = PANE_SLOTS.slice(0, openingCount).map((slot) => paneMarkup(slot)).join("\n");
187
313
 
188
314
  return `<!doctype html>
189
315
  <html lang="en">
@@ -201,36 +327,84 @@ ${MUD_STYLE}
201
327
  </head>
202
328
  <body>
203
329
  <main>
204
- <div class="eyebrow">tmct &middot; mud</div>
205
- <h1>Multiple actors, one shared world</h1>
206
- <div class="mud-stage" id="mudStage">
207
- ${windowHtml}
208
- <div class="world-map" id="worldMap" aria-label="the whole world, every level, every character">
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">
209
374
  <div class="world-map-head">
210
375
  <span class="world-map-title">the whole burrow</span>
211
376
  <span class="mono world-map-turn" id="worldMapTurn">turn 0</span>
212
377
  </div>
213
- <div class="world-map-bands" id="worldMapBands"></div>
214
- </div>
378
+ <div class="world-map-board" id="worldMapBoard"></div>
379
+ <div class="world-map-key" id="worldMapKey"></div>
380
+ </section>
215
381
  </div>
216
- <div class="rail" id="rail" aria-label="simulation controls">
217
- <div class="rail-row">
218
- <button type="button" id="autoToggle" aria-pressed="false">&#9654; auto</button>
219
- <span class="mono rail-turns" id="globalTurnCount">turns: 0</span>
220
- <label class="rail-slider">delay
221
- <input type="range" id="delaySlider" min="80" max="2000" step="20" value="${DEFAULT_DELAY_MS}">
222
- <span class="mono" id="delayValue">${DEFAULT_DELAY_MS}ms</span>
223
- </label>
224
- <label class="rail-slider">max turns
225
- <input type="range" id="maxTurnsSlider" min="20" max="2000" step="20" value="${DEFAULT_MAX_TURNS}">
226
- <span class="mono" id="maxTurnsValue">${DEFAULT_MAX_TURNS}</span>
227
- </label>
228
- <button type="button" id="resetBtn">reset</button>
229
- </div>
230
- <div class="rail-note mud-note">
231
- <p>${escapeHtml(MUD_NOTE_LINES[0])}</p>
232
- <p>${escapeHtml(MUD_NOTE_LINES[1])}</p>
233
- </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>
234
408
  </div>
235
409
  </main>
236
410
  <script>
@@ -245,161 +419,472 @@ ${embedScriptText(pageScript())}
245
419
  `;
246
420
  }
247
421
 
248
- function windowMarkup({ id, slot, species }) {
249
- const w = slugForSlot(slot);
250
- return `<div class="mud-window" id="${w}" data-slot="${escapeHtml(slot)}" data-character="${escapeHtml(id)}">
251
- <div class="mud-window-head">
252
- <h2>${escapeHtml(species)} <span class="mono char-id">${escapeHtml(id)}</span></h2>
253
- <span class="mono window-turn" id="${w}-turn">turn 0</span>
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>
254
434
  </div>
255
- <div class="room-view" id="${w}-room">
256
- <canvas id="${w}-canvas" width="360" height="220" aria-hidden="true"></canvas>
257
- <div class="sprite-layer" id="${w}-sprites"></div>
258
- <div class="speech-bubble" id="${w}-bubble" hidden></div>
259
- <div class="dig-flourish" id="${w}-flourish" hidden></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>
260
455
  </div>
261
456
  <p class="room-caption" id="${w}-caption"></p>
262
- <div class="window-columns">
263
- <div class="pouch" id="${w}-pouch" aria-label="pouch">
457
+ <div class="pane-columns">
458
+ <div class="pouch" aria-label="pouch">
264
459
  <h3>pouch</h3>
265
460
  <ul class="pouch-list" id="${w}-pouch-list"></ul>
266
461
  <p class="mono stat-line" id="${w}-stats"></p>
267
462
  </div>
268
- <div class="minimap" aria-label="what this character has discovered">
463
+ <div class="minimap" aria-label="what this character has dug or walked">
269
464
  <h3>known ground</h3>
270
- <div class="minimap-rooms" id="${w}-minimap"></div>
465
+ <div class="minimap-board" id="${w}-minimap"></div>
271
466
  </div>
272
467
  </div>
273
- <div class="window-controls controls-row">
274
- <button type="button" id="${w}-play" disabled>&#9654; play</button>
275
- <button type="button" id="${w}-step" disabled>step</button>
276
- <span class="mono" id="${w}-count">0 turns</span>
277
- </div>
278
468
  <div class="chat">
279
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>
280
477
  <form class="chatask" id="${w}-chatform">
281
478
  <span class="prompt mono">tmct&gt;</span>
282
- <input id="${w}-chatq" type="text" placeholder="dig north" aria-label="talk to ${escapeHtml(id)}" disabled>
479
+ <input id="${w}-chatq" type="text" placeholder="look" aria-label="type a command" disabled>
283
480
  </form>
284
- <div class="chatpills" id="${w}-chatpills" role="group" aria-label="quick commands">
285
- <button type="button" class="pill" data-fill="look" disabled>look</button>
286
- <button type="button" class="pill" data-fill="what do you know about food" disabled>what do you know about food</button>
287
- <button type="button" class="pill" data-fill="dig north" disabled>dig north</button>
288
- <button type="button" class="pill" data-fill="dig down" disabled>dig down</button>
289
- </div>
290
481
  </div>
291
- </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>`;
292
488
  }
293
489
 
294
490
  const MUD_STYLE = `
295
491
  :root {
296
- --soil-deep: #2B1D14; --soil-mid: #4A3324; --soil-light: #7A5A3D;
297
- --root-moss: #6B7A4F; --parchment: #EFE6D8; --mud-ink: #2A211A; --burrow-glow: #E8A33D;
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;
298
501
  }
299
502
  html { background: var(--soil-deep); }
300
- body { margin: 0; background: linear-gradient(180deg, var(--root-moss) 0%, var(--soil-light) 18%, var(--soil-mid) 55%, var(--soil-deep) 100%) fixed; color: var(--mud-ink); font-family: ${SANS_STACK}; font-size: 15px; line-height: 1.5; }
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; }
301
504
  .mono { font-family: ${MONO_STACK}; }
302
- main { max-width: 1280px; margin: 0 auto; padding: 1.4rem 1.2rem 2.4rem; }
303
- .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .1em; text-transform: uppercase; color: var(--parchment); opacity: .85; }
304
- h1 { font-family: ${DISPLAY_STACK}; font-weight: 600; font-size: 1.7rem; margin: .25rem 0 1.1rem; color: var(--parchment); text-wrap: balance; }
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; }
305
507
  h2 { font-family: ${DISPLAY_STACK}; font-size: 1rem; margin: 0; text-transform: capitalize; }
306
- h3 { font-family: ${DISPLAY_STACK}; font-size: .78rem; margin: 0 0 .35rem; text-transform: uppercase; letter-spacing: .04em; color: var(--soil-mid); }
508
+ h3 { font-family: ${MONO_STACK}; font-size: .58rem; margin: 0 0 .3rem; text-transform: uppercase; letter-spacing: .12em; color: var(--soil-mid); }
307
509
  button { font: inherit; color: inherit; background: none; cursor: pointer; }
308
510
  button:focus-visible, input:focus-visible { outline: 2px solid var(--burrow-glow); outline-offset: 2px; }
309
- button:disabled { opacity: .45; cursor: default; }
511
+ button:disabled { opacity: .4; cursor: default; }
310
512
 
311
- .mud-stage {
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 {
312
516
  position: relative;
313
- display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: auto auto; gap: 1rem;
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;
314
520
  }
315
- @media (max-width: 760px) { .mud-stage { grid-template-columns: 1fr; } .world-map { position: static; margin: .5rem 0; transform: none; } }
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; } }
316
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
+ }
317
597
  .mud-window {
318
598
  background: var(--parchment); border: 1px solid var(--soil-mid); border-radius: 4px;
319
599
  box-shadow: 0 2px 0 rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.35);
320
- padding: .7rem .8rem; display: flex; flex-direction: column; gap: .5rem; min-width: 0;
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);
321
603
  }
322
- .mud-window-head { display: flex; align-items: baseline; justify-content: space-between; gap: .5rem; }
323
- .char-id { font-size: .68rem; color: var(--soil-mid); }
324
- .window-turn { font-size: .7rem; color: var(--soil-mid); }
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); }
325
610
 
326
- .room-view { position: relative; border-radius: 3px; overflow: hidden; border: 1px solid var(--soil-mid); }
327
- .room-view canvas { display: block; width: 100%; height: auto; }
328
- .sprite-layer { position: absolute; inset: 0; display: flex; align-items: flex-end; flex-wrap: wrap; gap: 2%; padding: 4%; box-sizing: border-box; }
329
- .sprite { width: 15%; min-width: 28px; transition: transform .2s ease; }
330
- .sprite.self { width: 20%; min-width: 34px; order: -1; }
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; }
331
631
  .sprite svg { width: 100%; display: block; }
332
- @media (prefers-reduced-motion: reduce) { .sprite { transition: none; } }
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; }
333
653
 
334
- .speech-bubble {
335
- position: absolute; top: 6%; left: 6%; max-width: 78%;
336
- background: var(--parchment); color: var(--mud-ink); border: 1px solid var(--soil-mid); border-radius: 8px;
337
- padding: .3rem .55rem; font-size: .72rem; line-height: 1.3; box-shadow: 0 2px 4px rgba(0,0,0,.25);
338
- opacity: 0; transition: opacity .35s ease;
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;
339
667
  }
340
- .speech-bubble.shown { opacity: 1; }
341
- @media (prefers-reduced-motion: reduce) { .speech-bubble { transition: none; } }
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); }
342
671
 
343
- .dig-flourish { position: absolute; inset: 0; pointer-events: none; opacity: 0; background: radial-gradient(circle at 50% 70%, var(--burrow-glow) 0%, transparent 60%); transition: opacity .5s ease; }
344
- .dig-flourish.shown { opacity: .55; }
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; }
345
686
  @media (prefers-reduced-motion: reduce) { .dig-flourish { transition: none; opacity: 0 !important; } }
346
687
 
347
- .room-caption { font-size: .8rem; margin: 0; color: var(--soil-mid); }
348
-
349
- .window-columns { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem; }
350
- .pouch, .minimap { background: rgba(255,255,255,.35); border: 1px solid var(--soil-light); border-radius: 3px; padding: .4rem .5rem; }
351
- .pouch-list { list-style: none; margin: 0; padding: 0; font-size: .74rem; display: flex; flex-direction: column; gap: .15rem; }
352
- .pouch-list:empty::after { content: "carrying nothing"; color: var(--soil-mid); font-style: italic; }
353
- .stat-line { margin: .3rem 0 0; font-size: .64rem; color: var(--soil-mid); }
354
- .minimap-rooms { display: flex; flex-wrap: wrap; gap: .25rem; font-family: ${MONO_STACK}; font-size: .62rem; }
355
- .minimap-room { padding: .12rem .35rem; background: var(--soil-light); color: var(--parchment); border-radius: 2px; }
356
- .minimap-room.current { background: var(--burrow-glow); color: var(--mud-ink); font-weight: 600; }
357
- .minimap-rooms:empty::after { content: "nothing dug yet"; color: var(--soil-mid); font-family: ${SANS_STACK}; font-size: .68rem; font-style: italic; }
358
-
359
- .window-controls button, .rail button { font-family: ${MONO_STACK}; font-size: .72rem; text-transform: uppercase; letter-spacing: .02em; padding: .3rem .6rem; border: 1px solid var(--soil-mid); border-radius: 3px; background: var(--parchment); }
360
- .window-controls button:hover:not(:disabled), .rail button:hover:not(:disabled) { border-color: var(--burrow-glow); }
361
- .window-controls { gap: .4rem; }
362
- .window-controls .mono { margin-left: auto; font-size: .7rem; }
363
-
364
- .chat { display: flex; flex-direction: column; gap: .35rem; }
365
- .chatlog { display: flex; flex-direction: column; gap: .3rem; max-height: 140px; overflow-y: auto; }
366
- .chatlog .u { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--soil-mid); }
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; }
367
735
  .chatlog .u::before { content: "tmct> "; color: var(--burrow-glow); }
368
- .chatlog .a { font-size: .76rem; line-height: 1.35; }
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; }
369
771
  .chatask { display: flex; align-items: center; gap: .4rem; }
370
- .chatask .prompt { color: var(--burrow-glow); font-size: .72rem; }
371
- .chatask input { flex: 1; min-width: 0; font-family: ${MONO_STACK}; font-size: .72rem; background: rgba(255,255,255,.6); color: var(--mud-ink); border: 1px solid var(--soil-mid); border-radius: 2px; padding: .28rem .45rem; box-sizing: border-box; }
372
- .chatpills { display: flex; flex-wrap: wrap; gap: .25rem; }
373
- .pill { font-family: ${MONO_STACK}; font-size: .62rem; padding: .15rem .5rem; border: 1px solid var(--soil-mid); border-radius: 99px; background: rgba(255,255,255,.5); }
374
- .pill:hover:not(:disabled) { border-color: var(--burrow-glow); }
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; }
375
790
 
376
- .world-map {
377
- position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 5;
378
- width: min(420px, 90%); background: var(--soil-deep); color: var(--parchment);
379
- border: 2px solid var(--burrow-glow); border-radius: 6px; padding: .5rem .6rem .7rem;
380
- box-shadow: 0 6px 24px rgba(0,0,0,.45);
381
- }
382
- .world-map-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: .35rem; }
383
- .world-map-title { font-family: ${DISPLAY_STACK}; font-size: .82rem; letter-spacing: .02em; }
384
- .world-map-turn { font-size: .66rem; opacity: .8; }
385
- .world-map-bands { display: flex; flex-direction: column; gap: 2px; }
386
- .map-band { display: flex; align-items: center; gap: .35rem; padding: .3rem .4rem; border-radius: 2px; }
387
- .map-band .band-level { font-family: ${MONO_STACK}; font-size: .6rem; width: 2.4em; opacity: .75; }
388
- .map-band .band-rooms { display: flex; flex-wrap: wrap; gap: .3rem; flex: 1; }
389
- .map-room { font-family: ${MONO_STACK}; font-size: .6rem; padding: .12rem .4rem; border-radius: 2px; background: rgba(239,230,216,.14); }
390
- .map-room .room-cast { margin-left: .25rem; opacity: .85; }
391
- .map-room.freshly-dug { animation: dig-pulse 1.1s ease-out; }
392
- @keyframes dig-pulse { 0% { box-shadow: 0 0 0 0 var(--burrow-glow); } 100% { box-shadow: 0 0 0 8px transparent; } }
393
- @media (prefers-reduced-motion: reduce) { .map-room.freshly-dug { animation: none; } }
394
-
395
- .rail { margin-top: 1.2rem; background: var(--parchment); border: 1px solid var(--soil-mid); border-radius: 4px; padding: .7rem .9rem; }
396
- .rail-row { display: flex; flex-wrap: wrap; align-items: center; gap: .7rem; }
397
- .rail-turns { font-size: .78rem; }
398
- .rail-slider { display: flex; align-items: center; gap: .35rem; font-size: .68rem; color: var(--soil-mid); }
399
- .rail-slider input[type="range"] { accent-color: var(--burrow-glow); }
400
- .rail-note { margin-top: .6rem; padding-top: .55rem; border-top: 1px solid var(--soil-light); font-size: .78rem; color: var(--soil-mid); }
401
- .rail-note p { margin: 0 0 .4rem; }
402
- .rail-note p:last-child { margin-bottom: 0; }
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
+ }
403
888
  `;
404
889
 
405
890
  /** The inlined page script, as a plain function body handed to
@@ -411,28 +896,40 @@ function pageScript() {
411
896
  "use strict";
412
897
  const DATA = MUD_PAGE_DATA;
413
898
  const createTicker = ${createTicker.toString()};
414
- const esc = ${escapeHtml.toString()};
899
+ const escapeHtml = ${escapeHtml.toString()};
900
+ const esc = escapeHtml;
901
+ const paneMarkup = ${paneMarkup.toString()};
415
902
  const speciesOfCharacter = ${speciesOfCharacter.toString()};
416
903
  const mudRoomSceneObjects = ${mudRoomSceneObjects.toString()};
417
904
  const carriedItemsFor = ${carriedItemsFor.toString()};
418
905
  const levelsOf = ${levelsOf.toString()};
419
- const levelBands = ${levelBands.toString()};
420
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()};
421
911
  const roomSceneObjects = ${roomSceneObjects.toString()};
422
912
  const scenePlacement = ${scenePlacement.toString()};
423
913
  const spriteClassForObject = ${spriteClassForObject.toString()};
424
914
  const spriteAncestryRows = ${spriteAncestryRows.toString()};
915
+ const factsForSubject = ${factsForSubject.toString()};
425
916
  const visibleRoomOf = ${visibleRoomOf.toString()};
426
917
  const roomKindForRoom = ${roomKindForRoom.toString()};
427
- const factsForSubject = ${factsForSubject.toString()};
918
+ const allRoomIds = ${allRoomIds.toString()};
919
+ const renderMudEditorText = ${renderMudEditorText.toString()};
920
+ const wordBeforeCursor = ${wordBeforeCursor.toString()};
428
921
 
429
922
  const el = (id) => document.getElementById(id);
430
- const characters = DATA.characters;
431
- const characterIds = characters.map((c) => c.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" };
432
929
  const reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
433
930
 
434
931
  // ---- the one shared tick queue -----------------------------------------
435
- // Every window's ticker calls into this instead of session.windows[...]
932
+ // Every pane's ticker calls into this instead of session.windows[...]
436
933
  // directly, so two characters' turns can never interleave their reads and
437
934
  // writes of the one shared memoryDir, and the global turn counter always
438
935
  // increments in real execution order.
@@ -444,170 +941,434 @@ function pageScript() {
444
941
  }
445
942
 
446
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 = {};
447
950
  let globalTurn = 0;
951
+ let turnsTaken = {};
952
+ let finished = {};
448
953
  let maxTurns = DATA.defaultMaxTurns;
449
954
  let delayMs = DATA.defaultDelayMs;
450
955
  const wait = function (ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); };
451
956
  const liveWait = function () { return wait(delayMs); };
452
957
 
453
- const knownRoomIds = new Set();
454
958
  let freshlyDugRoom = null;
455
- const speechBubbles = new Map(); // room -> { character, text, expiresAtTurn }
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 }]
456
963
 
457
- const tickers = {};
964
+ let tickers = {};
458
965
  let autoOn = false;
459
966
 
460
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
+ }
461
986
 
462
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;
463
992
  return serializeTick(async function () {
993
+ if (bootAt !== bootSeq || finished[character]) return { outOfPlay: true };
464
994
  globalTurn += 1;
465
995
  const result = await session.windows[character].autoplayTick(globalTurn);
996
+ if (!result.outOfPlay) turnsTaken[character] = (turnsTaken[character] || 0) + 1;
466
997
  afterEngineTurn(character, result);
467
998
  return result;
468
999
  });
469
1000
  }
470
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
+
471
1081
  function afterEngineTurn(character, result) {
472
- if (!result || !result.room) { renderAll(); return; }
473
- if (!knownRoomIds.has(result.room)) knownRoomIds.add(result.room);
474
- if (result.roomAfter && !knownRoomIds.has(result.roomAfter)) {
475
- freshlyDugRoom = result.roomAfter;
476
- knownRoomIds.add(result.roomAfter);
477
- }
478
- const ask = (result.actions || []).find(function (a) { return a.step === "investigate" && a.kind === "ask"; });
479
- if (ask) {
480
- speechBubbles.set(result.room, {
481
- character: character, text: character + " asks about food \\u2014 hears about " + ask.thing + ".",
482
- expiresAtTurn: globalTurn + 1,
483
- });
1082
+ if (result && result.outOfPlay) {
1083
+ markOutOfPlay(character, result.outOfPlayReason, result.text);
1084
+ renderSoon();
1085
+ return;
484
1086
  }
485
- renderAll();
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();
486
1097
  }
487
1098
 
488
1099
  function ensureTicker(character) {
489
1100
  if (tickers[character]) return tickers[character];
490
- const w = windowIdFor(character);
491
- const playBtn = el(w + "-play");
492
- const stepBtn = el(w + "-step");
493
- const ticker = createTicker({
1101
+ const playBtn = hasPane(character) ? el(paneIdFor(character) + "-play") : null;
1102
+ tickers[character] = createTicker({
494
1103
  onTick: function () { return runOneTurn(character); },
495
1104
  onRender: function (state) {
496
- playBtn.textContent = state.playing ? "\\u23F8 pause" : "\\u25B6 play";
1105
+ if (playBtn) playBtn.textContent = state.playing ? "\\u23F8 pause" : "\\u25B6 play";
497
1106
  },
498
1107
  hasNext: hasNext,
499
1108
  wait: liveWait,
500
1109
  });
501
- playBtn.addEventListener("click", function () { ticker.play(); });
502
- stepBtn.addEventListener("click", function () { ticker.stepOnce(); });
503
- tickers[character] = ticker;
504
- return ticker;
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;
505
1135
  }
506
1136
 
507
- function windowIdFor(character) {
508
- const c = characters.find(function (x) { return x.id === character; });
509
- return "window-" + c.slot;
1137
+ function openLogPopup(slot, text) {
1138
+ const w = "window-" + slot;
1139
+ el(w + "-logpop-text").textContent = text;
1140
+ el(w + "-logpop").hidden = false;
510
1141
  }
511
1142
 
512
- // ---- chat docks ---------------------------------------------------------
513
- function wireChat(character) {
514
- const w = windowIdFor(character);
515
- const form = el(w + "-chatform");
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;
516
1161
  const input = el(w + "-chatq");
517
- const log = el(w + "-chatlog");
518
- const pillsEl = el(w + "-chatpills");
519
- function append(cls, text) {
520
- const d = document.createElement("div");
521
- d.className = cls;
522
- d.textContent = text;
523
- log.appendChild(d);
524
- log.scrollTop = log.scrollHeight;
525
- }
526
- form.addEventListener("submit", function (e) {
1162
+ el(w + "-chatform").addEventListener("submit", function (e) {
527
1163
  e.preventDefault();
1164
+ const character = characterInSlot(slot);
528
1165
  const line = input.value.trim();
529
- if (!line) return;
1166
+ if (!character || !line) return;
530
1167
  input.value = "";
531
- append("u", line);
532
- serializeTick(function () { return session.windows[character].turn(line); }).then(function (res) {
533
- append("a", res.answer);
534
- renderAll();
535
- });
1168
+ sendCommand(character, line);
536
1169
  });
537
- for (const pill of pillsEl.querySelectorAll(".pill")) {
538
- pill.addEventListener("click", function () {
539
- input.value = pill.getAttribute("data-fill");
540
- input.focus();
541
- });
542
- }
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;
543
1219
  }
544
1220
 
545
1221
  // ---- room-view rendering -------------------------------------------------
546
- function roomKindTint(kind) {
547
- if (kind === "outdoor") return "var(--root-moss)";
548
- return "var(--soil-mid)";
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;
549
1226
  }
550
1227
 
551
- function spriteSvgFor(species, rows) {
1228
+ function spriteSvgFor(species, rows, instanceKey) {
552
1229
  if (window.tmctMud && window.tmctMud.resolveSpriteAsset) {
553
- return window.tmctMud.resolveSpriteAsset(species, rows, [], DATA.spriteTemplates, window.tmctMud.SPRITE_REGISTRY);
1230
+ return window.tmctMud.resolveSpriteAsset(species, rows, [], DATA.spriteTemplates, window.tmctMud.SPRITE_REGISTRY, { instanceKey: instanceKey });
554
1231
  }
555
1232
  return "";
556
1233
  }
557
1234
 
558
- function objectSvgFor(spriteClass, rows) {
559
- if (window.tmctMud && window.tmctMud.resolveSpriteForClass) {
560
- return window.tmctMud.resolveSpriteForClass(spriteClass, rows, window.tmctMud.SPRITE_REGISTRY);
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
+ );
561
1249
  }
562
1250
  return "";
563
1251
  }
564
1252
 
565
- function drawRoomBackdrop(canvas, kind) {
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) {
566
1258
  const ctx = canvas.getContext("2d");
567
1259
  const w = canvas.width, h = canvas.height;
568
1260
  ctx.clearRect(0, 0, w, h);
569
1261
  const grad = ctx.createLinearGradient(0, 0, 0, h);
570
1262
  if (kind === "outdoor") {
571
- grad.addColorStop(0, "#9BB077"); grad.addColorStop(1, "#6B7A4F");
1263
+ grad.addColorStop(0, "#A9BE83"); grad.addColorStop(0.42, "#7E9159"); grad.addColorStop(1, "#5C6B41");
572
1264
  } else {
573
- grad.addColorStop(0, "#5A4130"); grad.addColorStop(1, "#2B1D14");
1265
+ grad.addColorStop(0, "#63482F"); grad.addColorStop(0.55, "#42301F"); grad.addColorStop(1, "#241710");
574
1266
  }
575
1267
  ctx.fillStyle = grad;
576
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>";
577
1329
  }
578
1330
 
579
- function renderRoomView(character, rows, state, here) {
580
- const w = windowIdFor(character);
581
- const canvas = el(w + "-canvas");
582
- const kind = roomKindForRoom(rows, here);
583
- drawRoomBackdrop(canvas, kind);
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));
584
1335
 
585
- const layer = el(w + "-sprites");
586
- layer.innerHTML = "";
587
- const selfNode = document.createElement("div");
588
- selfNode.className = "sprite self";
589
- selfNode.innerHTML = spriteSvgFor(speciesOfCharacter(character), rows);
590
- layer.appendChild(selfNode);
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
+ );
591
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 = [];
592
1348
  for (const obj of mudRoomSceneObjects(rows, state, here, character)) {
593
- const node = document.createElement("div");
594
- const isCast = characterIds.indexOf(obj.subject) !== -1;
595
- node.className = "sprite" + (isCast ? " roommate" : "");
596
- node.innerHTML = isCast ? spriteSvgFor(speciesOfCharacter(obj.subject), rows) : objectSvgFor(obj.spriteClass, rows);
597
- node.title = obj.subject;
598
- layer.appendChild(node);
599
- }
600
-
601
- const bubbleEl = el(w + "-bubble");
602
- const bubble = speechBubbles.get(here);
603
- if (bubble && bubble.expiresAtTurn >= globalTurn) {
604
- bubbleEl.textContent = bubble.text;
605
- bubbleEl.hidden = false;
606
- bubbleEl.classList.add("shown");
607
- } else {
608
- bubbleEl.classList.remove("shown");
609
- bubbleEl.hidden = true;
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)));
610
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("");
611
1372
 
612
1373
  const flourishEl = el(w + "-flourish");
613
1374
  if (freshlyDugRoom && freshlyDugRoom === here && !reduceMotion) {
@@ -616,86 +1377,551 @@ function pageScript() {
616
1377
  setTimeout(function () { flourishEl.classList.remove("shown"); flourishEl.hidden = true; }, 900);
617
1378
  }
618
1379
 
619
- el(w + "-caption").textContent = roomCaptionFor(rows, state, here, character);
1380
+ el(w + "-caption").textContent = roomCaptionFor(rows, state, here, character, roomMates);
620
1381
  }
621
1382
 
622
- function roomCaptionFor(rows, state, here, character) {
623
- if (!window.tmctMud) return here;
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 = [];
624
1391
  const view = window.tmctMud.worldDigestRows(rows, state, character);
625
1392
  const lines = view.filter(function (r) { return r.subject.toLowerCase() === here.toLowerCase(); })
626
1393
  .map(function (r) { return r.subject + " " + r.predicate + " " + r.object + "."; });
627
- return lines.length ? lines.join(" ") : ("You are in the " + here + ".");
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
+ }
628
1476
  }
629
1477
 
630
- function renderPouch(character, rows, state) {
631
- const w = windowIdFor(character);
632
- const list = el(w + "-pouch-list");
1478
+ function renderPouch(character, rows, state, roomIds) {
1479
+ const w = paneIdFor(character);
633
1480
  const items = carriedItemsFor(rows, state, character);
634
- list.innerHTML = items.map(function (i) { return "<li>" + esc(i.subject) + "</li>"; }).join("");
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("");
635
1484
  const mass = state.masses.get(character);
636
- const cfgKey = speciesOfCharacter(character);
637
- const drain = DATA.mudConfig[cfgKey + "MassDecrementPerTurn"];
1485
+ const drain = DATA.mudConfig[speciesOfCharacter(character) + "MassDecrementPerTurn"];
638
1486
  el(w + "-stats").textContent = "mass " + (mass ? mass.value : "?") + (drain !== undefined ? " \\u00b7 drain/turn " + drain : "");
639
1487
  }
640
1488
 
641
- function renderMinimap(character, rows, state, here) {
642
- const w = windowIdFor(character);
643
- const mapEl = el(w + "-minimap");
644
- const visited = session.windows[character].visitedRoomIds();
645
- const levels = levelsOf(state, DATA.rootRoom);
646
- const myLevel = levels.get(here);
647
- const rooms = visited.filter(function (r) { return levels.get(r) === myLevel; }).sort();
648
- mapEl.innerHTML = rooms.map(function (r) {
649
- return "<span class=\\"minimap-room" + (r === here ? " current" : "") + "\\">" + esc(r) + "</span>";
650
- }).join("");
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);
651
1496
  }
652
1497
 
653
- function renderWorldMap(rows, state) {
654
- const levels = levelsOf(state, DATA.rootRoom);
655
- const bands = levelBands(levels);
656
- el("worldMapBands").innerHTML = bands.map(function (band) {
657
- const rooms = band.rooms.map(function (r) {
658
- const cast = charactersInRoom(state, r, characterIds);
659
- const castText = cast.length ? "<span class=\\"room-cast\\">" + cast.map(function (c) { return speciesOfCharacter(c); }).join(",") + "</span>" : "";
660
- const fresh = r === freshlyDugRoom ? " freshly-dug" : "";
661
- return "<span class=\\"map-room" + fresh + "\\">" + esc(r) + castText + "</span>";
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>";
662
1562
  }).join("");
663
- return "<div class=\\"map-band\\"><span class=\\"band-level\\">L" + band.level + "</span><span class=\\"band-rooms\\">" + rooms + "</span></div>";
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>";
664
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("");
665
1608
  el("worldMapTurn").textContent = "turn " + globalTurn;
666
1609
  }
667
1610
 
668
- function renderAll() {
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() {
669
1634
  if (!session) return;
670
- updateGlobalTurnLabel();
671
- session.snapshot().then(function (snap) {
672
- renderWorldMap(snap.rows, snap.state);
673
- for (const c of characters) {
674
- const here = snap.state.placements.get(c.id) ? snap.state.placements.get(c.id).object : null;
675
- const w = "window-" + c.slot;
676
- el(w + "-turn").textContent = "turn " + globalTurn;
677
- el(w + "-count").textContent = globalTurn + " turns";
678
- if (!here) continue;
679
- renderRoomView(c.id, snap.rows, snap.state, here);
680
- renderPouch(c.id, snap.rows, snap.state);
681
- renderMinimap(c.id, snap.rows, snap.state, here);
682
- }
683
- freshlyDugRoom = null;
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;
684
1681
  });
685
1682
  }
686
1683
 
687
- // ---- the rail -------------------------------------------------------------
688
- function wireRail() {
689
- const autoBtn = el("autoToggle");
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");
690
1912
  const delaySlider = el("delaySlider");
691
1913
  const maxTurnsSlider = el("maxTurnsSlider");
692
- const resetBtn = el("resetBtn");
693
- autoBtn.addEventListener("click", function () {
1914
+ const playerCountSlider = el("playerCountSlider");
1915
+ const npcCountSlider = el("npcCountSlider");
1916
+ playBtn.addEventListener("click", function () {
1917
+ if (!session) return;
694
1918
  autoOn = !autoOn;
695
- autoBtn.setAttribute("aria-pressed", autoOn ? "true" : "false");
696
- autoBtn.textContent = autoOn ? "\\u23F8 auto" : "\\u25B6 auto";
697
- for (const c of characters) {
698
- const ticker = tickers[c.id];
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];
699
1925
  if (!ticker) continue;
700
1926
  if (autoOn) ticker.play(); else ticker.pause();
701
1927
  }
@@ -708,45 +1934,122 @@ function pageScript() {
708
1934
  maxTurns = Number(maxTurnsSlider.value);
709
1935
  el("maxTurnsValue").textContent = String(maxTurns);
710
1936
  });
711
- resetBtn.addEventListener("click", function () { boot(); });
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
+ });
712
1968
  }
713
1969
 
714
- function updateGlobalTurnLabel() {
715
- el("globalTurnCount").textContent = "turns: " + globalTurn;
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);
716
1978
  }
717
1979
 
718
- async function boot() {
719
- for (const c of characters) {
720
- const ticker = tickers[c.id];
721
- if (ticker) ticker.pause();
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);
722
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 = {};
723
2007
  globalTurn = 0;
724
- knownRoomIds.clear();
725
2008
  freshlyDugRoom = null;
2009
+ lastSnapshot = null;
726
2010
  speechBubbles.clear();
727
2011
  tickChain = Promise.resolve();
728
- for (const c of characters) {
729
- const w = "window-" + c.slot;
730
- el(w + "-chatlog").innerHTML = "";
731
- }
732
- session = await window.tmctMud.createMudSession(DATA.worldPayload, { characters: characterIds });
733
- for (const c of characters) {
734
- const w = "window-" + c.slot;
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);
735
2041
  el(w + "-play").disabled = false;
736
2042
  el(w + "-step").disabled = false;
737
2043
  el(w + "-chatq").disabled = false;
738
- for (const pill of el(w + "-chatpills").querySelectorAll(".pill")) pill.disabled = false;
739
- ensureTicker(c.id);
740
- wireChat(c.id);
741
2044
  }
742
- updateGlobalTurnLabel();
743
- renderAll();
744
- // Default on load: only the NW window plays, the rest start paused.
745
- const nw = characters.find(function (c) { return c.slot === "nw"; });
746
- if (nw) tickers[nw.id].play();
2045
+ for (const character of everyone()) {
2046
+ turnsTaken[character] = 0;
2047
+ ensureTicker(character);
2048
+ }
2049
+ renderSoon();
747
2050
  }
748
2051
 
749
- wireRail();
2052
+ wireDeck();
750
2053
  boot();
751
2054
  })();`;
752
2055
  }