@polycode-projects/the-mechanical-code-talker 3.3.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +176 -8
  2. package/corpus/reference/index.json.gz +0 -0
  3. package/corpus/reference/manifest.json +8 -8
  4. package/corpus/reference/shards/ref-00.jsonl.gz +0 -0
  5. package/corpus/sprites/src/sprite-facts.jsonl +52 -0
  6. package/corpus/worlds/index.json.gz +0 -0
  7. package/corpus/worlds/manifest.json +53 -13
  8. package/corpus/worlds/shards/greyvale-museum.jsonl.gz +0 -0
  9. package/corpus/worlds/shards/lantern-cottage.jsonl.gz +0 -0
  10. package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
  11. package/corpus/worlds/shards/mud-hollow.jsonl.gz +0 -0
  12. package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
  13. package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
  14. package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
  15. package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
  16. package/corpus/worlds/src/mud-garden.jsonl +34 -1
  17. package/corpus/worlds/src/mud-hollow.jsonl +82 -0
  18. package/corpus/worlds/src/mud-warren.jsonl +124 -0
  19. package/corpus/worlds/src/spider-fly.jsonl +1 -1
  20. package/data/sprites/animal-icon.toml +11 -5
  21. package/data/sprites/book-icon.toml +9 -5
  22. package/data/sprites/cabinet-icon.toml +10 -5
  23. package/data/sprites/cellar-icon.toml +16 -6
  24. package/data/sprites/container-icon.toml +7 -3
  25. package/data/sprites/desk-icon.toml +8 -5
  26. package/data/sprites/dog-icon.toml +8 -5
  27. package/data/sprites/dog-with-colour-icon.toml +13 -12
  28. package/data/sprites/drawing-room-icon.toml +14 -5
  29. package/data/sprites/egg-icon.toml +6 -3
  30. package/data/sprites/fly-icon.toml +10 -5
  31. package/data/sprites/furniture-icon.toml +5 -3
  32. package/data/sprites/garden-icon.toml +10 -3
  33. package/data/sprites/key-icon.toml +3 -1
  34. package/data/sprites/kitchen-icon.toml +15 -6
  35. package/data/sprites/lamp-icon.toml +9 -4
  36. package/data/sprites/letter-icon.toml +7 -4
  37. package/data/sprites/library-icon.toml +14 -3
  38. package/data/sprites/pan-icon.toml +7 -3
  39. package/data/sprites/person-icon.toml +6 -2
  40. package/data/sprites/poodle-icon.toml +1 -1
  41. package/data/sprites/portable-icon.toml +6 -4
  42. package/data/sprites/portrait-icon.toml +7 -4
  43. package/data/sprites/room-icon.toml +11 -2
  44. package/data/sprites/spider-icon.toml +9 -3
  45. package/data/sprites/study-icon.toml +10 -2
  46. package/package.json +5 -4
  47. package/src/adapters/p2p/webrtc-transport.mjs +146 -0
  48. package/src/domain/game-config.mjs +26 -5
  49. package/src/domain/grammar/ace.mjs +40 -4
  50. package/src/domain/grammar/lexicon-core.json +2 -1
  51. package/src/domain/grammar/lexicon.mjs +31 -0
  52. package/src/domain/memory/trust.mjs +6 -4
  53. package/src/domain/p2p/facts.mjs +81 -0
  54. package/src/domain/p2p/peer-id.mjs +32 -0
  55. package/src/domain/p2p/provenance-relabel.mjs +26 -0
  56. package/src/domain/p2p/sync-filter.mjs +31 -0
  57. package/src/domain/p2p/wire.mjs +123 -0
  58. package/src/domain/reference-pack.mjs +31 -7
  59. package/src/domain/spider-fly-world.mjs +1 -1
  60. package/src/domain/sprite-map.mjs +10 -2
  61. package/src/domain/sprite-templates.mjs +8 -6
  62. package/src/services/adventure-editor.mjs +10 -2
  63. package/src/services/adventure-viz.mjs +309 -73
  64. package/src/services/adventure.mjs +1034 -330
  65. package/src/services/chat-page-viz.mjs +1097 -16
  66. package/src/services/chat.mjs +99 -18
  67. package/src/services/code-explorer-viz.mjs +52 -14
  68. package/src/services/extract-facts.mjs +4 -7
  69. package/src/services/ingest-viz.mjs +54 -26
  70. package/src/services/ledger-viz.mjs +105 -56
  71. package/src/services/memory-panel-viz.mjs +24 -0
  72. package/src/services/mud-editor.mjs +313 -0
  73. package/src/services/mud-turn.mjs +264 -87
  74. package/src/services/mud-viz.mjs +2606 -384
  75. package/src/services/p2p-room.mjs +559 -0
  76. package/src/services/plan-viz.mjs +119 -61
  77. package/src/services/research-viz.mjs +151 -57
  78. package/src/services/spider-fly-turn.mjs +1 -1
  79. package/src/services/spider-fly-viz.mjs +67 -43
  80. package/src/services/sprite-catalog-viz.mjs +175 -51
  81. package/src/services/viz-theme.mjs +15 -0
  82. package/src/surfaces/web/memory-ask-browser.bundle.js +109 -109
  83. package/src/surfaces/web/mud-browser-entry.mjs +226 -18
  84. package/src/surfaces/web/p2p-browser-entry.mjs +39 -0
@@ -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,55 @@
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.
48
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
65
+ // ONE ticker instance per pane (viz-ticker.mjs's createTicker), so
66
+ // each can play/step independently, plus the deck's own "play" control that
67
+ // calls .play()/.pause() on both pane tickers at once. Nothing plays until
68
+ // that control is clicked. Every tick, from ANY pane, is funneled through one
69
+ // shared async queue (serializeTick, spliced below) so two characters' turns
70
+ // can never interleave their reads/writes of the one shared memoryDir —
71
+ // mud-browser-entry.mjs's own header names this as the caller's
72
+ // responsibility, and this is where that responsibility is discharged. The
73
+ // queue is also what makes the deck's GLOBAL turn counter well-defined: it
74
+ // increments in the exact order turns actually executed, never a race
75
+ // between panes.
76
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, scenarioLabel } 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";
83
+ import {
84
+ wireStateLabel, nodeRowsFor, nodeInitials, inviteLinkFor, inviteParamsFrom,
85
+ } from "./chat-page-viz.mjs";
54
86
  import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
55
87
 
56
88
  const DEFAULT_TITLE = "tmct — the mud";
@@ -64,13 +96,24 @@ const DISPLAY_STACK = SERIF_STACK;
64
96
  const SANS_STACK = `"IBM Plex Sans", "Inter", -apple-system, BlinkMacSystemFont, sans-serif`;
65
97
 
66
98
  const ROOT_ROOM = "garden";
67
- const SLOTS = ["nw", "ne", "sw", "se"];
99
+ const PANE_SLOTS = ["a", "b", "c", "d"];
100
+ // The players slider's own detents. Every count here divides the two-column
101
+ // grid evenly, so no layout ever carries a half-empty row.
102
+ const PLAYER_COUNTS = [1, 2, 4];
103
+ const DEFAULT_PLAYER_COUNT = 2;
104
+ // The npcs slider is a plain count, not a detent list: nothing lays an NPC
105
+ // out, so every number between its ends is as good as any other.
106
+ const NPC_COUNT_MIN = 1;
107
+ const NPC_COUNT_MAX = 10;
108
+ const NPC_COUNT_LABELLED = [1, 5, 10];
109
+ const DEFAULT_NPC_COUNT = 2;
68
110
  const DEFAULT_DELAY_MS = 650;
69
111
  const DEFAULT_MAX_TURNS = 400;
70
112
 
71
113
  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.`,
114
+ "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.",
115
+ `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.`,
116
+ "Share the burrow and a second digger joins from another machine, browser to browser, with no server in the middle. Each animal is claimed by one node, one hand on one lemming, and everything either of you digs, eats or learns lands in the same graph.",
74
117
  ];
75
118
 
76
119
  /** A character id's species — "mole-1" -> "mole", "groundhog-1" ->
@@ -104,11 +147,10 @@ export function carriedItemsFor(rows, state, character) {
104
147
 
105
148
  /** Every room's depth level, `Map<roomId, number>`, BFS from `root` at level
106
149
  * 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. */
150
+ * keeps the level unchanged — what tells the survey where the turf line
151
+ * falls. A room unreachable from `root` (should not happen every dug room
152
+ * writes a two-way exit back) is simply absent from the map rather than
153
+ * guessed at. Pure, self-contained. */
112
154
  export function levelsOf(state, root = "garden") {
113
155
  const levels = new Map([[root, 0]]);
114
156
  const queue = [root];
@@ -125,65 +167,266 @@ export function levelsOf(state, root = "garden") {
125
167
  return levels;
126
168
  }
127
169
 
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.
170
+ /** Which characters currently stand in `room` the survey's own per-room
171
+ * roster, both for the omniscient board and a pane's own visited-only one.
131
172
  * 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
173
  export function charactersInRoom(state, room, characters) {
147
174
  return characters.filter((c) => state.placements.get(c)?.object === room);
148
175
  }
149
176
 
150
- function slugForSlot(slot) {
151
- return `window-${slot}`;
177
+ /** The rooms in `roomIds` laid out on an integer grid FROM the world's own
178
+ * has-exit-* directions — never a force-directed guess, so a room north of
179
+ * another sits one row above it and a room dug DOWN sits one row below.
180
+ * Adapted from adventure-viz.mjs's `visitedRoomGraph`, with two differences
181
+ * a burrow needs and a manor does not: a cell already taken nudges right
182
+ * rather than stacking two rooms on one square (dig down and dig south from
183
+ * the same room otherwise collide), and every node carries its own `level`
184
+ * so the renderer can draw the turf line between the surface and the soil.
185
+ * An edge is drawn only between two rooms BOTH in `roomIds`; `hints` names
186
+ * every exit from an included room toward one that is not — the direction
187
+ * only, never the excluded room's own name, so a fog-of-war caller can draw
188
+ * "there's a way on" and nothing more. Pure. */
189
+ export function burrowGraph(state, roomIds, root = "garden") {
190
+ const DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
191
+ const known = new Set(roomIds || []);
192
+ const positions = new Map();
193
+ const taken = new Set();
194
+ const edges = [];
195
+ const edgeKeys = new Set();
196
+ const hints = [];
197
+ const place = (room, x, y) => {
198
+ let px = x;
199
+ for (let guard = 0; taken.has(px + "," + y) && guard < 64; guard += 1) px += 1;
200
+ positions.set(room, { x: px, y });
201
+ taken.add(px + "," + y);
202
+ };
203
+ let offsetX = 0;
204
+ for (const start of [root, ...[...known].sort()]) {
205
+ if (!known.has(start) || positions.has(start)) continue;
206
+ place(start, offsetX, 0);
207
+ const queue = [start];
208
+ while (queue.length) {
209
+ const room = queue.shift();
210
+ const pos = positions.get(room);
211
+ const dirs = state.exits.get(room);
212
+ for (const direction of [...(dirs?.keys() ?? [])].sort()) {
213
+ const target = dirs.get(direction);
214
+ if (!known.has(target)) { hints.push({ from: room, direction }); continue; }
215
+ const key = [room, target].sort().join("\0");
216
+ if (!edgeKeys.has(key)) { edgeKeys.add(key); edges.push({ from: room, to: target, direction }); }
217
+ if (positions.has(target)) continue;
218
+ const d = DELTA[direction] ?? [0, 0];
219
+ place(target, pos.x + d[0], pos.y + d[1]);
220
+ queue.push(target);
221
+ }
222
+ }
223
+ offsetX = Math.max(...[...positions.values()].map((p) => p.x)) + 2;
224
+ }
225
+ const levels = levelsOf(state, root);
226
+ const xs = [...positions.values()].map((p) => p.x);
227
+ const ys = [...positions.values()].map((p) => p.y);
228
+ const minX = Math.min(0, ...xs);
229
+ const minY = Math.min(0, ...ys);
230
+ const nodes = [...known].filter((room) => positions.has(room)).sort().map((room) => {
231
+ const p = positions.get(room);
232
+ return { id: room, x: p.x - minX, y: p.y - minY, level: levels.has(room) ? levels.get(room) : null };
233
+ });
234
+ return { nodes, edges, hints };
235
+ }
236
+
237
+ /** The directions `here` can still be dug in: every direction the room's kind
238
+ * allows that has no written exit yet. Above ground there is nothing to
239
+ * tunnel sideways THROUGH, so a garden digs straight down and no other way;
240
+ * underground, all six are open soil. The engine is the authority on this —
241
+ * the page prefers `tmctMud.diggableDirections` whenever the bundle exposes
242
+ * one and only falls back to the rule below. Pure, self-contained
243
+ * (roomKindForRoom is spliced alongside it). */
244
+ export function diggableDirections(rows, state, here) {
245
+ const exits = state.exits.get(here);
246
+ const kind = roomKindForRoom(rows, here);
247
+ const allowed = kind === "outdoor" ? ["down"] : ["north", "south", "east", "west", "up", "down"];
248
+ return allowed.filter((direction) => !exits || !exits.has(direction));
249
+ }
250
+
251
+ /** A carried or loose object's display name. A dug object is minted as
252
+ * `<kind>-<the full nested room id it was dug from>` (adventure.mjs's
253
+ * freshRoomId), which reads as a path, not a thing — so a subject that ends
254
+ * in "-<a room this world actually has>" shows as its kind alone. Every
255
+ * other id (the world's hand-authored props: "carrot", "worm-1") is already
256
+ * a name and passes straight through. Pure. */
257
+ export function itemLabel(subject, roomIds) {
258
+ const id = String(subject);
259
+ for (const room of roomIds || []) {
260
+ const suffix = "-" + room;
261
+ if (id.length > suffix.length && id.endsWith(suffix)) return id.slice(0, -suffix.length);
262
+ }
263
+ return id;
264
+ }
265
+
266
+ /** Whether `subject` is a creature rather than a prop: the world places a
267
+ * character with `mgx:currently-in` and everything else with a containment
268
+ * predicate, the same split mud-turn.mjs's own "what is there to examine"
269
+ * filter reads. Pure — so the room view can put animals on the floor and
270
+ * props on the wall without a roster to check against, and a character the
271
+ * page is not itself driving (an NPC) still reads as an animal. */
272
+ export function isCreature(state, subject) {
273
+ return state.placements.get(subject)?.predicate === "mgx:currently-in";
274
+ }
275
+
276
+ // ---- the shared burrow: reading the P2P layer's own facts back ---------------
277
+ // All four readers below work on plain fact rows and nothing else, so the page
278
+ // draws a claim, a wave and a node name identically whether the world is shared
279
+ // with three machines or with none. Pure and `.toString()`-splice safe.
280
+
281
+ /** Every assertion time a provenance string carries, in milliseconds. A tag is
282
+ * `<kind>:<id>@<ISO>`, and a fact asserted more than once holds several joined
283
+ * by " | " — a peer's relabelled copy of a tag keeps the time the fact was
284
+ * actually asserted, so reading them all back is what lets a claim be settled
285
+ * by its oldest and a wave by its newest. Pure, self-contained. */
286
+ export function provenanceStamps(provenance) {
287
+ const stamps = [];
288
+ const text = String(provenance || "");
289
+ const pattern = /@(\d{4}-\d{2}-\d{2}T[0-9:.]+Z)/g;
290
+ let found = pattern.exec(text);
291
+ while (found !== null) {
292
+ const at = Date.parse(found[1]);
293
+ if (!Number.isNaN(at)) stamps.push(at);
294
+ found = pattern.exec(text);
295
+ }
296
+ return stamps;
297
+ }
298
+
299
+ /** A peer id as the fact store actually holds it. `appendFacts` normalizes a
300
+ * term by stripping its CURIE prefix and lowercasing, so the `peer:<id>` a
301
+ * claim is written with reads back as the bare id — a comparison against a
302
+ * raw peer id has to normalize the same way or it never matches. Pure,
303
+ * self-contained. */
304
+ export function peerTerm(peerId) {
305
+ return String(peerId || "").replace(/^[a-z][\w.-]*:/i, "").toLowerCase();
306
+ }
307
+
308
+ /** Which peer plays `character`, or null when nobody has claimed it. Claims are
309
+ * add-only and never retracted, so two peers claiming the same animal both
310
+ * write and every page settles it the same way: the OLDEST claim wins, with
311
+ * the lower peer term breaking a dead heat so two machines reading the same
312
+ * rows can never disagree. Pure, self-contained (provenanceStamps is spliced
313
+ * alongside it). */
314
+ export function claimantOf(rows, character) {
315
+ let owner = null;
316
+ let ownerAt = Infinity;
317
+ for (const row of rows || []) {
318
+ if (row.subject !== character || row.predicate !== "mgx:playedBy") continue;
319
+ let at = Infinity;
320
+ for (const stamp of provenanceStamps(row.provenance)) if (stamp < at) at = stamp;
321
+ if (at < ownerAt || (at === ownerAt && owner !== null && String(row.object) < owner)) {
322
+ ownerAt = at;
323
+ owner = String(row.object);
324
+ }
325
+ }
326
+ return owner;
327
+ }
328
+
329
+ /** Which characters are waving right now — a read-time recency question over
330
+ * the newest tag on each `mgx:waved` fact, never stored state and never a
331
+ * retraction. A wave older than the window simply stops being read as one.
332
+ * Pure, self-contained. */
333
+ export function wavingCharacters(rows, nowMs, windowMs = 8000) {
334
+ const waving = [];
335
+ for (const row of rows || []) {
336
+ if (row.predicate !== "mgx:waved") continue;
337
+ let newest = null;
338
+ for (const stamp of provenanceStamps(row.provenance)) if (newest === null || stamp > newest) newest = stamp;
339
+ if (newest === null) continue;
340
+ const age = nowMs - newest;
341
+ if (age < 0 || age > windowMs) continue;
342
+ if (waving.indexOf(row.subject) === -1) waving.push(row.subject);
343
+ }
344
+ return waving;
345
+ }
346
+
347
+ /** The offer blob inside whatever somebody actually pasted: the `offer`
348
+ * parameter of a whole invite link, or the bare blob when that is what
349
+ * arrived. Both are what people really paste, and telling them apart by
350
+ * looking is cheaper than asking them to. Pure, self-contained. */
351
+ export function offerBlobIn(pasted) {
352
+ const text = String(pasted || "").trim();
353
+ const marker = text.indexOf("offer=");
354
+ if (marker === -1) return text;
355
+ const rest = text.slice(marker + "offer=".length);
356
+ const end = rest.search(/[&#\s]/);
357
+ return decodeURIComponent(end === -1 ? rest : rest.slice(0, end));
358
+ }
359
+
360
+ /** The name a peer chose for its node, from its latest `mgx:nodeName` fact,
361
+ * falling back to a shortened peer id. A label never waits for a name to
362
+ * arrive. Pure, self-contained. */
363
+ export function nodeNameFor(rows, peerId) {
364
+ const want = peerTerm(peerId);
365
+ let name = null;
366
+ let nameAt = -Infinity;
367
+ for (const row of rows || []) {
368
+ if (row.predicate !== "mgx:nodeName" || peerTerm(row.subject) !== want) continue;
369
+ let at = -Infinity;
370
+ for (const stamp of provenanceStamps(row.provenance)) if (stamp > at) at = stamp;
371
+ if (at >= nameAt) { nameAt = at; name = String(row.object); }
372
+ }
373
+ return name || want.slice(0, 8);
152
374
  }
153
375
 
154
376
  /** The self-contained mud.html page. Pure — identical output for identical
155
377
  * input; every other piece of state (the live world, chat, ticks) is
156
378
  * 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.
379
+ * `characters` is the ROSTER this page may draw from, `[{ id, species }]`;
380
+ * the page ships the default cast's panes and rebuilds the whole stage at
381
+ * every boot, so how many of the roster play, and which of them, is decided
382
+ * live by the players slider and the engine's own pickMudRoster the same
383
+ * draw the shared session is then opened for, never a second one. A count
384
+ * the roster cannot fill draws as many panes as it has animals.
385
+ * `worldPayload` is `{ name, facts, rules, opening }`, read once at build
386
+ * time through the real worlds-pack provider (see this module's own
387
+ * header). `scenarios` is the list of burrows the deck's dropdown offers,
388
+ * `[{ label, worldPayload, characters }]` — each one a whole world with its
389
+ * own roster, so picking one recasts the page over that world's animals
390
+ * rather than reskinning this one. `worldPayload`/`characters` name the
391
+ * scenario the page opens with; a page given no `scenarios` is the
392
+ * one-burrow case and ships no dropdown at all. `spriteTemplates` is the
393
+ * large-tier sprite set (data/sprites-large/*.toml) so every species
394
+ * resolves its own art instead of falling back to the flat animal icon.
163
395
  * `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
396
+ * the per-species mass/speed/dig-reach reference table each pane's stat line
397
+ * reads for flavor. `engineBundleJs` inlines the built mud-browser bundle
398
+ * instead of the sibling `<script src>`, mirroring spider-fly-viz.mjs's own
399
+ * standalone-export knob; default empty keeps the site build's sibling-file
170
400
  * arrangement unchanged. */
171
401
  export function renderMudHtml({
172
402
  title = DEFAULT_TITLE,
173
403
  worldPayload,
174
404
  characters = [],
405
+ scenarios = [],
175
406
  spriteTemplates = [],
176
407
  mudConfig = DEFAULT_GAME_CONFIG.mud,
177
408
  engineBundleJs = "",
178
409
  } = {}) {
410
+ const scenarioList = scenarios.length
411
+ ? scenarios
412
+ : [{ label: scenarioLabel(worldPayload?.name), worldPayload, characters }];
413
+ const opening = scenarioList[0];
414
+ const openingCount = Math.max(
415
+ 1,
416
+ Math.min(DEFAULT_PLAYER_COUNT, (opening.characters || []).length || DEFAULT_PLAYER_COUNT),
417
+ );
179
418
  const pageData = embedJson({
180
- worldPayload, characters, spriteTemplates, mudConfig,
419
+ scenarios: scenarioList, spriteTemplates, mudConfig,
420
+ paneSlots: PANE_SLOTS,
421
+ playerCounts: PLAYER_COUNTS,
422
+ defaultPlayerCount: DEFAULT_PLAYER_COUNT,
423
+ defaultNpcCount: DEFAULT_NPC_COUNT,
181
424
  rootRoom: ROOT_ROOM,
182
425
  defaultDelayMs: DEFAULT_DELAY_MS,
183
426
  defaultMaxTurns: DEFAULT_MAX_TURNS,
184
427
  });
185
428
 
186
- const windowHtml = characters.map((c) => windowMarkup(c)).join("\n");
429
+ const paneHtml = PANE_SLOTS.slice(0, openingCount).map((slot) => paneMarkup(slot)).join("\n");
187
430
 
188
431
  return `<!doctype html>
189
432
  <html lang="en">
@@ -201,36 +444,145 @@ ${MUD_STYLE}
201
444
  </head>
202
445
  <body>
203
446
  <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">
447
+ <h1 class="eyebrow">tmct &middot; mud</h1>
448
+ <div class="deck-row">
449
+ <section class="deck" aria-label="simulation controls">
450
+ <div class="deck-controls">
451
+ <button type="button" class="deck-play" id="autoToggle" aria-pressed="false">&#9654; play</button>
452
+ <button type="button" id="resetBtn">reset</button>
453
+ ${scenarioList.length > 1 ? ` <select id="scenarioSelect" class="deck-select" aria-label="which burrow to play">
454
+ ${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " selected" : ""}>${escapeHtml(s.label || scenarioLabel(s.worldPayload?.name))}</option>`).join("\n")}
455
+ </select>` : ""}
456
+ <button type="button" id="editModeBtn" aria-pressed="false">edit</button>
457
+ <button type="button" id="shareBtn">share</button>
458
+ <button type="button" id="joinOpenBtn">join</button>
459
+ <button type="button" class="state-pill" id="statePill" aria-expanded="false" aria-controls="netPanel"
460
+ title="whether this burrow is shared with anyone"><i class="state-dot"></i><span id="statePillWord">not shared</span></button>
461
+ <button type="button" class="deck-info-btn" id="deckInfoBtn" aria-expanded="false" aria-controls="deckInfoPopup" aria-label="about this demo">?</button>
462
+ <span class="mono deck-turns" id="globalTurnCount">turns: 0</span>
463
+ </div>
464
+ <div class="deck-sliders">
465
+ <label class="deck-slider">players
466
+ <input type="range" id="playerCountSlider" min="0" max="${PLAYER_COUNTS.length - 1}" step="1"
467
+ value="${Math.max(0, PLAYER_COUNTS.indexOf(DEFAULT_PLAYER_COUNT))}"
468
+ list="playerCountTicks" aria-valuetext="${DEFAULT_PLAYER_COUNT} players">
469
+ <datalist id="playerCountTicks">${PLAYER_COUNTS.map((n, i) => `<option value="${i}" label="${n}"></option>`).join("")}</datalist>
470
+ <span class="mono" id="playerCountValue">${DEFAULT_PLAYER_COUNT}</span>
471
+ </label>
472
+ <label class="deck-slider">npcs
473
+ <input type="range" id="npcCountSlider" min="${NPC_COUNT_MIN}" max="${NPC_COUNT_MAX}" step="1"
474
+ value="${DEFAULT_NPC_COUNT}"
475
+ list="npcCountTicks" aria-valuetext="${DEFAULT_NPC_COUNT} npcs">
476
+ <datalist id="npcCountTicks">${Array.from({ length: NPC_COUNT_MAX - NPC_COUNT_MIN + 1 }, (_, i) => {
477
+ const n = NPC_COUNT_MIN + i;
478
+ return NPC_COUNT_LABELLED.includes(n) ? `<option value="${n}" label="${n}"></option>` : `<option value="${n}"></option>`;
479
+ }).join("")}</datalist>
480
+ <span class="mono" id="npcCountValue">${DEFAULT_NPC_COUNT}</span>
481
+ </label>
482
+ <label class="deck-slider">delay
483
+ <input type="range" id="delaySlider" min="80" max="2000" step="20" value="${DEFAULT_DELAY_MS}">
484
+ <span class="mono" id="delayValue">${DEFAULT_DELAY_MS}ms</span>
485
+ </label>
486
+ <label class="deck-slider">max turns
487
+ <input type="range" id="maxTurnsSlider" min="20" max="2000" step="20" value="${DEFAULT_MAX_TURNS}">
488
+ <span class="mono" id="maxTurnsValue">${DEFAULT_MAX_TURNS}</span>
489
+ </label>
490
+ </div>
491
+ <div class="deck-info-popup mud-note" id="deckInfoPopup" role="dialog" aria-label="about this demo" hidden>
492
+ ${MUD_NOTE_LINES.map((line) => `<p>${escapeHtml(line)}</p>`).join("\n ")}
493
+ <button type="button" class="deck-info-popup-close" id="deckInfoClose" aria-label="close">&times;</button>
494
+ </div>
495
+ </section>
496
+ <section class="world-map" id="worldMap" aria-label="the whole burrow, every room, every character">
209
497
  <div class="world-map-head">
210
498
  <span class="world-map-title">the whole burrow</span>
211
499
  <span class="mono world-map-turn" id="worldMapTurn">turn 0</span>
212
500
  </div>
213
- <div class="world-map-bands" id="worldMapBands"></div>
214
- </div>
501
+ <div class="world-map-board" id="worldMapBoard"></div>
502
+ <div class="world-map-key" id="worldMapKey"></div>
503
+ </section>
215
504
  </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>
505
+ <section class="net-panel" id="netPanel" aria-label="the shared burrow" hidden>
506
+ <div class="net-head">
507
+ <span class="net-title">the shared burrow</span>
508
+ <span class="net-state" id="wireState" data-tone="idle"><b id="wireStateWord">not shared</b><span id="wireStateNote">this browser holds the only copy of this burrow.</span></span>
509
+ <button type="button" class="net-close" id="netPanelClose" aria-label="close">&times;</button>
510
+ </div>
511
+ <div class="net-fields">
512
+ <label class="net-field">this node
513
+ <input type="text" id="nodeNameInput" spellcheck="false" aria-label="the name this node goes by">
223
514
  </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>
515
+ <label class="net-field">this burrow
516
+ <input type="text" id="worldNameInput" spellcheck="false" aria-label="the name of the shared burrow">
227
517
  </label>
228
- <button type="button" id="resetBtn">reset</button>
229
518
  </div>
230
- <div class="rail-note mud-note">
231
- <p>${escapeHtml(MUD_NOTE_LINES[0])}</p>
232
- <p>${escapeHtml(MUD_NOTE_LINES[1])}</p>
519
+ <div class="net-block" id="sharePanel" hidden>
520
+ <p class="net-hint">Each link invites one person. Share again for the next.</p>
521
+ <textarea id="shareLink" readonly rows="2" aria-label="the invite link"></textarea>
522
+ </div>
523
+ <div class="net-block" id="invitePanel" hidden>
524
+ <label class="net-label" for="inviteBox">paste the invite you were sent</label>
525
+ <textarea id="inviteBox" rows="2" spellcheck="false"></textarea>
526
+ <button type="button" id="inviteBtn">make my reply</button>
527
+ <p class="net-problem" id="inviteProblem" role="alert" hidden></p>
233
528
  </div>
529
+ <div class="net-block" id="replyPanel">
530
+ <label class="net-label" for="replyBox">paste their reply here</label>
531
+ <textarea id="replyBox" rows="2" spellcheck="false"></textarea>
532
+ <button type="button" id="replyBtn">connect</button>
533
+ <p class="net-problem" id="replyProblem" role="alert" hidden></p>
534
+ </div>
535
+ <div class="net-block" id="answerPanel" hidden>
536
+ <label class="net-label" for="replyOut">send this back the same way the invite reached you</label>
537
+ <textarea id="replyOut" readonly rows="2"></textarea>
538
+ <button type="button" id="copyReplyBtn">copy the reply</button>
539
+ </div>
540
+ <div class="net-block">
541
+ <div class="net-label">nodes <span class="mono" id="nodeCount"></span></div>
542
+ <ul class="node-list" id="nodeList"></ul>
543
+ <p class="net-hint" id="nodeEmpty">Nobody else is digging here yet.</p>
544
+ </div>
545
+ </section>
546
+ <div class="join-card" id="joinCard" hidden>
547
+ <div class="join-inner" role="dialog" aria-label="an invitation to a shared burrow">
548
+ <p class="join-eyebrow" id="joinEyebrow">you have been invited to dig in</p>
549
+ <h2 class="join-world" id="joinWorld">a shared burrow</h2>
550
+ <p class="join-body" id="joinBody">Nothing runs until you press the button. It makes one reply for you to send back the way the invite reached you.</p>
551
+ <button type="button" id="joinBtn">create my reply</button>
552
+ <p class="net-problem" id="joinProblem" role="alert" hidden></p>
553
+ <div class="join-reply" id="joinReplyWrap" hidden>
554
+ <textarea id="joinReply" readonly rows="2" aria-label="your reply"></textarea>
555
+ <button type="button" id="joinCopyBtn">copy the reply</button>
556
+ </div>
557
+ <button type="button" class="join-dismiss" id="joinDismiss">dig on your own instead</button>
558
+ </div>
559
+ </div>
560
+ <div class="mud-stage" id="mudStage" data-panes="${openingCount}">
561
+ ${paneHtml}
562
+ </div>
563
+ <div class="edit-stage" id="mudEditStage" aria-label="the burrow's own facts, in plain sentences">
564
+ <section class="edit-text" aria-label="the world's facts as editable sentences">
565
+ <h2>the burrow, in plain sentences</h2>
566
+ <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>
567
+ <textarea id="editorText" spellcheck="false" aria-label="the world's own facts as plain sentences, one per line"></textarea>
568
+ <div class="chatpills" id="editorPills" aria-label="related words for the term before the cursor"></div>
569
+ <p class="edit-status" id="editorStatus" role="status"></p>
570
+ </section>
571
+ <aside class="edit-side" aria-label="the burrow as written, a clicked room, and the legend">
572
+ <section class="world-map edit-map" aria-label="every room this world writes">
573
+ <div class="world-map-head"><span class="world-map-title">the burrow as written</span></div>
574
+ <div class="world-map-board" id="editMapBoard"></div>
575
+ </section>
576
+ <section class="edit-panel" id="roomDetailPanel">
577
+ <h2 id="roomDetailTitle">click a room</h2>
578
+ <div class="room-detail-cast" id="roomDetailCast"></div>
579
+ <p class="edit-caption" id="roomDetailCaption">Click a room on the survey to see what stands in it.</p>
580
+ </section>
581
+ <section class="edit-panel">
582
+ <h2>legend</h2>
583
+ <div class="edit-legend" id="editLegend"></div>
584
+ </section>
585
+ </aside>
234
586
  </div>
235
587
  </main>
236
588
  <script>
@@ -245,161 +597,615 @@ ${embedScriptText(pageScript())}
245
597
  `;
246
598
  }
247
599
 
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>
600
+ /** One pane, character-agnostic: which animal it shows is stamped in at boot
601
+ * (`data-character`, the heading, the chat dock's own labels), so the page
602
+ * can draw a different cast out of the roster on every reset without a
603
+ * rebuild. Pure and self-contained (escapeHtml is spliced alongside it) —
604
+ * the page script calls this same function to build the stage in the
605
+ * browser, so the panes a count change adds are the panes this file ships. */
606
+ export function paneMarkup(slot) {
607
+ const w = `window-${slot}`;
608
+ return ` <section class="mud-window pane-${escapeHtml(slot)}" id="${w}" data-slot="${escapeHtml(slot)}" data-character="">
609
+ <div class="pane-head">
610
+ <h2 id="${w}-name">waiting</h2>
611
+ <span class="pane-node" id="${w}-node" hidden></span>
612
+ <span class="mono pane-turn" id="${w}-turn">turn 0</span>
254
613
  </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>
614
+ <div class="room-stage">
615
+ <div class="room-view" id="${w}-room">
616
+ <canvas id="${w}-canvas" width="420" height="152" aria-hidden="true"></canvas>
617
+ <div class="wall-band" id="${w}-wall"></div>
618
+ <div class="floor-band">
619
+ <div class="floor-others" id="${w}-others"></div>
620
+ <div class="floor-self" id="${w}-self"></div>
621
+ </div>
622
+ <div class="bubbles" id="${w}-bubbles"></div>
623
+ <div class="dig-flourish" id="${w}-flourish" hidden></div>
624
+ <div class="strike-flash" id="${w}-strike" aria-hidden="true"></div>
625
+ </div>
626
+ <div class="dir-ring" id="${w}-dirs" aria-label="ways out of this room">
627
+ <div class="dir-slot dir-north" id="${w}-dir-north"></div>
628
+ <div class="dir-slot dir-up" id="${w}-dir-up"></div>
629
+ <div class="dir-slot dir-west" id="${w}-dir-west"></div>
630
+ <div class="dir-slot dir-east" id="${w}-dir-east"></div>
631
+ <div class="dir-slot dir-south" id="${w}-dir-south"></div>
632
+ <div class="dir-slot dir-down" id="${w}-dir-down"></div>
633
+ </div>
260
634
  </div>
261
635
  <p class="room-caption" id="${w}-caption"></p>
262
- <div class="window-columns">
263
- <div class="pouch" id="${w}-pouch" aria-label="pouch">
636
+ <div class="pane-columns">
637
+ <div class="pouch" aria-label="pouch">
264
638
  <h3>pouch</h3>
265
639
  <ul class="pouch-list" id="${w}-pouch-list"></ul>
266
640
  <p class="mono stat-line" id="${w}-stats"></p>
267
641
  </div>
268
- <div class="minimap" aria-label="what this character has discovered">
642
+ <div class="minimap" aria-label="what this character has dug or walked">
269
643
  <h3>known ground</h3>
270
- <div class="minimap-rooms" id="${w}-minimap"></div>
644
+ <div class="minimap-board" id="${w}-minimap"></div>
271
645
  </div>
272
646
  </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
647
  <div class="chat">
279
648
  <div class="chatlog" id="${w}-chatlog" aria-live="polite"></div>
649
+ <div class="log-popup" id="${w}-logpop" role="dialog" aria-label="the whole reply" hidden>
650
+ <p class="log-popup-text" id="${w}-logpop-text"></p>
651
+ <button type="button" class="log-popup-close" id="${w}-logpop-close" aria-label="close the whole reply">&times;</button>
652
+ </div>
653
+ </div>
654
+ <div class="chat-console">
655
+ <div class="pill-strip">
656
+ <button type="button" class="pill wave-btn" id="${w}-wave" title="wave, so everyone in this room sees it"
657
+ aria-label="wave">&#128075; wave</button>
658
+ <div class="chatpills" id="${w}-chatpills" role="group" aria-label="quick commands"></div>
659
+ </div>
280
660
  <form class="chatask" id="${w}-chatform">
281
661
  <span class="prompt mono">tmct&gt;</span>
282
- <input id="${w}-chatq" type="text" placeholder="dig north" aria-label="talk to ${escapeHtml(id)}" disabled>
662
+ <input id="${w}-chatq" type="text" placeholder="look" aria-label="type a command" disabled>
283
663
  </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
664
  </div>
291
- </div>`;
665
+ <div class="pane-controls">
666
+ <button type="button" id="${w}-play" disabled>&#9654; play</button>
667
+ <button type="button" id="${w}-step" disabled>step</button>
668
+ <p class="pane-fate" id="${w}-fate" role="status" hidden></p>
669
+ </div>
670
+ </section>`;
292
671
  }
293
672
 
294
673
  const MUD_STYLE = `
295
674
  :root {
296
- --soil-deep: #2B1D14; --soil-mid: #4A3324; --soil-light: #7A5A3D;
297
- --root-moss: #6B7A4F; --parchment: #EFE6D8; --mud-ink: #2A211A; --burrow-glow: #E8A33D;
675
+ --soil-deep: #241710; --soil-mid: #4A3324; --soil-light: #7A5A3D;
676
+ --root-moss: #6B7A4F; --parchment: #EFE6D8; --mud-ink: #2A211A;
677
+ --burrow-glow: #E8A33D; --chalk: #D9CDB9;
678
+ /* Four hues that stay apart at the size an occupant dot is drawn, and all
679
+ four sit on the dark soil board as well as on parchment. Nothing green
680
+ enough to sink into a turf-filled surface room. */
681
+ --actor-a: #E0912A; --actor-b: #5F97B3; --actor-c: #BFC85F; --actor-d: #AE749E;
682
+ --pane-height: 618px;
683
+ --room-height: 152px;
298
684
  }
299
685
  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; }
686
+ 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
687
  .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; }
688
+ main { max-width: 1280px; margin: 0 auto; padding: 1.1rem 1.2rem 2.4rem; }
689
+ .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
690
  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); }
691
+ h3 { font-family: ${MONO_STACK}; font-size: .58rem; margin: 0 0 .3rem; text-transform: uppercase; letter-spacing: .12em; color: var(--soil-mid); }
307
692
  button { font: inherit; color: inherit; background: none; cursor: pointer; }
308
693
  button:focus-visible, input:focus-visible { outline: 2px solid var(--burrow-glow); outline-offset: 2px; }
309
- button:disabled { opacity: .45; cursor: default; }
694
+ button:disabled { opacity: .4; cursor: default; }
310
695
 
311
- .mud-stage {
696
+ /* ---- top row: the control deck, and the survey beside it ---- */
697
+ .deck-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: stretch; margin-bottom: 1rem; }
698
+ .deck {
312
699
  position: relative;
313
- display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: auto auto; gap: 1rem;
700
+ background: var(--parchment); border: 1px solid var(--soil-mid); border-radius: 4px;
701
+ box-shadow: 0 2px 0 rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.35);
702
+ padding: .8rem .9rem; display: flex; flex-direction: column; gap: .55rem; min-width: 0;
703
+ }
704
+ .deck-controls { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
705
+ .deck-info-btn {
706
+ font-family: ${MONO_STACK}; font-size: .78rem; line-height: 1; width: 1.5rem; height: 1.5rem;
707
+ border-radius: 50%; border: 1px solid var(--soil-mid); background: rgba(255,255,255,.5);
708
+ color: var(--soil-mid); padding: 0; flex: 0 0 auto;
709
+ }
710
+ .deck-info-btn:hover, .deck-info-btn[aria-expanded="true"] { border-color: var(--burrow-glow); color: var(--mud-ink); }
711
+ .deck button, .pane-controls button {
712
+ font-family: ${MONO_STACK}; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em;
713
+ padding: .32rem .7rem; border: 1px solid var(--soil-mid); border-radius: 3px; background: rgba(255,255,255,.5);
714
+ }
715
+ .deck button:hover:not(:disabled), .pane-controls button:hover:not(:disabled) { border-color: var(--burrow-glow); }
716
+ .deck-select {
717
+ font-family: ${MONO_STACK}; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em;
718
+ padding: .32rem .7rem; border: 1px solid var(--soil-mid); border-radius: 3px;
719
+ background: rgba(255,255,255,.5); color: var(--mud-ink);
314
720
  }
315
- @media (max-width: 760px) { .mud-stage { grid-template-columns: 1fr; } .world-map { position: static; margin: .5rem 0; transform: none; } }
721
+ .deck-select:hover { border-color: var(--burrow-glow); }
722
+ .deck-play { background: var(--mud-ink) !important; color: var(--parchment); border-color: var(--mud-ink) !important; padding: .38rem 1.1rem !important; }
723
+ .deck-play[aria-pressed="true"] { background: var(--burrow-glow) !important; border-color: var(--burrow-glow) !important; color: var(--mud-ink); }
724
+ .deck-turns { margin-left: auto; font-size: .74rem; color: var(--soil-mid); }
725
+ .deck-sliders { display: flex; flex-wrap: wrap; gap: 1rem; }
726
+ .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); }
727
+ .deck-slider input[type="range"] { accent-color: var(--burrow-glow); width: 8rem; max-width: 34vw; }
728
+ .deck-info-popup {
729
+ position: absolute; left: .9rem; right: .9rem; top: calc(100% + 8px); z-index: 8;
730
+ background: var(--soil-deep); color: var(--parchment);
731
+ border: 1px solid var(--burrow-glow); border-radius: 4px;
732
+ padding: .55rem 1.6rem .6rem .65rem; box-shadow: 0 8px 18px rgba(0,0,0,.5);
733
+ animation: popup-rise .16s ease-out;
734
+ }
735
+ .deck-info-popup::before {
736
+ content: ""; position: absolute; left: 1.1rem; top: -6px; width: 0; height: 0;
737
+ border-left: 6px solid transparent; border-right: 6px solid transparent; border-bottom: 6px solid var(--burrow-glow);
738
+ }
739
+ .deck-info-popup p { margin: 0 0 .4rem; max-width: 62ch; font-size: .78rem; line-height: 1.4; }
740
+ .deck-info-popup p:last-child { margin-bottom: 0; }
741
+ .deck-info-popup-close { position: absolute; top: .1rem; right: .3rem; font-size: 1rem; line-height: 1; color: var(--parchment); padding: .1rem .2rem; }
742
+ .deck-info-popup-close:hover { color: var(--burrow-glow); }
743
+ @media (prefers-reduced-motion: reduce) { .deck-info-popup { animation: none; } }
744
+
745
+ .world-map {
746
+ background: var(--soil-deep); color: var(--parchment);
747
+ border: 1px solid var(--burrow-glow); border-radius: 4px; padding: .55rem .65rem .6rem;
748
+ box-shadow: 0 2px 0 rgba(0,0,0,.3); display: flex; flex-direction: column; gap: .4rem; min-width: 0;
749
+ }
750
+ .world-map-head { display: flex; justify-content: space-between; align-items: baseline; gap: .5rem; }
751
+ .world-map-title { font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em; opacity: .85; }
752
+ .world-map-turn { font-size: .62rem; opacity: .7; }
753
+ .world-map-board { flex: 1; min-height: 168px; display: flex; align-items: center; justify-content: center; }
754
+ .world-map-board svg { width: 100%; height: 100%; max-height: 230px; }
755
+ .world-map-key { display: flex; flex-wrap: wrap; gap: .5rem; font-family: ${MONO_STACK}; font-size: .58rem; opacity: .85; }
756
+ .key-actor, .key-npcs { display: inline-flex; align-items: center; gap: .28rem; }
757
+ .key-npcs { opacity: .75; }
758
+ .key-dot { width: .5rem; height: .5rem; border-radius: 50%; display: inline-block; }
759
+
760
+ /* ---- the survey itself, drawn twice at two sizes ---- */
761
+ .burrow svg { display: block; }
762
+ .burrow .turf { fill: var(--root-moss); opacity: .3; }
763
+ .burrow .ground-line { stroke: var(--root-moss); stroke-width: 1.2; stroke-dasharray: 5 3; opacity: .9; }
764
+ .burrow .tunnel { stroke: var(--soil-light); stroke-width: 5.5; stroke-linecap: round; opacity: .85; }
765
+ .burrow .tunnel.shaft { stroke-dasharray: 3 3.5; stroke-width: 4; }
766
+ .burrow .hint { fill: var(--chalk); opacity: .45; }
767
+ .burrow .room rect { fill: var(--soil-mid); stroke: var(--chalk); stroke-width: .8; }
768
+ .burrow .room.surface rect { fill: var(--root-moss); }
769
+ .burrow .room text { fill: var(--parchment); font-family: ${MONO_STACK}; font-size: 7px; text-anchor: middle; }
770
+ .burrow .room.freshly-dug rect { stroke: var(--burrow-glow); stroke-width: 1.8; animation: dig-pulse 1.2s ease-out; }
771
+ .burrow .occupant { stroke: rgba(0,0,0,.45); stroke-width: .6; }
772
+ @keyframes dig-pulse { 0% { opacity: .25; } 100% { opacity: 1; } }
773
+ @media (prefers-reduced-motion: reduce) { .burrow .room.freshly-dug rect { animation: none; } }
316
774
 
775
+ /* ---- the panes ----
776
+ Two columns hold every count the players slider offers: four fills them
777
+ twice over, two fills them once. One is the exception, and it earns it —
778
+ a lone animal takes a single centred column at a reading measure and
779
+ spends the width it saves on a taller room and a longer log, so the page
780
+ reads as one portrait rather than one pane marooned in an empty grid. */
781
+ .mud-stage { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; align-items: start; }
782
+ .mud-stage[data-panes="1"] {
783
+ grid-template-columns: minmax(0, 46rem); justify-content: center;
784
+ --pane-height: 700px; --room-height: 196px;
785
+ }
317
786
  .mud-window {
318
787
  background: var(--parchment); border: 1px solid var(--soil-mid); border-radius: 4px;
319
788
  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;
789
+ padding: .7rem .8rem; display: flex; flex-direction: column; gap: .5rem;
790
+ min-width: 0; height: var(--pane-height); box-sizing: border-box; overflow: hidden;
791
+ border-top: 3px solid var(--actor-a);
321
792
  }
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); }
793
+ .mud-window.pane-b { border-top-color: var(--actor-b); }
794
+ .mud-window.pane-c { border-top-color: var(--actor-c); }
795
+ .mud-window.pane-d { border-top-color: var(--actor-d); }
796
+ .pane-head { display: flex; align-items: baseline; justify-content: space-between; gap: .5rem; }
797
+ .char-id { font-size: .66rem; color: var(--soil-mid); margin-left: .35rem; text-transform: none; }
798
+ .pane-turn { font-size: .68rem; color: var(--soil-mid); }
325
799
 
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; }
800
+ /* ---- room view: one row of soil, the compass around it ---- */
801
+ .room-stage { position: relative; padding: 19px 34px; flex: 0 0 auto; }
802
+ .room-view { position: relative; height: var(--room-height); border-radius: 3px; overflow: hidden; border: 1px solid var(--soil-mid); }
803
+ .room-view canvas { display: block; width: 100%; height: 100%; }
804
+ .wall-band { position: absolute; left: 0; right: 0; top: 4px; height: 62px; display: flex; align-items: flex-start; justify-content: center; gap: .5rem; }
805
+ .wall-item { display: flex; flex-direction: column; align-items: center; min-width: 34px; max-width: 72px; }
806
+ .wall-item .hook { width: 1px; height: 5px; background: rgba(0,0,0,.45); }
807
+ .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); }
808
+ .wall-item.hangs-high { margin-top: 0; }
809
+ .wall-item.hangs-low { margin-top: 14px; }
810
+ .wall-item svg { width: 100%; display: block; }
811
+ .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; }
812
+ /* A room can hold a dozen animals once the npcs slider is up, and the floor
813
+ is one row wide either way — so the crowd shares the row it has instead of
814
+ the surplus running off the edge under the overflow clip. --mate-width is
815
+ set per redraw from the room's own measured width. */
816
+ .floor-others { display: flex; align-items: flex-end; gap: 6px; min-width: 0; }
817
+ .floor-others .sprite-card { max-width: var(--mate-width, 76px); }
818
+ .floor-others .sprite { width: min(40px, var(--mate-width, 40px)); }
819
+ .sprite { width: 40px; }
331
820
  .sprite svg { width: 100%; display: block; }
332
- @media (prefers-reduced-motion: reduce) { .sprite { transition: none; } }
821
+ .floor-self .sprite { width: 48px; }
822
+
823
+ /* ---- sprite cards: adventure.html's own frame-plus-nameplate treatment,
824
+ shrunk to the one soil row a mud pane has for it. The plate names the
825
+ real subject (mole-1, carrot), never a class word. ---- */
826
+ .sprite-card { display: flex; flex-direction: column; align-items: center; max-width: 76px; }
827
+ .sprite-label {
828
+ margin-top: 2px; max-width: 100%; box-sizing: border-box;
829
+ font-family: ${MONO_STACK}; font-size: .5rem; line-height: 1.25; letter-spacing: .01em; text-align: center;
830
+ color: var(--mud-ink); background: rgba(239,230,216,.92); border: 1px solid var(--soil-mid); border-radius: 2px;
831
+ padding: 0 .22rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
832
+ }
833
+ .sprite-mass {
834
+ margin-top: 1px; font-family: ${MONO_STACK}; font-size: .46rem; line-height: 1.3; letter-spacing: .02em;
835
+ color: var(--parchment); background: rgba(36,23,16,.72); border-radius: 2px; padding: 0 .2rem; white-space: nowrap;
836
+ }
837
+ .sprite-card.actionable { cursor: pointer; }
838
+ .sprite-card.actionable:hover .sprite-frame, .sprite-card.actionable:focus-visible .sprite-frame { border-color: var(--burrow-glow); }
839
+ .sprite-card.actionable:hover .sprite-label, .sprite-card.actionable:focus-visible .sprite-label { border-color: var(--burrow-glow); background: var(--burrow-glow); }
840
+ .sprite-card.actionable:focus-visible { outline: 2px solid var(--burrow-glow); outline-offset: 1px; }
841
+ .wall-band .sprite-card.actionable { pointer-events: auto; }
842
+
843
+ /* ---- the compass ring: each way out sits where it points ---- */
844
+ .dir-ring { position: absolute; inset: 0; pointer-events: none; }
845
+ .dir-slot { position: absolute; pointer-events: auto; }
846
+ .dir-north { top: 0; left: 50%; transform: translateX(-102%); }
847
+ .dir-up { top: 0; left: 50%; transform: translateX(6px); }
848
+ .dir-south { bottom: 0; left: 50%; transform: translateX(-102%); }
849
+ .dir-down { bottom: 0; left: 50%; transform: translateX(6px); }
850
+ .dir-west { left: 0; top: 50%; transform: translateY(-50%); }
851
+ .dir-east { right: 0; top: 50%; transform: translateY(-50%); }
852
+ .dir-pill {
853
+ font-family: ${MONO_STACK}; font-size: .58rem; letter-spacing: .06em; line-height: 1;
854
+ padding: .22rem .42rem; border-radius: 2px; border: 1px solid var(--soil-mid);
855
+ background: var(--parchment); color: var(--mud-ink); white-space: nowrap;
856
+ }
857
+ .dir-pill.dig { border-style: dashed; border-color: var(--burrow-glow); color: var(--soil-mid); background: rgba(232,163,61,.14); }
858
+ .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; }
859
+ .dir-pill:hover:not(:disabled) { border-color: var(--burrow-glow); background: var(--burrow-glow); color: var(--mud-ink); }
333
860
 
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;
861
+ .bubbles { position: absolute; inset: 0; pointer-events: none; }
862
+ .bubble {
863
+ position: absolute; max-width: 46%;
864
+ background: var(--parchment); color: var(--mud-ink); border: 1px solid var(--soil-mid); border-radius: 7px;
865
+ padding: .22rem .45rem; font-size: .66rem; line-height: 1.25; box-shadow: 0 2px 4px rgba(0,0,0,.3);
339
866
  }
340
- .speech-bubble.shown { opacity: 1; }
341
- @media (prefers-reduced-motion: reduce) { .speech-bubble { transition: none; } }
867
+ .bubble.from-self { right: 4%; bottom: 52%; }
868
+ .bubble.from-other { left: 4%; bottom: 52%; }
869
+ .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); }
870
+ .bubble.from-self::after { right: 12%; }
871
+ .bubble.from-other::after { left: 12%; }
342
872
 
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; }
873
+ .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; }
874
+ .dig-flourish.shown { opacity: .5; }
345
875
  @media (prefers-reduced-motion: reduce) { .dig-flourish { transition: none; opacity: 0 !important; } }
346
876
 
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); }
877
+ /* Two lines clamped, and a box tall enough to hold both of them: at 1.5
878
+ line-height that is 3em, and anything shorter cuts the second line's
879
+ descenders off. */
880
+ .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; }
881
+
882
+ .pane-columns { flex: 0 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: .5rem; height: 86px; }
883
+ .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; }
884
+ .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; }
885
+ .pouch-list li { background: var(--soil-light); color: var(--parchment); border-radius: 2px; padding: .06rem .3rem; }
886
+ .pouch-list:empty::after { content: "carrying nothing"; color: var(--soil-mid); font-family: ${SANS_STACK}; font-style: italic; font-size: .68rem; }
887
+ .stat-line { margin: .25rem 0 0; font-size: .58rem; color: var(--soil-mid); }
888
+ .minimap-board { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; }
889
+ .minimap-board svg { width: 100%; height: 100%; max-height: 56px; }
890
+ .minimap-board:empty::after { content: "nothing dug yet"; color: var(--soil-mid); font-style: italic; font-size: .68rem; }
891
+ .minimap .burrow .room text { fill: var(--parchment); font-size: 6px; }
892
+ .minimap .burrow .room.here rect { fill: var(--burrow-glow); }
893
+ .minimap .burrow .room.here text { fill: var(--mud-ink); }
894
+
895
+ /* The log is the ONLY thing in a pane allowed to grow, and it grows inward:
896
+ flex-basis 0 keeps its own content out of the pane's height arithmetic, so
897
+ a hundred turns of narration scroll inside it instead of pushing the
898
+ command console off the bottom of a fixed-height pane. */
899
+ .chat { position: relative; flex: 1 1 0; min-height: 2.6rem; display: flex; flex-direction: column; }
900
+ /* Block flow, deliberately, not a flex column: a scrolled log's own lines
901
+ must keep their natural height, and flex items inside a height-capped
902
+ column shrink toward zero instead. */
903
+ .chatlog {
904
+ flex: 1 1 0; min-height: 0; display: block;
905
+ overflow-y: scroll; overscroll-behavior: contain; padding-right: .25rem;
906
+ }
907
+ .chatlog > * { margin: 0 0 .28rem; }
908
+ .chatlog > *:last-child { margin-bottom: 0; }
909
+ /* The log carries typed exchanges only — a scripted turn narrates through
910
+ the room view — so an untouched pane's log says what would fill it. */
911
+ .chatlog:empty::after { content: "Type a command below and the answer lands here."; color: var(--soil-mid); font-style: italic; font-size: .72rem; }
912
+ /* A styled webkit scrollbar reserves its own gutter, where the overlay one
913
+ Chromium defaults to on macOS shows nothing until you already scroll. The
914
+ standard properties go behind @supports because setting them at all makes
915
+ Blink ignore the pseudo-elements and fall back to that overlay bar. */
916
+ .chatlog::-webkit-scrollbar { width: 9px; }
917
+ .chatlog::-webkit-scrollbar-track { background: rgba(122,90,61,.18); border-radius: 5px; }
918
+ .chatlog::-webkit-scrollbar-thumb { background: var(--soil-light); border-radius: 5px; border: 2px solid var(--parchment); }
919
+ .chatlog::-webkit-scrollbar-thumb:hover { background: var(--soil-mid); }
920
+ @supports not selector(::-webkit-scrollbar) {
921
+ .chatlog { scrollbar-width: thin; scrollbar-color: var(--soil-light) rgba(122,90,61,.18); }
922
+ }
923
+ .chatlog .u { font-family: ${MONO_STACK}; font-size: .66rem; color: var(--soil-mid); overflow-wrap: anywhere; }
367
924
  .chatlog .u::before { content: "tmct> "; color: var(--burrow-glow); }
368
- .chatlog .a { font-size: .76rem; line-height: 1.35; }
925
+ /* A reply is capped at four rendered lines. A capped one says so and opens the
926
+ whole text in the popup above, so no single answer can take the log over.
927
+ The unclamped variant exists only to be measured against: a line-clamped box
928
+ reports its clamped height as its scrollHeight. */
929
+ .chatlog .a { font-size: .74rem; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
930
+ .chatlog .a.unclamped { display: block; -webkit-line-clamp: none; overflow: visible; }
931
+ .chatlog .clipped { position: relative; cursor: pointer; padding-right: 2.4rem; }
932
+ .chatlog .clipped::after {
933
+ content: "more"; position: absolute; right: 0; bottom: 0;
934
+ font-family: ${MONO_STACK}; font-size: .5rem; letter-spacing: .06em; text-transform: uppercase;
935
+ color: var(--mud-ink); background: var(--burrow-glow); border-radius: 99px; padding: 0 .32rem;
936
+ }
937
+ .chatlog .clipped:hover::after, .chatlog .clipped:focus-visible::after { background: var(--mud-ink); color: var(--parchment); }
938
+ .chatlog .clipped:focus-visible { outline: 2px solid var(--burrow-glow); outline-offset: 1px; }
939
+
940
+ .log-popup {
941
+ position: absolute; left: 0; right: 0; bottom: calc(100% - 1.4rem); z-index: 8;
942
+ background: var(--soil-deep); color: var(--parchment);
943
+ border: 1px solid var(--burrow-glow); border-radius: 4px;
944
+ padding: .45rem 1.5rem .5rem .55rem; box-shadow: 0 8px 18px rgba(0,0,0,.5);
945
+ max-height: 13rem; overflow-y: auto; animation: popup-rise .16s ease-out;
946
+ }
947
+ .log-popup::after {
948
+ content: ""; position: absolute; left: 1.4rem; bottom: -6px; width: 0; height: 0;
949
+ border-left: 6px solid transparent; border-right: 6px solid transparent; border-top: 6px solid var(--burrow-glow);
950
+ }
951
+ .log-popup-text { margin: 0; font-size: .74rem; line-height: 1.4; }
952
+ .log-popup-close { position: absolute; top: .1rem; right: .25rem; font-size: 1rem; line-height: 1; color: var(--parchment); padding: .1rem .2rem; }
953
+ .log-popup-close:hover { color: var(--burrow-glow); }
954
+ @keyframes popup-rise { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } }
955
+ @media (prefers-reduced-motion: reduce) { .log-popup { animation: none; } }
956
+
957
+ /* The console never moves: pills and prompt sit outside the log's own flex
958
+ track, so no amount of narration can reach them. */
959
+ .chat-console { flex: 0 0 auto; display: flex; flex-direction: column; gap: .3rem; margin-top: .3rem; }
369
960
  .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); }
961
+ .chatask .prompt { color: var(--burrow-glow); font-size: .7rem; }
962
+ .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; }
963
+ .chatpills { display: flex; flex-wrap: wrap; gap: .22rem; max-height: 3.9rem; overflow-y: auto; scrollbar-width: thin; }
964
+ .chatpills:empty { display: none; }
965
+ .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; }
966
+ .pill:hover:not(:disabled) { border-color: var(--burrow-glow); background: var(--burrow-glow); }
967
+ .pill.affordance { border-style: dashed; border-color: var(--soil-light); }
968
+ .pill.way { border-style: solid; border-color: var(--soil-mid); background: rgba(232,163,61,.16); }
969
+ .pane-controls { flex: 0 0 auto; display: flex; align-items: center; gap: .4rem; }
970
+ .pane-fate {
971
+ margin: 0; font-family: ${MONO_STACK}; font-size: .62rem; text-transform: uppercase; letter-spacing: .08em;
972
+ padding: .3rem .6rem; border-radius: 3px; background: var(--soil-deep); color: var(--parchment);
973
+ }
974
+ .mud-window.out-of-play { border-top-color: var(--soil-mid); }
975
+ .mud-window.out-of-play .pane-controls button { display: none; }
976
+ .room-view, .pane-columns { transition: filter .4s ease, opacity .4s ease; }
977
+ .mud-window.out-of-play .room-view, .mud-window.out-of-play .pane-columns { filter: grayscale(1); opacity: .5; }
978
+ .mud-window.out-of-play .dir-ring { display: none; }
375
979
 
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; }
980
+ /* ---- the pounce: the one violent beat on a page that otherwise only fades.
981
+ Every other motion here eases out; this one accelerates into the prey and
982
+ stops dead. It plays once, on the tick a character is first out of play,
983
+ and the pane grays out underneath it when it lands. ---- */
984
+ .strike-flash {
985
+ position: absolute; inset: 0; pointer-events: none; opacity: 0;
986
+ background: radial-gradient(circle at 76% 72%, rgba(158,32,16,.95) 0%, rgba(36,23,16,.62) 40%, transparent 72%);
987
+ }
988
+ @keyframes strike-flash { 0%, 42% { opacity: 0; } 50% { opacity: .92; } 100% { opacity: 0; } }
989
+ @keyframes fox-pounce {
990
+ 0% { transform: translateX(0) scale(1); }
991
+ 16% { transform: translateX(-7px) scale(.94); }
992
+ 54% { transform: translateX(var(--pounce-x, 180px)) scale(1.48); }
993
+ 100% { transform: translateX(var(--pounce-x, 180px)) scale(1.44); }
994
+ }
995
+ @keyframes prey-taken {
996
+ 0%, 40% { transform: none; opacity: 1; }
997
+ 47% { transform: translateX(-6px) rotate(-8deg); }
998
+ 54% { transform: translateX(6px) rotate(7deg); }
999
+ 61% { transform: translateX(-4px) rotate(-4deg); }
1000
+ 100% { transform: translateX(3px) rotate(4deg) scale(.82); opacity: 0; }
1001
+ }
1002
+ .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; }
1003
+ .room-view.pounce .floor-self { transform-origin: 50% 100%; animation: prey-taken 1.05s cubic-bezier(.4,0,.2,1) forwards; }
1004
+ .room-view.pounce .strike-flash { animation: strike-flash 1.05s ease-out forwards; }
1005
+ .room-view.pounce .bubbles, .room-view.pounce .wall-band { opacity: .35; }
1006
+ @keyframes fate-drop { from { opacity: 0; transform: translateY(-7px); } to { opacity: 1; transform: none; } }
1007
+ .pane-fate { animation: fate-drop .3s ease-out; }
1008
+ @media (prefers-reduced-motion: reduce) {
1009
+ .room-view, .pane-columns { transition: none; }
1010
+ .pane-fate { animation: none; }
1011
+ .room-view.pounce .sprite-card.lunging, .room-view.pounce .floor-self, .room-view.pounce .strike-flash { animation: none; }
1012
+ }
1013
+
1014
+ /* ---- edit mode ----
1015
+ One page, two stages. Editing hides the panes AND the deck's own live
1016
+ survey: the survey in here is drawn from the edit buffer, and two boards
1017
+ showing two different burrows side by side would just be a puzzle. The deck
1018
+ itself stays, because the way back out is a button on it. */
1019
+ .edit-stage { display: none; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 1rem; align-items: start; }
1020
+ body.editing .mud-stage, body.editing .deck-row .world-map { display: none; }
1021
+ body.editing .deck-row { grid-template-columns: 1fr; }
1022
+ body.editing .edit-stage { display: grid; }
1023
+ #editModeBtn[aria-pressed="true"] { background: var(--burrow-glow); border-color: var(--burrow-glow); }
1024
+
1025
+ .edit-text, .edit-panel {
1026
+ background: var(--parchment); border: 1px solid var(--soil-mid); border-radius: 4px;
1027
+ box-shadow: 0 2px 0 rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.35);
1028
+ padding: .7rem .8rem; min-width: 0;
1029
+ }
1030
+ .edit-text { display: flex; flex-direction: column; gap: .45rem; }
1031
+ .edit-text h2, .edit-panel h2 {
1032
+ font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em;
1033
+ color: var(--soil-mid); margin: 0;
1034
+ }
1035
+ .edit-lede { margin: 0; font-size: .74rem; color: var(--soil-mid); max-width: 62ch; }
1036
+ #editorText {
1037
+ width: 100%; box-sizing: border-box; min-height: 26rem; flex: 1 1 auto; resize: vertical;
1038
+ font-family: ${MONO_STACK}; font-size: .74rem; line-height: 1.6;
1039
+ color: var(--mud-ink); background: rgba(255,255,255,.6);
1040
+ border: 1px solid var(--soil-mid); border-radius: 3px; padding: .55rem .6rem;
1041
+ }
1042
+ /* Three states, three colours, and the middle one is the important one: a
1043
+ document that does not yet parse is holding its retractions back, and has to
1044
+ look different from one that synced clean. */
1045
+ .edit-status { margin: 0; font-family: ${MONO_STACK}; font-size: .62rem; min-height: 1.2em; color: var(--soil-mid); }
1046
+ .edit-status.pending { color: #9A5B12; }
1047
+ .edit-status.ok { color: #4E6B2E; }
1048
+
1049
+ .edit-side { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
1050
+ .edit-map .world-map-board { min-height: 190px; }
1051
+ .edit-map .burrow .room { cursor: pointer; }
1052
+ .edit-map .burrow .room.here rect { stroke: var(--burrow-glow); stroke-width: 1.8; }
1053
+ .edit-panel { display: flex; flex-direction: column; gap: .35rem; }
1054
+ .edit-caption { margin: 0; font-size: .74rem; color: var(--soil-mid); }
1055
+ .room-detail-cast, .edit-legend { display: flex; flex-wrap: wrap; gap: .4rem; }
1056
+ .edit-legend .sprite-card, .room-detail-cast .sprite-card { max-width: 58px; }
1057
+ .edit-legend .sprite-frame, .room-detail-cast .sprite-frame {
1058
+ width: 34px; padding: 2px; box-sizing: border-box; background: rgba(255,255,255,.6);
1059
+ border: 1px solid var(--soil-mid); border-radius: 2px;
1060
+ }
1061
+ .edit-legend .sprite-frame svg, .room-detail-cast .sprite-frame svg { width: 100%; display: block; }
1062
+ .edit-empty { font-size: .74rem; font-style: italic; color: var(--soil-mid); }
1063
+
1064
+ /* Narrow, every pane stacks and every count reads the same — including the
1065
+ single-pane one, whose centred column and extra height only buy anything
1066
+ when there is width to spare. */
1067
+ @media (max-width: 900px) {
1068
+ .deck-row { grid-template-columns: 1fr; }
1069
+ .mud-stage, .mud-stage[data-panes="1"] {
1070
+ grid-template-columns: 1fr; justify-content: stretch;
1071
+ --pane-height: 596px; --room-height: 152px;
1072
+ }
1073
+ .world-map-board { min-height: 150px; }
1074
+ .edit-stage { grid-template-columns: 1fr; }
1075
+ #editorText { min-height: 18rem; }
1076
+ }
1077
+
1078
+ /* ---- the control panel, in a Lemmings register, on the burrow's own palette ----
1079
+ The 1991 game put everything a player touches in one chunky beveled strip
1080
+ under the level. This deck keeps that shape — one bordered block, a play
1081
+ button that reads as the "go" key, a lit corner readout — but in the same
1082
+ soil/parchment/burrow-glow palette every other panel on the page already
1083
+ uses, so it reads as this world's own control panel rather than a
1084
+ different game's console dropped on top of it. Everything below overrides
1085
+ the parchment deck above it deliberately, so the panel is one block a
1086
+ reader can find rather than a dozen edits. */
1087
+ :root {
1088
+ --lem-face: var(--chalk); --lem-face-hi: #FBF7EF; --lem-face-lo: var(--soil-light);
1089
+ --lem-go: var(--mud-ink); --lem-go-hi: var(--burrow-glow);
1090
+ --lem-alert: #A5432B;
1091
+ --lem-readout: var(--burrow-glow); --lem-readout-bg: var(--soil-deep);
1092
+ --lem-chalk: var(--soil-mid);
1093
+ }
1094
+ .deck-slider, .deck h3 { color: var(--lem-chalk); }
1095
+ .deck-slider input[type="range"] { accent-color: var(--burrow-glow); }
1096
+ .deck button, .pane-controls button, .net-panel button, .join-inner button {
1097
+ background: var(--lem-face); color: var(--mud-ink); border: 1px solid var(--soil-mid); border-radius: 3px;
1098
+ padding: .32rem .7rem; box-shadow: 0 1px 0 rgba(0,0,0,.12);
1099
+ }
1100
+ .deck button:hover:not(:disabled), .pane-controls button:hover:not(:disabled),
1101
+ .net-panel button:hover:not(:disabled), .join-inner button:hover:not(:disabled) {
1102
+ background: var(--lem-face-hi); border-color: var(--burrow-glow);
1103
+ }
1104
+ .deck button:active:not(:disabled), .pane-controls button:active:not(:disabled),
1105
+ .net-panel button:active:not(:disabled), .join-inner button:active:not(:disabled) {
1106
+ background: var(--lem-face-lo); box-shadow: inset 0 1px 2px rgba(0,0,0,.25);
1107
+ }
1108
+ .deck-play { background: var(--lem-go) !important; color: var(--parchment) !important; border-color: var(--lem-go) !important; }
1109
+ .deck-play[aria-pressed="true"] { background: var(--lem-alert) !important; border-color: var(--lem-alert) !important; color: var(--parchment) !important; }
1110
+ /* The corner readout: a lit panel display, in the burrow's own amber rather than DOS-console green. */
1111
+ .deck-turns { background: var(--lem-readout-bg); color: var(--lem-readout);
1112
+ border: 1px solid var(--soil-mid); border-radius: 2px; padding: .1rem .5rem; }
1113
+ .deck-info-btn { background: var(--lem-face); color: var(--mud-ink); border: 1px solid var(--soil-mid); }
1114
+ .deck-info-btn:hover, .deck-info-btn[aria-expanded="true"] { background: var(--lem-face-hi); border-color: var(--burrow-glow); color: var(--mud-ink); }
1115
+ #editModeBtn[aria-pressed="true"] { background: var(--burrow-glow); border-color: var(--burrow-glow); color: var(--mud-ink); }
1116
+
1117
+ /* ---- whether the burrow is shared, said in one word on the panel ---- */
1118
+ .state-pill { display: inline-flex; align-items: center; gap: .34rem;
1119
+ font-family: ${MONO_STACK}; font-size: .68rem; text-transform: uppercase; letter-spacing: .08em; }
1120
+ .state-dot { width: .46rem; height: .46rem; border-radius: 50%; background: var(--lem-face-lo); flex: 0 0 auto; }
1121
+ .state-pill[data-tone="waiting"] .state-dot { background: #E0912A; }
1122
+ .state-pill[data-tone="working"] .state-dot { background: #E0912A; }
1123
+ .state-pill[data-tone="live"] .state-dot { background: var(--lem-go); }
1124
+ .state-pill[data-tone="failed"] .state-dot { background: var(--lem-alert); }
1125
+
1126
+ .net-panel {
1127
+ background: var(--soil-deep); color: var(--parchment);
1128
+ border: 1px solid var(--burrow-glow); border-radius: 3px;
1129
+ box-shadow: 0 2px 0 rgba(0,0,0,.3);
1130
+ padding: .7rem .8rem; margin-bottom: 1rem;
1131
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: .6rem .9rem; align-items: start;
1132
+ }
1133
+ .net-head { grid-column: 1 / -1; display: flex; align-items: baseline; gap: .6rem; flex-wrap: wrap; }
1134
+ .net-title { font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em; opacity: .85; }
1135
+ .net-state { font-size: .74rem; display: flex; gap: .4rem; flex-wrap: wrap; align-items: baseline; }
1136
+ .net-state b { font-family: ${MONO_STACK}; font-size: .66rem; text-transform: uppercase; letter-spacing: .08em; }
1137
+ .net-state[data-tone="live"] b { color: var(--lem-go-hi); }
1138
+ .net-state[data-tone="failed"] b { color: #FF8C70; }
1139
+ .net-state[data-tone="waiting"] b, .net-state[data-tone="working"] b { color: #F0C070; }
1140
+ .net-state span { opacity: .8; }
1141
+ .net-close { margin-left: auto; }
1142
+ .net-fields, .net-block { display: flex; flex-direction: column; gap: .3rem; min-width: 0; }
1143
+ .net-fields { grid-column: 1 / -1; flex-direction: row; flex-wrap: wrap; gap: .8rem; }
1144
+ .net-field, .net-label { font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .1em; opacity: .85; }
1145
+ .net-field { display: flex; flex-direction: column; gap: .2rem; }
1146
+ .net-panel input[type="text"], .net-panel textarea {
1147
+ font-family: ${MONO_STACK}; font-size: .68rem; color: var(--lem-readout); background: var(--lem-readout-bg);
1148
+ border: 1px solid #000; border-radius: 2px; padding: .26rem .4rem; width: 100%; box-sizing: border-box;
1149
+ resize: vertical; overflow-wrap: anywhere;
1150
+ }
1151
+ .net-panel input[readonly], .net-panel textarea[readonly] { opacity: .85; }
1152
+ .net-panel button { align-self: flex-start; font-family: ${MONO_STACK}; font-size: .66rem;
1153
+ text-transform: uppercase; letter-spacing: .08em; }
1154
+ .net-hint { margin: 0; font-size: .7rem; opacity: .75; }
1155
+ .net-problem { margin: 0; font-size: .72rem; color: #FFB4A0; }
1156
+ .node-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .22rem; }
1157
+ .node-row { display: flex; align-items: center; gap: .4rem; font-size: .74rem; }
1158
+ .node-row[data-away="true"] { opacity: .55; }
1159
+ .node-avatar { width: 1.25rem; height: 1.25rem; border-radius: 50%; background: var(--lem-face);
1160
+ color: #16162A; font-family: ${MONO_STACK}; font-size: .52rem; display: inline-flex;
1161
+ align-items: center; justify-content: center; flex: 0 0 auto; }
1162
+ .node-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1163
+ .node-self { font-family: ${MONO_STACK}; font-size: .54rem; text-transform: uppercase; letter-spacing: .08em; opacity: .6; }
1164
+ .node-hand { opacity: 0; }
1165
+ .node-row[data-waving="true"] .node-hand { opacity: 1; animation: hand-wave .8s ease-in-out infinite; }
1166
+ .node-when { margin-left: auto; font-family: ${MONO_STACK}; font-size: .58rem; opacity: .6; }
1167
+
1168
+ .join-card { position: fixed; inset: 0; z-index: 40; display: flex; align-items: center; justify-content: center;
1169
+ background: rgba(8,8,42,.86); padding: 1rem; }
1170
+ /* Setting display at all outranks the browser's own [hidden] rule, so every
1171
+ block here that is hidden by attribute has to say so a second time. */
1172
+ .join-card[hidden], .net-panel[hidden], .net-block[hidden], .join-reply[hidden] { display: none; }
1173
+ .join-inner { background: var(--soil-deep);
1174
+ color: var(--parchment); border: 1px solid var(--burrow-glow); border-radius: 3px;
1175
+ box-shadow: 0 10px 26px rgba(0,0,0,.5);
1176
+ padding: 1.1rem 1.2rem; max-width: 32rem; width: 100%; display: flex; flex-direction: column; gap: .55rem; }
1177
+ .join-eyebrow { margin: 0; font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em; opacity: .8; }
1178
+ .join-world { font-family: ${DISPLAY_STACK}; font-size: 1.4rem; margin: 0; }
1179
+ .join-body { margin: 0; font-size: .8rem; opacity: .9; max-width: 52ch; }
1180
+ .join-reply { display: flex; flex-direction: column; gap: .3rem; }
1181
+ .join-inner textarea { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--lem-readout);
1182
+ background: var(--lem-readout-bg); border: 1px solid #000; border-radius: 2px; padding: .3rem .4rem;
1183
+ width: 100%; box-sizing: border-box; overflow-wrap: anywhere; }
1184
+ .join-inner button { align-self: flex-start; font-family: ${MONO_STACK}; font-size: .68rem;
1185
+ text-transform: uppercase; letter-spacing: .08em; }
1186
+ .join-dismiss { background: transparent !important; box-shadow: none !important; color: #C6C6E8 !important;
1187
+ text-decoration: underline; padding-left: 0 !important; }
1188
+
1189
+ /* ---- a wave, and the node a character is played from ---- */
1190
+ .pill-strip { display: flex; align-items: flex-start; gap: .22rem; min-width: 0; }
1191
+ .pill-strip .chatpills { flex: 1 1 auto; min-width: 0; }
1192
+ .wave-btn { flex: 0 0 auto; }
1193
+ .sprite-card { position: relative; }
1194
+ .wave-hand { position: absolute; top: -0.55rem; right: -0.35rem; font-size: 1.1rem; line-height: 1;
1195
+ transform-origin: 70% 80%; animation: hand-wave .8s ease-in-out infinite; pointer-events: none; }
1196
+ @keyframes hand-wave { 0%, 100% { transform: rotate(-14deg); } 50% { transform: rotate(20deg); } }
1197
+ .sprite-node {
1198
+ margin-top: 1px; max-width: 100%; box-sizing: border-box;
1199
+ font-family: ${MONO_STACK}; font-size: .34rem; line-height: 1.3; letter-spacing: .01em; text-align: center;
1200
+ color: var(--soil-mid); background: rgba(239,230,216,.7); border-radius: 2px; padding: 0 .2rem;
1201
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1202
+ }
1203
+ .pane-node { font-family: ${MONO_STACK}; font-size: .56rem; color: var(--soil-mid); opacity: .85; }
1204
+ .mud-window.remote .pane-controls button { display: none; }
1205
+ .mud-window.remote .room-view { box-shadow: inset 0 0 0 2px rgba(60,60,180,.35); }
1206
+ @media (prefers-reduced-motion: reduce) {
1207
+ .wave-hand, .node-row[data-waving="true"] .node-hand { animation: none; }
1208
+ }
403
1209
  `;
404
1210
 
405
1211
  /** The inlined page script, as a plain function body handed to
@@ -411,28 +1217,68 @@ function pageScript() {
411
1217
  "use strict";
412
1218
  const DATA = MUD_PAGE_DATA;
413
1219
  const createTicker = ${createTicker.toString()};
414
- const esc = ${escapeHtml.toString()};
1220
+ const escapeHtml = ${escapeHtml.toString()};
1221
+ const esc = escapeHtml;
1222
+ const paneMarkup = ${paneMarkup.toString()};
415
1223
  const speciesOfCharacter = ${speciesOfCharacter.toString()};
416
1224
  const mudRoomSceneObjects = ${mudRoomSceneObjects.toString()};
417
1225
  const carriedItemsFor = ${carriedItemsFor.toString()};
418
1226
  const levelsOf = ${levelsOf.toString()};
419
- const levelBands = ${levelBands.toString()};
420
1227
  const charactersInRoom = ${charactersInRoom.toString()};
1228
+ const burrowGraph = ${burrowGraph.toString()};
1229
+ const diggableDirections = ${diggableDirections.toString()};
1230
+ const itemLabel = ${itemLabel.toString()};
1231
+ const isCreature = ${isCreature.toString()};
421
1232
  const roomSceneObjects = ${roomSceneObjects.toString()};
422
1233
  const scenePlacement = ${scenePlacement.toString()};
423
1234
  const spriteClassForObject = ${spriteClassForObject.toString()};
424
1235
  const spriteAncestryRows = ${spriteAncestryRows.toString()};
1236
+ const factsForSubject = ${factsForSubject.toString()};
425
1237
  const visibleRoomOf = ${visibleRoomOf.toString()};
426
1238
  const roomKindForRoom = ${roomKindForRoom.toString()};
427
- const factsForSubject = ${factsForSubject.toString()};
1239
+ const allRoomIds = ${allRoomIds.toString()};
1240
+ const renderMudEditorText = ${renderMudEditorText.toString()};
1241
+ const wordBeforeCursor = ${wordBeforeCursor.toString()};
1242
+ const provenanceStamps = ${provenanceStamps.toString()};
1243
+ const peerTerm = ${peerTerm.toString()};
1244
+ const claimantOf = ${claimantOf.toString()};
1245
+ const wavingCharacters = ${wavingCharacters.toString()};
1246
+ const nodeNameFor = ${nodeNameFor.toString()};
1247
+ const offerBlobIn = ${offerBlobIn.toString()};
1248
+ const wireStateLabel = ${wireStateLabel.toString()};
1249
+ const nodeRowsFor = ${nodeRowsFor.toString()};
1250
+ const nodeInitials = ${nodeInitials.toString()};
1251
+ const inviteLinkFor = ${inviteLinkFor.toString()};
1252
+ const inviteParamsFrom = ${inviteParamsFrom.toString()};
428
1253
 
429
1254
  const el = (id) => document.getElementById(id);
430
- const characters = DATA.characters;
431
- const characterIds = characters.map((c) => c.id);
1255
+ // Which of the shipped burrows is loaded. Every read of the world — the
1256
+ // session it is opened over, the roster it is cast from, the provenance
1257
+ // prefix edit mode filters on — goes through this one index, so switching
1258
+ // scenarios needs no second copy of any of them.
1259
+ let scenarioIndex = 0;
1260
+ const scenario = function () { return DATA.scenarios[scenarioIndex]; };
1261
+ const rosterOf = function (s) { return (s.characters || []).map(function (c) { return c.id; }); };
1262
+ // Where the survey starts laying rooms out: the burrow's own origin, the
1263
+ // same fact the dig mechanic measures its reach from. Falling back to the
1264
+ // shipped default only matters for a world that declares no origin at all,
1265
+ // and burrowGraph already copes by starting from whichever room sorts first.
1266
+ const rootRoomOf = function (s) {
1267
+ const facts = (s.worldPayload && s.worldPayload.facts) || [];
1268
+ const origin = facts.find(function (f) { return f.predicate === "mgx:is-origin" && f.object === "true"; });
1269
+ return origin ? origin.subject : DATA.rootRoom;
1270
+ };
1271
+ let roster = rosterOf(scenario());
1272
+ let rootRoom = rootRoomOf(scenario());
1273
+ let slots = [];
1274
+ const ACTOR_COLORS = ["var(--actor-a)", "var(--actor-b)", "var(--actor-c)", "var(--actor-d)"];
1275
+ const NPC_COLOR = "var(--chalk)";
1276
+ const COMPASS = ["north", "south", "east", "west", "up", "down"];
1277
+ const DIR_GLYPH = { north: "\\u25B2 N", south: "\\u25BC S", west: "\\u25C0 W", east: "E \\u25B6", up: "\\u21E1", down: "\\u21E3" };
432
1278
  const reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
433
1279
 
434
1280
  // ---- the one shared tick queue -----------------------------------------
435
- // Every window's ticker calls into this instead of session.windows[...]
1281
+ // Every pane's ticker calls into this instead of session.windows[...]
436
1282
  // directly, so two characters' turns can never interleave their reads and
437
1283
  // writes of the one shared memoryDir, and the global turn counter always
438
1284
  // increments in real execution order.
@@ -444,170 +1290,499 @@ function pageScript() {
444
1290
  }
445
1291
 
446
1292
  let session = null;
1293
+ let cast = [];
1294
+ // The animals with no pane. They act through the same session windows and
1295
+ // the same tick queue as the cast; every list below that says "everyone"
1296
+ // means both, and every list that says "cast" means the panes alone.
1297
+ let npcs = [];
1298
+ let slotOf = {};
447
1299
  let globalTurn = 0;
1300
+ let turnsTaken = {};
1301
+ let finished = {};
448
1302
  let maxTurns = DATA.defaultMaxTurns;
449
1303
  let delayMs = DATA.defaultDelayMs;
450
1304
  const wait = function (ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); };
451
1305
  const liveWait = function () { return wait(delayMs); };
452
1306
 
453
- const knownRoomIds = new Set();
454
1307
  let freshlyDugRoom = null;
455
- const speechBubbles = new Map(); // room -> { character, text, expiresAtTurn }
1308
+ // Which animals another node has claimed, character -> that node's name.
1309
+ // Refreshed from the store on every redraw, which is also every turn, so a
1310
+ // claim that arrives from a peer takes effect on the next tick rather than
1311
+ // being cached until something else happens to look.
1312
+ let claimedElsewhere = {};
1313
+ // Whoever is waving right now, and the timer that lets a wave stop being
1314
+ // current. A wave is never retracted — it just stops being recent.
1315
+ let wavingNow = [];
1316
+ let waveRedrawTimer = null;
1317
+ // The last omniscient read, kept so the pounce can draw the predator's own
1318
+ // den after the engine has already moved the character out of every room.
1319
+ let lastSnapshot = null;
1320
+ const speechBubbles = new Map(); // room -> [{ speaker, text, expiresAtTurn }]
456
1321
 
457
- const tickers = {};
1322
+ let tickers = {};
458
1323
  let autoOn = false;
459
1324
 
460
1325
  function hasNext() { return globalTurn < maxTurns; }
1326
+ function everyone() { return cast.concat(npcs); }
1327
+ function hasPane(character) { return Boolean(slotOf[character]); }
1328
+ function paneIdFor(character) { return "window-" + slotOf[character]; }
1329
+ function colorFor(character) {
1330
+ const seat = cast.indexOf(character);
1331
+ return seat === -1 ? NPC_COLOR : ACTOR_COLORS[seat % ACTOR_COLORS.length];
1332
+ }
1333
+
1334
+ // The engine keeps each character's own tally (its scripted ticks AND its
1335
+ // typed commands), which is the only one that can be right: a typed "dig
1336
+ // north" is a turn the page never drove. The page's own count of the ticks it
1337
+ // fired is the fallback, so a pane never falls back to showing the shared
1338
+ // global figure as if it were its own.
1339
+ function turnsFor(character) {
1340
+ const w = session && session.windows[character];
1341
+ const own = w && typeof w.turnsTaken === "function" ? w.turnsTaken() : null;
1342
+ return typeof own === "number" ? own : (turnsTaken[character] || 0);
1343
+ }
461
1344
 
462
1345
  async function runOneTurn(character) {
1346
+ // A tick already queued when a reset lands belongs to a world that no
1347
+ // longer exists, and a dozen animals make that overlap ordinary rather
1348
+ // than rare.
1349
+ const bootAt = bootSeq;
463
1350
  return serializeTick(async function () {
1351
+ if (bootAt !== bootSeq || finished[character]) return { outOfPlay: true };
1352
+ // An animal another node claimed is played there, not here. Its moves
1353
+ // still arrive as facts and it still draws, talks and can be waved at —
1354
+ // this page just never takes its turn for it.
1355
+ if (claimedElsewhere[character]) return { playedElsewhere: true };
464
1356
  globalTurn += 1;
465
1357
  const result = await session.windows[character].autoplayTick(globalTurn);
1358
+ if (!result.outOfPlay) turnsTaken[character] = (turnsTaken[character] || 0) + 1;
466
1359
  afterEngineTurn(character, result);
1360
+ await broadcastLocalChange();
467
1361
  return result;
468
1362
  });
469
1363
  }
470
1364
 
1365
+ // A character whose run has ended keeps its pane, its final turn count and
1366
+ // its chat log, and loses everything that would advance it — the engine
1367
+ // declines its turns from here on, so offering the controls would promise a
1368
+ // turn that never runs. An NPC has none of that to lose: it stops ticking and
1369
+ // the survey stops drawing it, which is the whole of its ending. WHICH ending
1370
+ // it was is never cached here — every reader asks the engine fresh, so a pane
1371
+ // that says "eaten" over an animal that quietly starved is never possible.
1372
+ function markOutOfPlay(character, reason, note) {
1373
+ if (finished[character]) return;
1374
+ finished[character] = true;
1375
+ const ticker = tickers[character];
1376
+ if (ticker) ticker.pause();
1377
+ if (!hasPane(character)) return;
1378
+ const w = paneIdFor(character);
1379
+ el(w + "-play").disabled = true;
1380
+ el(w + "-step").disabled = true;
1381
+ el(w + "-chatq").disabled = true;
1382
+ el(w + "-chatpills").innerHTML = "";
1383
+ const fate = reason === "starved" ? "starved" : "eaten";
1384
+ // A run that ended on a TYPED command carries no note of its own — the
1385
+ // world's ending sentence comes from the engine either way, never a second
1386
+ // wording of the same event.
1387
+ appendChat(character, "a", note
1388
+ || (window.tmctMud.outOfPlayPhrase(character, fate) + ". It takes no more turns."));
1389
+ playFateScene(character, fate);
1390
+ }
1391
+
1392
+ // Whichever individual the WORLD marks dangerous, never a species this page
1393
+ // hardcodes — the same fact adventure.mjs's own predator check reads.
1394
+ function predatorInRows(rows) {
1395
+ for (const row of rows) {
1396
+ if (row.predicate === "mgx:is-predator" && row.object === "true") return row.subject;
1397
+ }
1398
+ return null;
1399
+ }
1400
+
1401
+ // The pane holds on the den for the length of one pounce before it grays out.
1402
+ // Cutting straight to the banner throws away the only moment this demo has to
1403
+ // show what took the character: the character's last drawn room is the one it
1404
+ // walked out OF, so the scene is redrawn against the den first, then the fox
1405
+ // crosses it. An animal that ran out of mass was taken by nothing, so it
1406
+ // settles straight into its banner — as does reduced motion, or a world with
1407
+ // no marked predator.
1408
+ function playFateScene(character, fate) {
1409
+ const w = paneIdFor(character);
1410
+ const settle = function () {
1411
+ el(w).classList.add("out-of-play");
1412
+ const banner = el(w + "-fate");
1413
+ banner.hidden = false;
1414
+ banner.textContent = fate + " \\u00b7 " + turnsFor(character) + " turns";
1415
+ };
1416
+ const predator = lastSnapshot ? predatorInRows(lastSnapshot.rows) : null;
1417
+ const den = predator ? (lastSnapshot.state.placements.get(predator) || {}).object : null;
1418
+ if (fate === "starved" || reduceMotion || !den) { settle(); return; }
1419
+
1420
+ const rows = lastSnapshot.rows;
1421
+ const state = lastSnapshot.state;
1422
+ renderRoomView(character, rows, state, den, allRoomIds(rows),
1423
+ window.tmctMud.castInRoom(rows, state, den, character));
1424
+
1425
+ const room = el(w + "-room");
1426
+ const lunger = room.querySelector('.floor-others .sprite-card[data-subject="' + predator + '"]');
1427
+ const prey = el(w + "-self");
1428
+ if (!lunger || !prey) { settle(); return; }
1429
+ const gap = prey.getBoundingClientRect().left - lunger.getBoundingClientRect().left;
1430
+ lunger.style.setProperty("--pounce-x", Math.max(0, Math.round(gap) - 8) + "px");
1431
+ room.classList.add("pounce");
1432
+ lunger.classList.add("lunging");
1433
+ setTimeout(settle, 1000);
1434
+ }
1435
+
1436
+ // A bubble carries what was SAID, not the sentence: the answer names a
1437
+ // thing, and the thing's own display label is only resolvable against the
1438
+ // fact rows a redraw has and a finished turn does not.
1439
+ function noteSpeech(room, speaker, bubble) {
1440
+ if (!speechBubbles.has(room)) speechBubbles.set(room, []);
1441
+ speechBubbles.get(room).push(Object.assign({ speaker: speaker, expiresAtTurn: globalTurn + 2 }, bubble));
1442
+ }
1443
+
471
1444
  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
- });
1445
+ if (result && result.outOfPlay) {
1446
+ markOutOfPlay(character, result.outOfPlayReason, result.text);
1447
+ renderSoon();
1448
+ return;
1449
+ }
1450
+ if (!result || !result.room) { renderSoon(); return; }
1451
+ if (result.roomAfter && result.roomAfter !== result.room) freshlyDugRoom = result.roomAfter;
1452
+ for (const action of result.actions || []) {
1453
+ if (action.kind !== "ask" && action.kind !== "talk") continue;
1454
+ const other = action.teller || action.target || action.object;
1455
+ if (action.text) noteSpeech(result.room, character, { text: action.text });
1456
+ if (other && action.thing) noteSpeech(result.room, other, { thing: action.thing });
1457
+ else if (other && action.text) noteSpeech(result.room, other, { text: action.text });
484
1458
  }
485
- renderAll();
1459
+ renderSoon();
486
1460
  }
487
1461
 
488
1462
  function ensureTicker(character) {
489
1463
  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({
1464
+ const playBtn = hasPane(character) ? el(paneIdFor(character) + "-play") : null;
1465
+ tickers[character] = createTicker({
494
1466
  onTick: function () { return runOneTurn(character); },
495
1467
  onRender: function (state) {
496
- playBtn.textContent = state.playing ? "\\u23F8 pause" : "\\u25B6 play";
1468
+ if (playBtn) playBtn.textContent = state.playing ? "\\u23F8 pause" : "\\u25B6 play";
497
1469
  },
498
1470
  hasNext: hasNext,
499
1471
  wait: liveWait,
500
1472
  });
501
- playBtn.addEventListener("click", function () { ticker.play(); });
502
- stepBtn.addEventListener("click", function () { ticker.stepOnce(); });
503
- tickers[character] = ticker;
504
- return ticker;
1473
+ return tickers[character];
1474
+ }
1475
+
1476
+ // ---- chat docks ---------------------------------------------------------
1477
+ // The line's own text is never shortened, only its rendered height — the whole
1478
+ // thing stays readable through the popup, and stays in the DOM for anything
1479
+ // reading the log.
1480
+ function appendChat(character, cls, text) {
1481
+ const log = el(paneIdFor(character) + "-chatlog");
1482
+ const d = document.createElement("div");
1483
+ d.className = cls;
1484
+ d.textContent = text;
1485
+ log.appendChild(d);
1486
+ // A line-clamped box reports scrollHeight EQUAL to its clamped height, so
1487
+ // the overflow has to be read against the same box with the clamp lifted.
1488
+ d.classList.add("unclamped");
1489
+ const wholeHeight = d.scrollHeight;
1490
+ d.classList.remove("unclamped");
1491
+ if (wholeHeight - d.clientHeight > 2) {
1492
+ d.classList.add("clipped");
1493
+ d.setAttribute("role", "button");
1494
+ d.setAttribute("tabindex", "0");
1495
+ d.setAttribute("title", "read the whole line");
1496
+ }
1497
+ log.scrollTop = log.scrollHeight;
505
1498
  }
506
1499
 
507
- function windowIdFor(character) {
508
- const c = characters.find(function (x) { return x.id === character; });
509
- return "window-" + c.slot;
1500
+ function openLogPopup(slot, text) {
1501
+ const w = "window-" + slot;
1502
+ el(w + "-logpop-text").textContent = text;
1503
+ el(w + "-logpop").hidden = false;
510
1504
  }
511
1505
 
512
- // ---- chat docks ---------------------------------------------------------
513
- function wireChat(character) {
514
- const w = windowIdFor(character);
515
- const form = el(w + "-chatform");
1506
+ function closeLogPopup(slot) {
1507
+ el("window-" + slot + "-logpop").hidden = true;
1508
+ }
1509
+
1510
+ function sendCommand(character, line) {
1511
+ appendChat(character, "u", line);
1512
+ const owner = claimedElsewhere[character];
1513
+ if (owner) {
1514
+ const refusal = character + " is played from " + owner + ". You can watch it and wave at it, but its turns are theirs.";
1515
+ appendChat(character, "a", refusal);
1516
+ return Promise.resolve({ answer: refusal, end: false });
1517
+ }
1518
+ // A wave is a gesture, not a world command — the engine has no verb for it
1519
+ // and would answer with an honest miss. Both ways in (this typed line and
1520
+ // the pane's own hand button) write the same fact.
1521
+ if (/^\\/?wave$/i.test(line.trim())) {
1522
+ return waveAs(character).then(function (here) {
1523
+ appendChat(character, "a", here
1524
+ ? "you wave. Everyone in the " + here + " sees it."
1525
+ : character + " is standing nowhere, so there is nobody to wave at.");
1526
+ return { answer: "", end: false };
1527
+ });
1528
+ }
1529
+ return serializeTick(function () { return session.windows[character].turn(line); }).then(async function (res) {
1530
+ appendChat(character, "a", res.answer);
1531
+ await broadcastLocalChange();
1532
+ renderSoon();
1533
+ return res;
1534
+ });
1535
+ }
1536
+
1537
+ // A wave is an ordinary add-only fact in the shared world, so it needs no
1538
+ // network to work: one browser renders it exactly the way four do, and the
1539
+ // room broadcast below is the only part sharing adds.
1540
+ async function waveAs(character) {
1541
+ if (!session || finished[character]) return null;
1542
+ const here = await serializeTick(function () { return session.wave(character); });
1543
+ if (here) await broadcastLocalChange();
1544
+ renderSoon();
1545
+ return here;
1546
+ }
1547
+
1548
+ // Wired as the pane is built. The stage is rebuilt on every boot, so a
1549
+ // pane's listeners live and die with it; each one still reads its slot's
1550
+ // character at click time, because the cast is bound after the markup.
1551
+ function wirePane(slot) {
1552
+ const w = "window-" + slot;
516
1553
  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) {
1554
+ el(w + "-chatform").addEventListener("submit", function (e) {
527
1555
  e.preventDefault();
1556
+ const character = characterInSlot(slot);
528
1557
  const line = input.value.trim();
529
- if (!line) return;
1558
+ if (!character || !line) return;
530
1559
  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
- });
1560
+ sendCommand(character, line);
1561
+ });
1562
+ el(w + "-play").addEventListener("click", function () {
1563
+ const character = characterInSlot(slot);
1564
+ if (!character) return;
1565
+ const ticker = ensureTicker(character);
1566
+ if (ticker.getState().playing) ticker.pause(); else ticker.play();
1567
+ });
1568
+ el(w + "-step").addEventListener("click", function () {
1569
+ const character = characterInSlot(slot);
1570
+ if (character) ensureTicker(character).stepOnce();
1571
+ });
1572
+ el(w + "-wave").addEventListener("click", function () {
1573
+ const character = characterInSlot(slot);
1574
+ if (character) sendCommand(character, "wave");
1575
+ });
1576
+
1577
+ const log = el(w + "-chatlog");
1578
+ log.addEventListener("click", function (e) {
1579
+ const line = e.target.closest(".clipped");
1580
+ if (line) openLogPopup(slot, line.textContent);
1581
+ });
1582
+ log.addEventListener("keydown", function (e) {
1583
+ if (e.key !== "Enter" && e.key !== " ") return;
1584
+ const line = e.target.closest(".clipped");
1585
+ if (!line) return;
1586
+ e.preventDefault();
1587
+ openLogPopup(slot, line.textContent);
1588
+ });
1589
+ el(w + "-logpop-close").addEventListener("click", function () { closeLogPopup(slot); });
1590
+ el(w).addEventListener("keydown", function (e) {
1591
+ if (e.key === "Escape") closeLogPopup(slot);
1592
+ });
1593
+
1594
+ // Clicking a drawn thing runs the same grounded command its pill would, so
1595
+ // the room view is a second way into one action set rather than its own.
1596
+ // A sprite the room grants no action on carries no command and does nothing.
1597
+ el(w + "-room").addEventListener("click", function (e) {
1598
+ const card = e.target.closest(".sprite-card[data-command]");
1599
+ const character = characterInSlot(slot);
1600
+ if (!card || !character || finished[character]) return;
1601
+ sendCommand(character, card.getAttribute("data-command"));
1602
+ });
1603
+ el(w + "-room").addEventListener("keydown", function (e) {
1604
+ if (e.key !== "Enter" && e.key !== " ") return;
1605
+ const card = e.target.closest(".sprite-card[data-command]");
1606
+ const character = characterInSlot(slot);
1607
+ if (!card || !character || finished[character]) return;
1608
+ e.preventDefault();
1609
+ sendCommand(character, card.getAttribute("data-command"));
536
1610
  });
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
- }
1611
+ }
1612
+
1613
+ function characterInSlot(slot) {
1614
+ return cast[slots.indexOf(slot)] || null;
543
1615
  }
544
1616
 
545
1617
  // ---- room-view rendering -------------------------------------------------
546
- function roomKindTint(kind) {
547
- if (kind === "outdoor") return "var(--root-moss)";
548
- return "var(--soil-mid)";
1618
+ function hashOf(text) {
1619
+ let h = 0;
1620
+ for (let i = 0; i < String(text).length; i += 1) h = (h * 31 + String(text).charCodeAt(i)) % 100000;
1621
+ return h;
549
1622
  }
550
1623
 
551
- function spriteSvgFor(species, rows) {
1624
+ function spriteSvgFor(species, rows, instanceKey) {
552
1625
  if (window.tmctMud && window.tmctMud.resolveSpriteAsset) {
553
- return window.tmctMud.resolveSpriteAsset(species, rows, [], DATA.spriteTemplates, window.tmctMud.SPRITE_REGISTRY);
1626
+ return window.tmctMud.resolveSpriteAsset(species, rows, [], DATA.spriteTemplates, window.tmctMud.SPRITE_REGISTRY, { instanceKey: instanceKey });
554
1627
  }
555
1628
  return "";
556
1629
  }
557
1630
 
558
- function objectSvgFor(spriteClass, rows) {
559
- if (window.tmctMud && window.tmctMud.resolveSpriteForClass) {
560
- return window.tmctMud.resolveSpriteForClass(spriteClass, rows, window.tmctMud.SPRITE_REGISTRY);
1631
+ // A wall-hung object resolves through the SAME property-aware, large-tier
1632
+ // resolver the characters do, keyed on the object's OWN name (via
1633
+ // spriteAncestryRows' synthetic subClassOf edge to its declared class), so
1634
+ // a carrot draws a carrot and only a class with no template anywhere falls
1635
+ // back. The fallback root is "portable", not the resolver's default
1636
+ // "animal": everything hung on this wall is a thing, and a thing with no
1637
+ // sprite should read as a plain parcel rather than a creature.
1638
+ function objectSvgFor(subject, rows) {
1639
+ if (window.tmctMud && window.tmctMud.resolveSpriteAsset) {
1640
+ return window.tmctMud.resolveSpriteAsset(
1641
+ subject, spriteAncestryRows(rows, subject), factsForSubject(rows, subject),
1642
+ DATA.spriteTemplates, window.tmctMud.SPRITE_REGISTRY,
1643
+ { rootFallback: "portable", instanceKey: subject },
1644
+ );
561
1645
  }
562
1646
  return "";
563
1647
  }
564
1648
 
565
- function drawRoomBackdrop(canvas, kind) {
1649
+ // The backdrop is a drawing of what the digest already says about the room:
1650
+ // turf and sky above ground, packed strata and pebbles below it. Every
1651
+ // stroke is placed off a hash of the room's own id, so a room looks the
1652
+ // same every redraw and two rooms never look alike.
1653
+ function drawRoomBackdrop(canvas, kind, room) {
566
1654
  const ctx = canvas.getContext("2d");
567
1655
  const w = canvas.width, h = canvas.height;
568
1656
  ctx.clearRect(0, 0, w, h);
569
1657
  const grad = ctx.createLinearGradient(0, 0, 0, h);
570
1658
  if (kind === "outdoor") {
571
- grad.addColorStop(0, "#9BB077"); grad.addColorStop(1, "#6B7A4F");
1659
+ grad.addColorStop(0, "#A9BE83"); grad.addColorStop(0.42, "#7E9159"); grad.addColorStop(1, "#5C6B41");
572
1660
  } else {
573
- grad.addColorStop(0, "#5A4130"); grad.addColorStop(1, "#2B1D14");
1661
+ grad.addColorStop(0, "#63482F"); grad.addColorStop(0.55, "#42301F"); grad.addColorStop(1, "#241710");
574
1662
  }
575
1663
  ctx.fillStyle = grad;
576
1664
  ctx.fillRect(0, 0, w, h);
1665
+
1666
+ const seed = hashOf(room);
1667
+ ctx.save();
1668
+ if (kind === "outdoor") {
1669
+ ctx.strokeStyle = "rgba(45,62,30,.5)";
1670
+ ctx.lineWidth = 1.4;
1671
+ for (let i = 0; i < 26; i += 1) {
1672
+ const x = ((seed * (i + 3)) % w);
1673
+ const base = h - 6 - ((seed >> (i % 5)) % 8);
1674
+ ctx.beginPath();
1675
+ ctx.moveTo(x, base);
1676
+ ctx.lineTo(x + (i % 2 ? 3 : -3), base - 9 - (i % 4) * 2);
1677
+ ctx.stroke();
1678
+ }
1679
+ } else {
1680
+ ctx.strokeStyle = "rgba(0,0,0,.22)";
1681
+ ctx.lineWidth = 1;
1682
+ for (let i = 1; i < 4; i += 1) {
1683
+ const y = (h / 4) * i + ((seed >> i) % 6) - 3;
1684
+ ctx.beginPath();
1685
+ ctx.moveTo(0, y);
1686
+ for (let x = 0; x <= w; x += 30) ctx.lineTo(x, y + (((seed + x) % 7) - 3));
1687
+ ctx.stroke();
1688
+ }
1689
+ ctx.fillStyle = "rgba(255,255,255,.07)";
1690
+ for (let i = 0; i < 14; i += 1) {
1691
+ const x = ((seed * (i + 7)) % w);
1692
+ const y = ((seed * (i + 2)) % h);
1693
+ ctx.beginPath();
1694
+ ctx.arc(x, y, 1 + (i % 3), 0, Math.PI * 2);
1695
+ ctx.fill();
1696
+ }
1697
+ }
1698
+ ctx.restore();
1699
+ const floor = ctx.createLinearGradient(0, h - 26, 0, h);
1700
+ floor.addColorStop(0, "rgba(0,0,0,0)");
1701
+ floor.addColorStop(1, "rgba(0,0,0,.35)");
1702
+ ctx.fillStyle = floor;
1703
+ ctx.fillRect(0, h - 26, w, 26);
1704
+ }
1705
+
1706
+ // The world's own hasMass fold, the same one the pouch's stat line reads.
1707
+ // Null for anything the world never weighed, which draws no figure at all
1708
+ // rather than a guessed one.
1709
+ function massOf(state, subject) {
1710
+ const mass = state.masses.get(subject);
1711
+ return mass ? mass.value : null;
1712
+ }
1713
+
1714
+ // A sprite card: adventure.html's own frame-plus-nameplate, carrying the one
1715
+ // command this room currently grants on its subject (none, for a thing the
1716
+ // room offers nothing on) and the subject's own mass under the name. The
1717
+ // last argument holds the two things only a shared burrow has: a hand over a
1718
+ // character waving right now, and the node another peer plays it from — one
1719
+ // more line under the name, in the same font, never a second label system.
1720
+ function spriteCardHtml(subject, label, inner, command, extraClass, mass, extra) {
1721
+ const opts = extra || {};
1722
+ const action = command
1723
+ ? ' actionable" data-command="' + esc(command) + '" role="button" tabindex="0" title="' + esc(command) + '"'
1724
+ : '" title="' + esc(label) + '"';
1725
+ return '<div class="sprite-card ' + (extraClass || "") + (opts.waving ? " waving" : "") + action
1726
+ + ' data-subject="' + esc(subject) + '">'
1727
+ + (opts.waving ? '<div class="wave-hand" aria-hidden="true">\\u{1F44B}</div>' : "")
1728
+ + inner + '<div class="sprite-label">' + esc(label) + "</div>"
1729
+ + (opts.node ? '<div class="sprite-node">via ' + esc(opts.node) + "</div>" : "")
1730
+ + (mass === null || mass === undefined ? "" : '<div class="sprite-mass">mass ' + esc(mass) + "</div>")
1731
+ + "</div>";
1732
+ }
1733
+
1734
+ // A character another node plays carries that node's name; one this node
1735
+ // plays, or one nobody has claimed, carries nothing extra.
1736
+ function nodeLabelFor(character) {
1737
+ return claimedElsewhere[character] || null;
1738
+ }
1739
+ function isWavingNow(character) {
1740
+ return wavingNow.indexOf(character) !== -1;
577
1741
  }
578
1742
 
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);
1743
+ function renderRoomView(character, rows, state, here, roomIds, roomMates) {
1744
+ const w = paneIdFor(character);
1745
+ drawRoomBackdrop(el(w + "-canvas"), roomKindForRoom(rows, here), here);
1746
+ const commands = commandsBySubject(affordancesFor(rows, state, here, character));
584
1747
 
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);
1748
+ el(w + "-self").innerHTML = spriteCardHtml(
1749
+ character, character,
1750
+ '<div class="sprite">' + spriteSvgFor(speciesOfCharacter(character), rows, character) + "</div>",
1751
+ null, "self", massOf(state, character),
1752
+ { waving: isWavingNow(character), node: nodeLabelFor(character) },
1753
+ );
591
1754
 
1755
+ const others = roomMates.map(function (mate) {
1756
+ return spriteCardHtml(mate, mate,
1757
+ '<div class="sprite">' + spriteSvgFor(speciesOfCharacter(mate), rows, mate) + "</div>",
1758
+ commands[mate], "", massOf(state, mate),
1759
+ { waving: isWavingNow(mate), node: nodeLabelFor(mate) });
1760
+ });
1761
+ const wall = [];
592
1762
  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;
1763
+ if (isCreature(state, obj.subject)) continue;
1764
+ const plane = scenePlacement(rows, state, obj.subject).plane;
1765
+ const hang = plane === "ceiling" || plane === "wall" ? "hangs-high" : "hangs-low";
1766
+ // The caption is always the THING'S own name, never the class whose
1767
+ // sprite ended up drawn — an item wearing an ancestor's icon because
1768
+ // its own class has no sprite yet still has to say what it is.
1769
+ wall.push(spriteCardHtml(obj.subject, labelForItem(rows, obj.subject, roomIds),
1770
+ '<div class="hook"></div><div class="sprite-frame">' + objectSvgFor(obj.subject, rows) + "</div>",
1771
+ commands[obj.subject], "wall-item " + hang, massOf(state, obj.subject)));
610
1772
  }
1773
+ const othersEl = el(w + "-others");
1774
+ othersEl.innerHTML = others.join("");
1775
+ const floorForMates = el(w + "-room").clientWidth - 68;
1776
+ othersEl.style.setProperty("--mate-width",
1777
+ Math.max(18, Math.min(76, Math.floor(floorForMates / Math.max(1, others.length)) - 6)) + "px");
1778
+ el(w + "-wall").innerHTML = wall.join("");
1779
+
1780
+ const live = (speechBubbles.get(here) || []).filter(function (b) { return b.expiresAtTurn >= globalTurn; });
1781
+ el(w + "-bubbles").innerHTML = live.map(function (b) {
1782
+ const side = b.speaker === character ? "from-self" : "from-other";
1783
+ const said = b.thing ? "there's " + labelForItem(rows, b.thing, roomIds) + ", if you can reach it." : b.text;
1784
+ return '<div class="bubble ' + side + '">' + esc(said) + "</div>";
1785
+ }).join("");
611
1786
 
612
1787
  const flourishEl = el(w + "-flourish");
613
1788
  if (freshlyDugRoom && freshlyDugRoom === here && !reduceMotion) {
@@ -616,86 +1791,1007 @@ function pageScript() {
616
1791
  setTimeout(function () { flourishEl.classList.remove("shown"); flourishEl.hidden = true; }, 900);
617
1792
  }
618
1793
 
619
- el(w + "-caption").textContent = roomCaptionFor(rows, state, here, character);
1794
+ el(w + "-caption").textContent = roomCaptionFor(rows, state, here, character, roomMates);
620
1795
  }
621
1796
 
622
- function roomCaptionFor(rows, state, here, character) {
623
- if (!window.tmctMud) return here;
1797
+ // The digest's own room sentences say what the ROOM is, and nothing about who
1798
+ // is standing in it — a "Badger-2 is in the sett-1" row is filed under the
1799
+ // badger, not the sett, so filtering the view to this room drops every mate.
1800
+ // The cast sentence comes from the same list the talk pills and the floor
1801
+ // sprites read, so the description can never name a different set of animals
1802
+ // than the pane offers to talk to.
1803
+ function roomCaptionFor(rows, state, here, character, roomMates) {
1804
+ const parts = [];
624
1805
  const view = window.tmctMud.worldDigestRows(rows, state, character);
625
1806
  const lines = view.filter(function (r) { return r.subject.toLowerCase() === here.toLowerCase(); })
626
1807
  .map(function (r) { return r.subject + " " + r.predicate + " " + r.object + "."; });
627
- return lines.length ? lines.join(" ") : ("You are in the " + here + ".");
1808
+ parts.push(lines.length ? lines.join(" ") : "You are in the " + here + ".");
1809
+ if (roomMates.length) {
1810
+ parts.push("Here with you: " + roomMates.map(function (mate) {
1811
+ const mass = massOf(state, mate);
1812
+ return "the " + mate + (mass === null ? "" : " (mass " + mass + ")");
1813
+ }).join(", ") + ".");
1814
+ }
1815
+ return parts.join(" ");
1816
+ }
1817
+
1818
+ // ---- the compass ring ----------------------------------------------------
1819
+ // One button per direction, in the slot that direction points at: the "go"
1820
+ // a written exit already allows, or the "dig" that would open one. A
1821
+ // direction with neither draws nothing, so the ring never offers a command
1822
+ // the world would refuse.
1823
+ function diggableFor(rows, state, here) {
1824
+ const fromEngine = window.tmctMud && window.tmctMud.diggableDirections;
1825
+ return fromEngine ? fromEngine(rows, state, here) : diggableDirections(rows, state, here);
1826
+ }
1827
+
1828
+ function renderDirections(character, rows, state, here) {
1829
+ const w = paneIdFor(character);
1830
+ const exits = state.exits.get(here);
1831
+ const diggable = diggableFor(rows, state, here);
1832
+ for (const direction of COMPASS) {
1833
+ const slot = el(w + "-dir-" + direction);
1834
+ const open = exits && exits.has(direction);
1835
+ const command = open ? "go " + direction : (diggable.indexOf(direction) !== -1 ? "dig " + direction : null);
1836
+ if (!command) { slot.innerHTML = ""; continue; }
1837
+ const cls = "dir-pill " + (open ? "go" : "dig") + (direction === "up" || direction === "down" ? " vertical" : "");
1838
+ slot.innerHTML = '<button type="button" class="' + cls + '" data-command="' + esc(command) + '" title="' + esc(command)
1839
+ + '" aria-label="' + esc(command) + '">' + DIR_GLYPH[direction] + "</button>";
1840
+ slot.querySelector("button").addEventListener("click", function () { sendCommand(character, command); });
1841
+ }
1842
+ }
1843
+
1844
+ // ---- the pill row --------------------------------------------------------
1845
+ // Every action the room actually grants comes from the engine's own
1846
+ // roomAffordances — the SAME list the "You can:" line in a look reply is built
1847
+ // from — so nothing the reply above offers can be missing from the row beneath
1848
+ // it. Exits appear in both the row and the compass ring on purpose: the ring
1849
+ // says WHERE a way out points, the row says what it is called.
1850
+ function affordancesFor(rows, state, here, character) {
1851
+ const fromEngine = window.tmctMud && window.tmctMud.roomAffordances;
1852
+ return fromEngine ? fromEngine(rows, state, here, character) : [];
1853
+ }
1854
+
1855
+ function subjectOfAction(action) {
1856
+ return action.indexOf("talk to ") === 0 ? action.slice(8) : action.slice(action.indexOf(" ") + 1);
1857
+ }
1858
+
1859
+ function commandsBySubject(actions) {
1860
+ const bySubject = {};
1861
+ for (const action of actions) {
1862
+ if (action.indexOf("go ") === 0) continue;
1863
+ bySubject[subjectOfAction(action)] = action;
1864
+ }
1865
+ return bySubject;
1866
+ }
1867
+
1868
+ function renderChatPills(character, rows, state, here, roomIds) {
1869
+ const w = paneIdFor(character);
1870
+ const pillsEl = el(w + "-chatpills");
1871
+ const pills = [
1872
+ { command: "look", label: "look", cls: "" },
1873
+ { command: "what do you know about food", label: "what do you know about food", cls: "" },
1874
+ ];
1875
+ for (const action of affordancesFor(rows, state, here, character)) {
1876
+ if (action.indexOf("go ") === 0) { pills.push({ command: action, label: action, cls: " affordance way" }); continue; }
1877
+ const subject = subjectOfAction(action);
1878
+ const label = action.slice(0, action.length - subject.length) + labelForItem(rows, subject, roomIds);
1879
+ pills.push({ command: action, label: label, cls: " affordance" });
1880
+ }
1881
+ pillsEl.innerHTML = pills.map(function (p) {
1882
+ return '<button type="button" class="pill' + p.cls + '" data-command="' + esc(p.command)
1883
+ + '" title="' + esc(p.command) + '">' + esc(p.label) + "</button>";
1884
+ }).join("");
1885
+ for (const pill of pillsEl.querySelectorAll(".pill")) {
1886
+ pill.addEventListener("click", function () {
1887
+ sendCommand(character, pill.getAttribute("data-command"));
1888
+ });
1889
+ }
628
1890
  }
629
1891
 
630
- function renderPouch(character, rows, state) {
631
- const w = windowIdFor(character);
632
- const list = el(w + "-pouch-list");
1892
+ function renderPouch(character, rows, state, roomIds) {
1893
+ const w = paneIdFor(character);
633
1894
  const items = carriedItemsFor(rows, state, character);
634
- list.innerHTML = items.map(function (i) { return "<li>" + esc(i.subject) + "</li>"; }).join("");
1895
+ el(w + "-pouch-list").innerHTML = items.map(function (i) {
1896
+ return '<li title="' + esc(i.subject) + '">' + esc(labelForItem(rows, i.subject, roomIds)) + "</li>";
1897
+ }).join("");
635
1898
  const mass = state.masses.get(character);
636
- const cfgKey = speciesOfCharacter(character);
637
- const drain = DATA.mudConfig[cfgKey + "MassDecrementPerTurn"];
1899
+ const drain = DATA.mudConfig[speciesOfCharacter(character) + "MassDecrementPerTurn"];
638
1900
  el(w + "-stats").textContent = "mass " + (mass ? mass.value : "?") + (drain !== undefined ? " \\u00b7 drain/turn " + drain : "");
639
1901
  }
640
1902
 
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("");
1903
+ // The world mints a dug object with its own display-name fact, so the engine
1904
+ // can say "carrot" while every verb still resolves the distinct id
1905
+ // ("carrot-1"). itemLabel is the fallback for an id carrying no such fact.
1906
+ function labelForItem(rows, subject, roomIds) {
1907
+ const fromEngine = window.tmctMud && window.tmctMud.displayNameOf;
1908
+ const name = fromEngine ? fromEngine(rows, subject) : null;
1909
+ return name && name !== subject ? name : itemLabel(subject, roomIds);
651
1910
  }
652
1911
 
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>";
1912
+ // ---- the burrow survey ---------------------------------------------------
1913
+ // One routine, drawn twice: the omniscient board in the top row (every room
1914
+ // the world has) and each pane's own known ground (only what that character
1915
+ // has dug or walked, with a fading stub where it knows a way carries on).
1916
+ function burrowSvg(graph, options) {
1917
+ if (!graph.nodes.length) return "";
1918
+ const opts = options || {};
1919
+ const compact = !!opts.compact;
1920
+ const cellX = compact ? 44 : 76, cellY = compact ? 26 : 54;
1921
+ const roomW = compact ? 34 : 62, roomH = compact ? 14 : 26;
1922
+ const maxX = Math.max.apply(null, graph.nodes.map(function (n) { return n.x; }));
1923
+ const maxY = Math.max.apply(null, graph.nodes.map(function (n) { return n.y; }));
1924
+ const w = (maxX + 1) * cellX, h = (maxY + 1) * cellY;
1925
+ const byRoom = {};
1926
+ for (const n of graph.nodes) byRoom[n.id] = n;
1927
+ const cx = function (n) { return (n.x + 0.5) * cellX; };
1928
+ const cy = function (n) { return (n.y + 0.5) * cellY; };
1929
+
1930
+ // The turf band is only drawn when the layout genuinely separates the
1931
+ // surface from the soil — a lateral dig can push a surface room onto a
1932
+ // lower row, and a ground line above it would say something false. The
1933
+ // room fills carry the same information either way, which is all the
1934
+ // thumbnail-sized copy has room for.
1935
+ const surfaceRows = graph.nodes.filter(function (n) { return n.level === 0; }).map(function (n) { return n.y; });
1936
+ const soilRows = graph.nodes.filter(function (n) { return n.level !== 0; }).map(function (n) { return n.y; });
1937
+ let turf = "";
1938
+ if (!compact && surfaceRows.length && soilRows.length && Math.max.apply(null, surfaceRows) < Math.min.apply(null, soilRows)) {
1939
+ const groundY = (Math.max.apply(null, surfaceRows) + 0.5) * cellY + roomH / 2 + (compact ? 3 : 7);
1940
+ turf = '<rect class="turf" x="0" y="0" width="' + w + '" height="' + groundY + '"></rect>'
1941
+ + '<line class="ground-line" x1="0" y1="' + groundY + '" x2="' + w + '" y2="' + groundY + '"></line>';
1942
+ }
1943
+
1944
+ const tunnels = graph.edges.map(function (e) {
1945
+ const a = byRoom[e.from], b = byRoom[e.to];
1946
+ if (!a || !b) return "";
1947
+ const shaft = e.direction === "up" || e.direction === "down" ? " shaft" : "";
1948
+ return '<line class="tunnel' + shaft + '" x1="' + cx(a) + '" y1="' + cy(a) + '" x2="' + cx(b) + '" y2="' + cy(b) + '"></line>';
1949
+ }).join("");
1950
+
1951
+ const HINT_DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
1952
+ const hints = graph.hints.map(function (hi) {
1953
+ const from = byRoom[hi.from];
1954
+ if (!from) return "";
1955
+ const d = HINT_DELTA[hi.direction] || [0, 0];
1956
+ 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>';
1957
+ }).join("");
1958
+
1959
+ const occupants = opts.occupants || {};
1960
+ const rooms = graph.nodes.map(function (n) {
1961
+ const here = opts.here === n.id ? " here" : "";
1962
+ const fresh = opts.fresh === n.id ? " freshly-dug" : "";
1963
+ const surface = n.level === 0 ? " surface" : "";
1964
+ const label = esc(n.id);
1965
+ const fit = label.length * (compact ? 3.6 : 4.3) > roomW - 6
1966
+ ? ' textLength="' + (roomW - 6) + '" lengthAdjust="spacingAndGlyphs"' : "";
1967
+ const cast = occupants[n.id] || [];
1968
+ // The room's own width is the budget: a dozen animals down one hole must
1969
+ // still read as a dozen dots inside that room rather than a line running
1970
+ // out of it, so the gap closes as the crowd grows.
1971
+ const step = Math.min(compact ? 6 : 9, (roomW - 6) / Math.max(1, cast.length));
1972
+ const dots = cast.map(function (c, i) {
1973
+ const spread = (i - (cast.length - 1) / 2) * step;
1974
+ return '<circle class="occupant" cx="' + (cx(n) + spread) + '" cy="' + (cy(n) + roomH / 2) + '" r="' + (compact ? 2.4 : 3.6)
1975
+ + '" fill="' + c.color + '"><title>' + esc(c.character) + "</title></circle>";
662
1976
  }).join("");
663
- return "<div class=\\"map-band\\"><span class=\\"band-level\\">L" + band.level + "</span><span class=\\"band-rooms\\">" + rooms + "</span></div>";
1977
+ return '<g class="room' + surface + here + fresh + '" data-room="' + label + '"><rect x="' + (cx(n) - roomW / 2) + '" y="' + (cy(n) - roomH / 2)
1978
+ + '" width="' + roomW + '" height="' + roomH + '" rx="3"></rect><text x="' + cx(n) + '" y="' + (cy(n) + (compact ? 2 : 2.5))
1979
+ + '"' + fit + ">" + label + "</text>" + dots + "</g>";
664
1980
  }).join("");
1981
+
1982
+ return '<div class="burrow"><svg viewBox="0 0 ' + w + " " + h + '" preserveAspectRatio="xMidYMid meet" role="img" aria-label="'
1983
+ + esc(opts.label || "the burrow") + '">' + turf + tunnels + hints + rooms + "</svg></div>";
1984
+ }
1985
+
1986
+ function renderMinimap(character, rows, state, here) {
1987
+ const visited = session.windows[character].visitedRoomIds();
1988
+ const graph = burrowGraph(state, visited, rootRoom);
1989
+ el(paneIdFor(character) + "-minimap").innerHTML = burrowSvg(graph, {
1990
+ compact: true, here: here, label: "the ground " + character + " has covered",
1991
+ });
1992
+ }
1993
+
1994
+ function renderWorldMap(rows, state, roomIds) {
1995
+ const graph = burrowGraph(state, roomIds, rootRoom);
1996
+ const occupants = {};
1997
+ for (const room of roomIds) {
1998
+ const here = charactersInRoom(state, room, everyone());
1999
+ if (!here.length) continue;
2000
+ occupants[room] = here.map(function (c) { return { character: c, color: colorFor(c) }; });
2001
+ }
2002
+ el("worldMapBoard").innerHTML = burrowSvg(graph, {
2003
+ occupants: occupants, fresh: freshlyDugRoom, label: "every room in the burrow, and who stands where",
2004
+ });
2005
+ // One key entry per pane, because a pane is what a colour identifies. The
2006
+ // npcs share the last entry: naming ten of them would take more room than
2007
+ // the survey they annotate, and each one's own dot already carries its name.
2008
+ const keys = cast.map(function (c) {
2009
+ const room = state.placements.get(c) ? state.placements.get(c).object : "?";
2010
+ const fate = window.tmctMud.outOfPlayReasonOf(state, c);
2011
+ const where = fate ? fate : "in " + esc(room);
2012
+ return '<span class="key-actor"><span class="key-dot" style="background:' + colorFor(c) + '"></span>'
2013
+ + esc(speciesOfCharacter(c)) + " " + where + "</span>";
2014
+ });
2015
+ if (npcs.length) {
2016
+ const live = npcs.filter(function (c) { return !finished[c]; }).length;
2017
+ const tally = live === npcs.length ? String(npcs.length) : live + " of " + npcs.length;
2018
+ keys.push('<span class="key-npcs"><span class="key-dot" style="background:' + NPC_COLOR + '"></span>'
2019
+ + tally + (live === 1 ? " npc" : " npcs") + " digging</span>");
2020
+ }
2021
+ el("worldMapKey").innerHTML = keys.join("");
665
2022
  el("worldMapTurn").textContent = "turn " + globalTurn;
666
2023
  }
667
2024
 
668
- function renderAll() {
2025
+ // A whole redraw per turn is one thing at two characters and quite another at
2026
+ // fourteen, where the ticks land faster than a snapshot can be read. Requests
2027
+ // arriving mid-render collapse into a single follow-up, so the page always
2028
+ // ends up drawing the newest state and never queues a backlog of stale ones.
2029
+ let rendering = false;
2030
+ let renderAgain = false;
2031
+ async function renderSoon() {
2032
+ if (rendering) { renderAgain = true; return; }
2033
+ rendering = true;
2034
+ try {
2035
+ await renderAll();
2036
+ } finally {
2037
+ rendering = false;
2038
+ }
2039
+ if (renderAgain) { renderAgain = false; renderSoon(); }
2040
+ }
2041
+
2042
+ // A character eaten on its OWN turn is out of play the moment that turn
2043
+ // returns, one whole tick before autoplayTick would report it — so every
2044
+ // character's fate is read from the engine every redraw, not only off a tick
2045
+ // result. It reads against the snapshot this redraw already holds: a
2046
+ // per-window call would re-fold the whole world once per animal.
2047
+ async function renderAll() {
669
2048
  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);
2049
+ el("globalTurnCount").textContent = "turns: " + globalTurn;
2050
+ const snap = await session.snapshot();
2051
+ lastSnapshot = snap;
2052
+ const roomIds = allRoomIds(snap.rows);
2053
+ const nowMs = Date.now();
2054
+ wavingNow = wavingCharacters(snap.rows, nowMs);
2055
+ // Every claimed animal, not just the ones this page cast: a character
2056
+ // another node plays walks into this room as an ordinary room-mate, and
2057
+ // its label has to name where it is played from just the same.
2058
+ claimedElsewhere = {};
2059
+ if (myPeerId) {
2060
+ const claimed = {};
2061
+ for (const row of snap.rows) if (row.predicate === "mgx:playedBy") claimed[row.subject] = true;
2062
+ for (const character of Object.keys(claimed)) {
2063
+ const owner = claimantOf(snap.rows, character);
2064
+ if (owner === null || peerTerm(owner) === peerTerm(myPeerId)) continue;
2065
+ claimedElsewhere[character] = nodeNameFor(snap.rows, owner);
2066
+ const ticker = tickers[character];
2067
+ if (ticker) ticker.pause();
682
2068
  }
683
- freshlyDugRoom = null;
2069
+ }
2070
+ for (const character of everyone()) {
2071
+ const reason = window.tmctMud.outOfPlayReasonOf(snap.state, character);
2072
+ if (reason) markOutOfPlay(character, reason, null);
2073
+ }
2074
+ renderWorldMap(snap.rows, snap.state, roomIds);
2075
+ for (const character of cast) {
2076
+ const pane = paneIdFor(character);
2077
+ el(pane + "-turn").textContent = "turn " + turnsFor(character);
2078
+ const owner = claimedElsewhere[character];
2079
+ el(pane).classList.toggle("remote", Boolean(owner));
2080
+ el(pane + "-node").hidden = !owner;
2081
+ el(pane + "-node").textContent = owner ? "played from " + owner : "";
2082
+ if (finished[character]) continue;
2083
+ const place = snap.state.placements.get(character);
2084
+ const here = place ? place.object : null;
2085
+ if (!here) continue;
2086
+ const roomMates = window.tmctMud.castInRoom(snap.rows, snap.state, here, character);
2087
+ renderRoomView(character, snap.rows, snap.state, here, roomIds, roomMates);
2088
+ renderDirections(character, snap.rows, snap.state, here);
2089
+ renderChatPills(character, snap.rows, snap.state, here, roomIds);
2090
+ renderPouch(character, snap.rows, snap.state, roomIds);
2091
+ renderMinimap(character, snap.rows, snap.state, here);
2092
+ }
2093
+ freshlyDugRoom = null;
2094
+ // A wave carries no retraction, so nothing tells the page when one stops
2095
+ // being current — the recency window does, and only a redraw can notice it
2096
+ // has passed. One follow-up per second while any hand is up, and none at
2097
+ // all once they are all down.
2098
+ clearTimeout(waveRedrawTimer);
2099
+ if (wavingNow.length) waveRedrawTimer = setTimeout(renderSoon, 1000);
2100
+ }
2101
+
2102
+ // ---- edit mode -----------------------------------------------------------
2103
+ // The textarea is seeded from renderMudEditorText over the world's OWN facts.
2104
+ // Reads are scoped to this world's provenance tag the same way session
2105
+ // .applyEdit scopes its writes: the live store also carries the default
2106
+ // persona's background corpus, and this page must never show, or risk
2107
+ // retracting, a fact that is not part of the burrow.
2108
+ //
2109
+ // Typing debounces two things at two paces. The suggestion pills are cheap
2110
+ // in-memory lookups and can chase the cursor (~180ms). The store sync is the
2111
+ // one that writes, so it waits for the typing to settle (~450ms) — a
2112
+ // half-finished word must never be read as what somebody meant.
2113
+ let editRows = [];
2114
+ let editState = { placements: new Map(), openness: new Map(), masses: new Map(), exits: new Map(), turnCount: 0 };
2115
+ let allStoreRows = [];
2116
+ let selectedRoomId = null;
2117
+ let editing = false;
2118
+
2119
+ function worldOnlyRows(rows) {
2120
+ const prefix = "world:" + scenario().worldPayload.name;
2121
+ return (rows || []).filter(function (r) {
2122
+ return typeof r.provenance === "string" && r.provenance.indexOf(prefix) === 0;
684
2123
  });
685
2124
  }
686
2125
 
687
- // ---- the rail -------------------------------------------------------------
688
- function wireRail() {
689
- const autoBtn = el("autoToggle");
2126
+ function renderEditMap() {
2127
+ const graph = burrowGraph(editState, allRoomIds(editRows), rootRoom);
2128
+ el("editMapBoard").innerHTML = graph.nodes.length
2129
+ ? burrowSvg(graph, { here: selectedRoomId, label: "every room this world writes" })
2130
+ : '<span class="edit-empty">no rooms written yet</span>';
2131
+ }
2132
+
2133
+ // A class card, the legend's and the room detail's shared unit — the sprite
2134
+ // frame the panes already use, shrunk, with the subject's own name under it.
2135
+ function editCardHtml(subject, label, svg) {
2136
+ return '<div class="sprite-card" title="' + esc(subject) + '"><div class="sprite-frame">' + svg
2137
+ + '</div><div class="sprite-label">' + esc(label) + "</div></div>";
2138
+ }
2139
+
2140
+ // Every class this world actually puts in a room, by its own icon. A creature
2141
+ // resolves on its species (the same read every pane's own sprite makes), a
2142
+ // prop on its declared class — so the legend shows what the survey shows.
2143
+ function renderEditLegend() {
2144
+ const classes = {};
2145
+ for (const subject of editState.placements.keys()) {
2146
+ const cls = isCreature(editState, subject) ? speciesOfCharacter(subject) : spriteClassForObject(editRows, subject);
2147
+ if (cls) classes[cls] = true;
2148
+ }
2149
+ const names = Object.keys(classes).sort();
2150
+ el("editLegend").innerHTML = names.length
2151
+ ? names.map(function (cls) { return editCardHtml(cls, cls, spriteSvgFor(cls, editRows, "legend-" + cls)); }).join("")
2152
+ : '<span class="edit-empty">nothing placed yet</span>';
2153
+ }
2154
+
2155
+ function renderRoomDetail() {
2156
+ const titleEl = el("roomDetailTitle");
2157
+ const castEl = el("roomDetailCast");
2158
+ const captionEl = el("roomDetailCaption");
2159
+ if (!selectedRoomId) {
2160
+ titleEl.textContent = "click a room";
2161
+ castEl.innerHTML = "";
2162
+ captionEl.textContent = "Click a room on the survey to see what stands in it.";
2163
+ return;
2164
+ }
2165
+ titleEl.textContent = selectedRoomId;
2166
+ const roomIds = allRoomIds(editRows);
2167
+ const here = [];
2168
+ for (const subject of editState.placements.keys()) {
2169
+ if (visibleRoomOf(editRows, editState, subject) !== selectedRoomId) continue;
2170
+ const creature = isCreature(editState, subject);
2171
+ here.push({
2172
+ subject: subject,
2173
+ label: creature ? subject : labelForItem(editRows, subject, roomIds),
2174
+ svg: creature ? spriteSvgFor(speciesOfCharacter(subject), editRows, subject) : objectSvgFor(subject, editRows),
2175
+ });
2176
+ }
2177
+ here.sort(function (a, b) { return a.subject.localeCompare(b.subject); });
2178
+ castEl.innerHTML = here.map(function (t) { return editCardHtml(t.subject, t.label, t.svg); }).join("");
2179
+ const kind = roomKindForRoom(editRows, selectedRoomId);
2180
+ const ways = [...(editState.exits.get(selectedRoomId) || new Map()).keys()].sort();
2181
+ captionEl.textContent = "An " + kind + " room. "
2182
+ + (ways.length ? "Ways out: " + ways.join(", ") + ". " : "No way out written yet. ")
2183
+ + (here.length ? here.length + " thing" + (here.length === 1 ? "" : "s") + " placed here." : "Nothing placed here.");
2184
+ }
2185
+
2186
+ function refreshEditPanels() {
2187
+ renderEditMap();
2188
+ renderEditLegend();
2189
+ renderRoomDetail();
2190
+ }
2191
+
2192
+ el("editMapBoard").addEventListener("click", function (e) {
2193
+ const g = e.target.closest("[data-room]");
2194
+ if (!g) return;
2195
+ selectedRoomId = g.getAttribute("data-room");
2196
+ renderEditMap();
2197
+ renderRoomDetail();
2198
+ });
2199
+
2200
+ // The lateral SKOS neighbourhood plus the vertical is-a chain for whatever
2201
+ // word the cursor sits behind. Read over the FULL store, not the world's own
2202
+ // rows: a term's synonyms mostly live in the background corpus, not in the
2203
+ // burrow's own vocabulary. Nothing found is nothing shown — an honest miss.
2204
+ function renderSuggestionPills() {
2205
+ const box = el("editorPills");
2206
+ const term = wordBeforeCursor(el("editorText").value, el("editorText").selectionStart);
2207
+ if (!term || !window.tmctMud) { box.innerHTML = ""; return; }
2208
+ const related = window.tmctMud.relatedForTerm ? window.tmctMud.relatedForTerm(allStoreRows, term) : null;
2209
+ const chain = window.tmctMud.classAncestorChain ? window.tmctMud.classAncestorChain(term, allStoreRows) : [];
2210
+ const seen = { };
2211
+ seen[term] = true;
2212
+ const out = [];
2213
+ const push = function (label) { if (label && !seen[label]) { seen[label] = true; out.push(label); } };
2214
+ if (related) {
2215
+ related.synonyms.forEach(push);
2216
+ related.related.forEach(function (r) { push(r.prefLabel); });
2217
+ }
2218
+ chain.slice(1).forEach(push);
2219
+ box.innerHTML = out.slice(0, 8).map(function (s) {
2220
+ return '<button type="button" class="pill" data-insert="' + esc(s) + '">' + esc(s) + "</button>";
2221
+ }).join("");
2222
+ }
2223
+
2224
+ el("editorPills").addEventListener("click", function (e) {
2225
+ const btn = e.target.closest(".pill");
2226
+ if (!btn) return;
2227
+ const area = el("editorText");
2228
+ const pos = area.selectionStart;
2229
+ const word = wordBeforeCursor(area.value, pos);
2230
+ const insert = btn.getAttribute("data-insert");
2231
+ area.value = area.value.slice(0, pos - word.length) + insert + area.value.slice(pos);
2232
+ const next = pos - word.length + insert.length;
2233
+ area.setSelectionRange(next, next);
2234
+ area.focus();
2235
+ onEditorChanged();
2236
+ });
2237
+
2238
+ async function applyEditorText() {
2239
+ if (!session) return;
2240
+ const status = el("editorStatus");
2241
+ status.className = "edit-status pending";
2242
+ status.textContent = "reading the burrow\\u2026";
2243
+ const result = await serializeTick(function () { return session.applyEdit(el("editorText").value); });
2244
+ await broadcastLocalChange();
2245
+ const snap = await session.snapshot();
2246
+ allStoreRows = snap.rows;
2247
+ editRows = worldOnlyRows(snap.rows);
2248
+ editState = window.tmctMud.foldWorldState(window.tmctMud.worldActionRows(editRows));
2249
+ if (result.unrecognized.length) {
2250
+ const lines = result.unrecognized.map(function (u) { return u.line; }).join(", ");
2251
+ status.className = "edit-status pending";
2252
+ status.textContent = result.unrecognized.length + " line"
2253
+ + (result.unrecognized.length === 1 ? "" : "s") + " not understood yet (line " + lines
2254
+ + ") \\u2014 nothing is retracted until they are.";
2255
+ } else {
2256
+ status.className = "edit-status ok";
2257
+ status.textContent = (result.added || result.removed)
2258
+ ? "synced \\u2014 " + result.added + " fact(s) written, " + result.removed + " retracted."
2259
+ : "synced \\u2014 no change.";
2260
+ }
2261
+ refreshEditPanels();
2262
+ }
2263
+
2264
+ let suggestTimer = null;
2265
+ let syncTimer = null;
2266
+ function scheduleSuggestions() { clearTimeout(suggestTimer); suggestTimer = setTimeout(renderSuggestionPills, 180); }
2267
+ function scheduleSync() { clearTimeout(syncTimer); syncTimer = setTimeout(applyEditorText, 450); }
2268
+ function onEditorChanged() { scheduleSuggestions(); scheduleSync(); }
2269
+ el("editorText").addEventListener("input", onEditorChanged);
2270
+ el("editorText").addEventListener("click", scheduleSuggestions);
2271
+ el("editorText").addEventListener("keyup", function (e) {
2272
+ if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].indexOf(e.key) !== -1) scheduleSuggestions();
2273
+ });
2274
+
2275
+ // Nothing may tick while the world is being rewritten: a turn and an edit both
2276
+ // write the same store, and a turn landing mid-sync would fold against rows
2277
+ // the textarea no longer describes. So every ticker stops and every control
2278
+ // that could start one is disabled for as long as the editor is open.
2279
+ function setPlayControlsEnabled(on) {
2280
+ el("autoToggle").disabled = !on;
2281
+ el("playerCountSlider").disabled = !on;
2282
+ el("resetBtn").disabled = !on;
2283
+ for (const character of cast) {
2284
+ if (finished[character]) continue;
2285
+ el(paneIdFor(character) + "-play").disabled = !on;
2286
+ el(paneIdFor(character) + "-step").disabled = !on;
2287
+ }
2288
+ }
2289
+
2290
+ async function enterEditMode() {
2291
+ editing = true;
2292
+ autoOn = false;
2293
+ const playBtn = el("autoToggle");
2294
+ playBtn.setAttribute("aria-pressed", "false");
2295
+ playBtn.textContent = "\\u25B6 play";
2296
+ for (const character of cast) {
2297
+ const ticker = tickers[character];
2298
+ if (ticker) ticker.pause();
2299
+ }
2300
+ setPlayControlsEnabled(false);
2301
+ const snap = await session.snapshot();
2302
+ allStoreRows = snap.rows;
2303
+ editRows = worldOnlyRows(snap.rows);
2304
+ editState = window.tmctMud.foldWorldState(window.tmctMud.worldActionRows(editRows));
2305
+ el("editorText").value = renderMudEditorText(editRows, editState);
2306
+ el("editorStatus").className = "edit-status";
2307
+ el("editorStatus").textContent = "";
2308
+ el("editorPills").innerHTML = "";
2309
+ selectedRoomId = null;
2310
+ document.body.classList.add("editing");
2311
+ el("editModeBtn").textContent = "back to playing";
2312
+ el("editModeBtn").setAttribute("aria-pressed", "true");
2313
+ refreshEditPanels();
2314
+ }
2315
+
2316
+ async function exitEditMode() {
2317
+ editing = false;
2318
+ clearTimeout(syncTimer);
2319
+ // One last sync on the way out, so a change typed in the final half-second
2320
+ // before the button is clicked reaches the world like every other one.
2321
+ await applyEditorText();
2322
+ document.body.classList.remove("editing");
2323
+ el("editModeBtn").textContent = "edit";
2324
+ el("editModeBtn").setAttribute("aria-pressed", "false");
2325
+ setPlayControlsEnabled(true);
2326
+ await renderAll();
2327
+ }
2328
+
2329
+ // ---- the shared burrow: nodes, the wire, and the two-paste handshake -----
2330
+ // Every piece of networking arrives from ./vendor/p2p.js, the site's own
2331
+ // shared P2P asset, imported the first time somebody actually asks to share
2332
+ // rather than at boot. The room owns signaling, the mesh and the merge; this
2333
+ // block owns what a person sees and clicks, and nothing else.
2334
+ const P2P_ASSET = "./vendor/p2p.js";
2335
+ const NODE_NAME_KEY = "tmct.mud.nodeName";
2336
+ const invite = inviteParamsFrom(window.location.search);
2337
+
2338
+ let p2p = null;
2339
+ let p2pLoad = null;
2340
+ let room = null;
2341
+ let myPeerId = null;
2342
+ let myDisplayName = "";
2343
+ let worldId = "";
2344
+ let worldName = "";
2345
+ let nodeClockTimer = null;
2346
+
2347
+ function loadP2p() {
2348
+ if (!p2pLoad) p2pLoad = import(P2P_ASSET).then(function (mod) { p2p = mod; return mod; });
2349
+ return p2pLoad;
2350
+ }
2351
+ window.tmctP2pLoad = loadP2p;
2352
+
2353
+ function readStoredNodeName() {
2354
+ try { return localStorage.getItem(NODE_NAME_KEY) || ""; } catch { return ""; }
2355
+ }
2356
+ function writeStoredNodeName(name) {
2357
+ try { localStorage.setItem(NODE_NAME_KEY, name); } catch { /* private mode — the name still holds for this visit */ }
2358
+ }
2359
+
2360
+ async function ensureIdentity() {
2361
+ const mod = await loadP2p();
2362
+ if (!myPeerId) myPeerId = mod.generatePeerId();
2363
+ if (!myDisplayName) {
2364
+ myDisplayName = readStoredNodeName() || mod.generateDisplayName();
2365
+ el("nodeNameInput").value = myDisplayName;
2366
+ }
2367
+ if (!worldId) worldId = invite && invite.world ? invite.world : mod.generateWorldId();
2368
+ if (!worldName) {
2369
+ worldName = invite && invite.worldName ? invite.worldName : mod.generateDisplayName();
2370
+ el("worldNameInput").value = worldName;
2371
+ }
2372
+ }
2373
+
2374
+ async function ensureRoom() {
2375
+ if (room) return room;
2376
+ const mod = await loadP2p();
2377
+ await ensureIdentity();
2378
+ if (!session && bootRun) await bootRun;
2379
+ if (!session) throw new Error("the burrow hasn't finished opening");
2380
+ // The one place this page decides what is worth replicating: whatever the
2381
+ // engine calls live world state, plus the P2P layer's own four predicates.
2382
+ // The check is handed over rather than imported, so the sync filter never
2383
+ // learns the world engine's private predicate names.
2384
+ const extraPredicates = (window.tmctMud.P2P_PREDICATES || []).slice();
2385
+ const isState = window.tmctMud.isMudStatePredicate;
2386
+ room = mod.createP2pRoom({
2387
+ memoryDir: session.memoryDir,
2388
+ myPeerId: myPeerId,
2389
+ myDisplayName: myDisplayName,
2390
+ worldId: worldId,
2391
+ worldName: worldName,
2392
+ transportFactory: function () { return mod.createTransport({ iceServers: [] }); },
2393
+ syncableFacts: function (rows) { return mod.mudSyncableFacts(rows, isState, extraPredicates); },
2394
+ });
2395
+ room.onStateChanged(function (state) {
2396
+ // The join card exists to produce one reply. Once the channel is open it
2397
+ // has done its job, and a full-screen card over a live burrow is just
2398
+ // something in the way.
2399
+ if (state === "connected") el("joinCard").hidden = true;
2400
+ renderWire();
2401
+ });
2402
+ room.onPeersChanged(function () { renderNodes(); renderWire(); });
2403
+ room.onFactsChanged(function () { renderNodes(); renderSoon(); });
2404
+ await room.start();
2405
+ // The burrow's name is written into the graph the moment the room starts,
2406
+ // and this version retracts nothing — so the field stops taking edits.
2407
+ el("worldNameInput").readOnly = true;
2408
+ window.tmctP2pRoom = room;
2409
+ await claimMyAnimals();
2410
+ renderWire();
2411
+ renderNodes();
2412
+ if (!nodeClockTimer) nodeClockTimer = setInterval(renderNodes, 10000);
2413
+ return room;
2414
+ }
2415
+
2416
+ // Every animal this page drives is claimed the moment the burrow is shared,
2417
+ // panes and npcs alike: two peers each running scripted turns for one mole
2418
+ // would be two hands on one lemming. The claim is add-only and the oldest
2419
+ // wins, so a page that loses one simply stops driving that animal and
2420
+ // watches somebody else play it instead.
2421
+ async function claimMyAnimals() {
2422
+ if (!session || !room) return;
2423
+ await serializeTick(function () { return session.claimCharacters(everyone(), myPeerId); });
2424
+ await broadcastLocalChange();
2425
+ renderSoon();
2426
+ }
2427
+
2428
+ // The room diffs the store against what it last saw and broadcasts whatever
2429
+ // changed. The page calls it after every local turn, wave, claim and edit —
2430
+ // a broadcast that fails is a dead channel, reported by its own close
2431
+ // handler, and must never fail the turn that happened to carry it.
2432
+ function broadcastLocalChange() {
2433
+ if (!room) return Promise.resolve({ broadcast: 0 });
2434
+ return room.afterLocalChange().catch(function () { return { broadcast: 0 }; });
2435
+ }
2436
+
2437
+ function dropRoom(note) {
2438
+ if (!room) return;
2439
+ room.close();
2440
+ room = null;
2441
+ window.tmctP2pRoom = null;
2442
+ clearInterval(nodeClockTimer);
2443
+ nodeClockTimer = null;
2444
+ el("shareLink").value = "";
2445
+ el("replyOut").value = "";
2446
+ el("worldNameInput").readOnly = false;
2447
+ renderWire();
2448
+ renderNodes();
2449
+ // Last, not first: renderWire restates the note for the state it has just
2450
+ // read, which for a room that is gone is the idle one ("this browser holds
2451
+ // the only copy"). WHY the room went is the thing worth saying, so it is
2452
+ // written over the top rather than under it.
2453
+ if (note) el("wireStateNote").textContent = note;
2454
+ }
2455
+
2456
+ // The state machine is shared with chat.html and the words mostly are too.
2457
+ // Two of them are not: nothing is taught here and nothing is a node below,
2458
+ // so those two notes are said in this page's own terms rather than forking
2459
+ // the whole table for a burrow.
2460
+ const MUD_WIRE_NOTES = {
2461
+ idle: "this browser holds the only copy of this burrow.",
2462
+ connected: "what you dig from here reaches every node in the burrow, and theirs reaches you.",
2463
+ };
2464
+
2465
+ function renderWire() {
2466
+ const state = room ? room.state : "idle";
2467
+ const label = wireStateLabel(state);
2468
+ const note = MUD_WIRE_NOTES[state] || label.note;
2469
+ const wire = el("wireState");
2470
+ wire.dataset.tone = label.tone;
2471
+ el("wireStateWord").textContent = label.word;
2472
+ el("wireStateNote").textContent = note;
2473
+ const pill = el("statePill");
2474
+ pill.dataset.tone = label.tone;
2475
+ el("statePillWord").textContent = label.pill;
2476
+ pill.title = note;
2477
+ el("sharePanel").hidden = !el("shareLink").value;
2478
+ // Once the channel is open the reply has been used; leaving "send this
2479
+ // back" on screen would be asking for something already done.
2480
+ el("answerPanel").hidden = !el("replyOut").value || (room && room.state === "connected");
2481
+ }
2482
+
2483
+ function relativeWhen(at, nowMs) {
2484
+ if (at === null || at === undefined) return "\\u2014";
2485
+ const seconds = Math.max(0, Math.round((nowMs - at) / 1000));
2486
+ if (seconds < 5) return "now";
2487
+ if (seconds < 60) return seconds + "s";
2488
+ if (seconds < 3600) return Math.round(seconds / 60) + "m";
2489
+ if (seconds < 86400) return Math.round(seconds / 3600) + "h";
2490
+ return Math.round(seconds / 86400) + "d";
2491
+ }
2492
+
2493
+ function renderNodes() {
2494
+ const listEl = el("nodeList");
2495
+ if (!room || !p2p) {
2496
+ el("nodeCount").textContent = "";
2497
+ listEl.textContent = "";
2498
+ el("nodeEmpty").hidden = false;
2499
+ return;
2500
+ }
2501
+ const rows = nodeRowsFor({
2502
+ peers: room.peers(),
2503
+ factRows: room.factRows(),
2504
+ myPeerId: myPeerId,
2505
+ myDisplayName: myDisplayName,
2506
+ nameFor: room.displayNameFor,
2507
+ latestTimestampOf: p2p.latestProvenanceTimestamp,
2508
+ });
2509
+ const nowMs = Date.now();
2510
+ const waving = wavingCharacters(room.factRows(), nowMs);
2511
+ el("nodeCount").textContent = rows.length + (rows.length === 1 ? " node" : " nodes");
2512
+ listEl.textContent = "";
2513
+ for (const entry of rows) {
2514
+ const item = document.createElement("li");
2515
+ item.className = "node-row";
2516
+ item.dataset.self = String(entry.isSelf);
2517
+ item.dataset.away = String(!entry.connected);
2518
+ item.dataset.peer = entry.peerId;
2519
+ item.dataset.waving = String(waving.some(function (character) {
2520
+ const owner = claimantOf(room.factRows(), character);
2521
+ return owner !== null && peerTerm(owner) === peerTerm(entry.peerId);
2522
+ }));
2523
+ const avatar = document.createElement("span");
2524
+ avatar.className = "node-avatar";
2525
+ avatar.textContent = nodeInitials(entry.name);
2526
+ avatar.title = entry.connected
2527
+ ? "connected"
2528
+ : "away \\u2014 closed the tab or dropped offline; everything it dug stays";
2529
+ const name = document.createElement("span");
2530
+ name.className = "node-name";
2531
+ name.textContent = entry.name;
2532
+ const hand = document.createElement("span");
2533
+ hand.className = "node-hand";
2534
+ hand.textContent = "\\u{1F44B}";
2535
+ const when = document.createElement("span");
2536
+ when.className = "node-when";
2537
+ when.textContent = relativeWhen(entry.lastActiveAt, nowMs);
2538
+ item.appendChild(avatar);
2539
+ item.appendChild(name);
2540
+ if (entry.isSelf) {
2541
+ const self = document.createElement("span");
2542
+ self.className = "node-self";
2543
+ self.textContent = "you";
2544
+ item.appendChild(self);
2545
+ }
2546
+ item.appendChild(hand);
2547
+ item.appendChild(when);
2548
+ listEl.appendChild(item);
2549
+ }
2550
+ el("nodeEmpty").hidden = rows.length > 1;
2551
+ }
2552
+
2553
+ async function copyText(text) {
2554
+ try {
2555
+ if (navigator.clipboard && navigator.clipboard.writeText) {
2556
+ await navigator.clipboard.writeText(text);
2557
+ return true;
2558
+ }
2559
+ } catch { /* fall through — the box on the page still holds the text */ }
2560
+ return false;
2561
+ }
2562
+
2563
+ const openNetPanel = function () {
2564
+ el("netPanel").hidden = false;
2565
+ el("statePill").setAttribute("aria-expanded", "true");
2566
+ };
2567
+ const closeNetPanel = function () {
2568
+ el("netPanel").hidden = true;
2569
+ el("statePill").setAttribute("aria-expanded", "false");
2570
+ };
2571
+
2572
+ function wireNetPanel() {
2573
+ el("netPanelClose").addEventListener("click", closeNetPanel);
2574
+ el("statePill").addEventListener("click", function () {
2575
+ if (el("netPanel").hidden) openNetPanel(); else closeNetPanel();
2576
+ });
2577
+
2578
+ el("shareBtn").addEventListener("click", async function () {
2579
+ const shareBtn = el("shareBtn");
2580
+ shareBtn.disabled = true;
2581
+ try {
2582
+ const active = await ensureRoom();
2583
+ const minted = await active.startSharing();
2584
+ el("shareLink").value = inviteLinkFor(window.location.href, {
2585
+ blob: minted.blob, world: worldId, worldName: worldName,
2586
+ });
2587
+ el("replyProblem").hidden = true;
2588
+ openNetPanel();
2589
+ renderWire();
2590
+ await copyText(el("shareLink").value);
2591
+ } catch (err) {
2592
+ el("replyProblem").textContent = "couldn't create an invite (" + (err && err.message ? err.message : err) + ").";
2593
+ el("replyProblem").hidden = false;
2594
+ openNetPanel();
2595
+ } finally {
2596
+ shareBtn.disabled = false;
2597
+ }
2598
+ });
2599
+
2600
+ // The inviter's page has exactly one paste target, so there is no wrong box
2601
+ // to choose. A rejected paste keeps its text so the copy can be fixed.
2602
+ el("replyBtn").addEventListener("click", async function () {
2603
+ let active = room;
2604
+ if (!active) {
2605
+ try { active = await ensureRoom(); } catch { return; }
2606
+ }
2607
+ const outcome = await active.completeInvite(el("replyBox").value);
2608
+ if (outcome && outcome.error) {
2609
+ el("replyProblem").textContent = outcome.message;
2610
+ el("replyProblem").hidden = false;
2611
+ return;
2612
+ }
2613
+ el("replyProblem").hidden = true;
2614
+ el("replyBox").value = "";
2615
+ renderWire();
2616
+ });
2617
+
2618
+ el("copyReplyBtn").addEventListener("click", function () { copyText(el("replyOut").value); });
2619
+
2620
+ // The link flow lands somebody on the join card before they have dug
2621
+ // anything. This is the other case: a burrow already open, an invite that
2622
+ // arrived in a message, and no reason to throw the burrow away to take it.
2623
+ el("joinOpenBtn").addEventListener("click", function () {
2624
+ el("invitePanel").hidden = false;
2625
+ openNetPanel();
2626
+ el("inviteBox").focus();
2627
+ });
2628
+
2629
+ el("inviteBtn").addEventListener("click", async function () {
2630
+ const problem = el("inviteProblem");
2631
+ const pasted = el("inviteBox").value.trim();
2632
+ if (!pasted) {
2633
+ problem.textContent = "paste the invite you were sent, then try again";
2634
+ problem.hidden = false;
2635
+ return;
2636
+ }
2637
+ if (room) {
2638
+ problem.textContent = "this burrow is already shared. Reset it first, then take the invite.";
2639
+ problem.hidden = false;
2640
+ return;
2641
+ }
2642
+ // A whole link and a bare blob are both what people actually paste, so
2643
+ // both are read. The blob's own envelope decides either way.
2644
+ const blob = offerBlobIn(pasted);
2645
+ const mod = await loadP2p();
2646
+ const decoded = mod.decodeInviteBlob(blob);
2647
+ if (decoded.error || decoded.value.kind !== "offer") {
2648
+ problem.textContent = decoded.error
2649
+ ? "this invite looks cut short \\u2014 ask for it to be sent again"
2650
+ : "that's a reply, not an invite \\u2014 it belongs on the page that sent the invite";
2651
+ problem.hidden = false;
2652
+ return;
2653
+ }
2654
+ // The world id has to be the invite's before the room is opened: a room
2655
+ // that minted its own would refuse the invite as belonging elsewhere.
2656
+ await ensureIdentity();
2657
+ worldId = decoded.value.world || worldId;
2658
+ if (decoded.value.worldName) {
2659
+ worldName = decoded.value.worldName;
2660
+ el("worldNameInput").value = worldName;
2661
+ }
2662
+ try {
2663
+ const active = await ensureRoom();
2664
+ const outcome = await active.acceptInvite(blob);
2665
+ if (outcome && outcome.error) {
2666
+ problem.textContent = outcome.message;
2667
+ problem.hidden = false;
2668
+ return;
2669
+ }
2670
+ problem.hidden = true;
2671
+ el("inviteBox").value = "";
2672
+ el("replyOut").value = outcome.blob;
2673
+ renderWire();
2674
+ await copyText(outcome.blob);
2675
+ } catch (err) {
2676
+ problem.textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
2677
+ problem.hidden = false;
2678
+ }
2679
+ });
2680
+
2681
+ el("nodeNameInput").addEventListener("change", function () {
2682
+ const name = el("nodeNameInput").value.trim();
2683
+ if (!name || name === myDisplayName) { el("nodeNameInput").value = myDisplayName; return; }
2684
+ myDisplayName = name;
2685
+ writeStoredNodeName(name);
2686
+ if (!room) { renderNodes(); return; }
2687
+ room.setMyDisplayName(name).then(renderNodes).catch(function () { /* the next broadcast carries it */ });
2688
+ });
2689
+ el("worldNameInput").addEventListener("change", function () {
2690
+ const input = el("worldNameInput");
2691
+ if (input.readOnly) { input.value = worldName; return; }
2692
+ const name = input.value.trim();
2693
+ if (name) worldName = name;
2694
+ input.value = worldName;
2695
+ });
2696
+
2697
+ el("joinBtn").addEventListener("click", async function () {
2698
+ const joinBtn = el("joinBtn");
2699
+ joinBtn.disabled = true;
2700
+ try {
2701
+ const active = await ensureRoom();
2702
+ const outcome = await active.acceptInvite(invite.offer);
2703
+ if (outcome && outcome.error) {
2704
+ el("joinProblem").textContent = outcome.message;
2705
+ el("joinProblem").hidden = false;
2706
+ joinBtn.disabled = false;
2707
+ joinBtn.textContent = "try again";
2708
+ return;
2709
+ }
2710
+ el("joinProblem").hidden = true;
2711
+ el("joinReply").value = outcome.blob;
2712
+ el("replyOut").value = outcome.blob;
2713
+ el("joinReplyWrap").hidden = false;
2714
+ joinBtn.textContent = "reply created";
2715
+ el("joinDismiss").textContent = "close this and start digging";
2716
+ renderWire();
2717
+ await copyText(outcome.blob);
2718
+ } catch (err) {
2719
+ el("joinProblem").textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
2720
+ el("joinProblem").hidden = false;
2721
+ joinBtn.disabled = false;
2722
+ }
2723
+ });
2724
+ el("joinCopyBtn").addEventListener("click", function () { copyText(el("joinReply").value); });
2725
+ el("joinDismiss").addEventListener("click", function () {
2726
+ el("joinCard").hidden = true;
2727
+ if (el("replyOut").value) openNetPanel();
2728
+ });
2729
+ }
2730
+
2731
+ // A link that lost part of itself on the way reads as a mangled link, never
2732
+ // as no invite at all.
2733
+ async function prepareJoinCard() {
2734
+ el("joinCard").hidden = false;
2735
+ el("joinWorld").textContent = invite.worldName || "a shared burrow";
2736
+ el("joinBtn").focus();
2737
+ const mod = await loadP2p();
2738
+ const decoded = mod.decodeInviteBlob(invite.offer);
2739
+ if (decoded.error || decoded.value.kind !== "offer") {
2740
+ el("joinBtn").hidden = true;
2741
+ el("joinEyebrow").textContent = "this link didn't arrive in one piece";
2742
+ el("joinWorld").textContent = invite.worldName || "an invitation";
2743
+ el("joinBody").textContent = decoded.error
2744
+ ? "Part of the link was lost on the way here. Ask for it to be sent again, and check the whole thing travels."
2745
+ : "That link carries a reply rather than an invite. It belongs in the box on the page that sent the invite.";
2746
+ el("joinDismiss").textContent = "dig on your own instead";
2747
+ return;
2748
+ }
2749
+ if (decoded.value.world) invite.world = decoded.value.world;
2750
+ if (decoded.value.worldName) {
2751
+ invite.worldName = decoded.value.worldName;
2752
+ el("joinWorld").textContent = decoded.value.worldName;
2753
+ }
2754
+ }
2755
+
2756
+ // ---- the control deck ----------------------------------------------------
2757
+ // The players slider runs on detents (1, 2, 4), so its own value is the
2758
+ // index of the count, never the count.
2759
+ function chosenPlayerCount() {
2760
+ const picked = DATA.playerCounts[Number(el("playerCountSlider").value)];
2761
+ return picked || DATA.defaultPlayerCount;
2762
+ }
2763
+
2764
+ function showPlayerCount(count) {
2765
+ el("playerCountValue").textContent = String(count);
2766
+ el("playerCountSlider").setAttribute("aria-valuetext", count + (count === 1 ? " player" : " players"));
2767
+ }
2768
+
2769
+ // The npcs slider carries its own count, so it needs no detent table.
2770
+ function chosenNpcCount() {
2771
+ const picked = Number(el("npcCountSlider").value);
2772
+ return Number.isFinite(picked) ? picked : DATA.defaultNpcCount;
2773
+ }
2774
+
2775
+ function showNpcCount(count) {
2776
+ el("npcCountValue").textContent = String(count);
2777
+ el("npcCountSlider").setAttribute("aria-valuetext", count + (count === 1 ? " npc" : " npcs"));
2778
+ }
2779
+
2780
+ function wireDeck() {
2781
+ const playBtn = el("autoToggle");
690
2782
  const delaySlider = el("delaySlider");
691
2783
  const maxTurnsSlider = el("maxTurnsSlider");
692
- const resetBtn = el("resetBtn");
693
- autoBtn.addEventListener("click", function () {
2784
+ const playerCountSlider = el("playerCountSlider");
2785
+ const npcCountSlider = el("npcCountSlider");
2786
+ playBtn.addEventListener("click", function () {
2787
+ if (!session) return;
694
2788
  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];
2789
+ playBtn.setAttribute("aria-pressed", autoOn ? "true" : "false");
2790
+ playBtn.textContent = autoOn ? "\\u23F8 pause" : "\\u25B6 play";
2791
+ // Every animal in the world, pane or none: the deck's play control is the
2792
+ // world running, and an npc has no other control that could start it.
2793
+ for (const character of everyone()) {
2794
+ const ticker = tickers[character];
699
2795
  if (!ticker) continue;
700
2796
  if (autoOn) ticker.play(); else ticker.pause();
701
2797
  }
@@ -708,45 +2804,171 @@ function pageScript() {
708
2804
  maxTurns = Number(maxTurnsSlider.value);
709
2805
  el("maxTurnsValue").textContent = String(maxTurns);
710
2806
  });
711
- resetBtn.addEventListener("click", function () { boot(); });
2807
+ // The readout follows the thumb, but a new cast waits for the drag to
2808
+ // settle: booting on every intermediate detent would open, and throw
2809
+ // away, a whole shared world per step.
2810
+ playerCountSlider.addEventListener("input", function () { showPlayerCount(chosenPlayerCount()); });
2811
+ playerCountSlider.addEventListener("change", function () { boot(); });
2812
+ npcCountSlider.addEventListener("input", function () { showNpcCount(chosenNpcCount()); });
2813
+ npcCountSlider.addEventListener("change", function () { boot(); });
2814
+ el("resetBtn").addEventListener("click", function () { boot(); });
2815
+ // Picking a burrow is a recast like any other, so it runs the same boot
2816
+ // the sliders and reset run. Edit mode follows the world rather than the
2817
+ // page: whatever is half-typed is flushed into the burrow it was typed
2818
+ // over, and the editor reopens on the one that just loaded.
2819
+ const scenarioSelect = el("scenarioSelect");
2820
+ if (scenarioSelect) {
2821
+ scenarioSelect.addEventListener("change", async function () {
2822
+ const picked = Number(scenarioSelect.value);
2823
+ if (!DATA.scenarios[picked] || picked === scenarioIndex) return;
2824
+ const wasEditing = editing;
2825
+ if (wasEditing) await exitEditMode();
2826
+ scenarioIndex = picked;
2827
+ await boot("a different burrow opened \\u2014 share again to link up.");
2828
+ if (wasEditing) await enterEditMode();
2829
+ });
2830
+ }
2831
+ el("editModeBtn").addEventListener("click", function () {
2832
+ if (!session) return;
2833
+ if (editing) exitEditMode(); else enterEditMode();
2834
+ });
2835
+
2836
+ const infoBtn = el("deckInfoBtn");
2837
+ const infoPopup = el("deckInfoPopup");
2838
+ function openDeckInfo() {
2839
+ infoPopup.hidden = false;
2840
+ infoBtn.setAttribute("aria-expanded", "true");
2841
+ }
2842
+ function closeDeckInfo() {
2843
+ infoPopup.hidden = true;
2844
+ infoBtn.setAttribute("aria-expanded", "false");
2845
+ }
2846
+ infoBtn.addEventListener("click", function () { if (infoPopup.hidden) openDeckInfo(); else closeDeckInfo(); });
2847
+ el("deckInfoClose").addEventListener("click", closeDeckInfo);
2848
+ document.addEventListener("keydown", function (e) { if (e.key === "Escape" && !infoPopup.hidden) closeDeckInfo(); });
2849
+ document.addEventListener("click", function (e) {
2850
+ if (infoPopup.hidden) return;
2851
+ if (e.target === infoBtn || infoPopup.contains(e.target)) return;
2852
+ closeDeckInfo();
2853
+ });
712
2854
  }
713
2855
 
714
- function updateGlobalTurnLabel() {
715
- el("globalTurnCount").textContent = "turns: " + globalTurn;
2856
+ // The stage is the cast's, so it is rebuilt for the cast: one paneMarkup
2857
+ // call per animal, the count on the stage itself for the grid to lay out
2858
+ // against, and a fresh set of listeners for the panes that just appeared.
2859
+ function renderStage() {
2860
+ const stage = el("mudStage");
2861
+ stage.setAttribute("data-panes", String(slots.length));
2862
+ stage.innerHTML = slots.map(function (slot) { return paneMarkup(slot); }).join("");
2863
+ for (const slot of slots) wirePane(slot);
716
2864
  }
717
2865
 
718
- async function boot() {
719
- for (const c of characters) {
720
- const ticker = tickers[c.id];
721
- if (ticker) ticker.pause();
2866
+ function bindPanes() {
2867
+ for (let i = 0; i < slots.length; i += 1) {
2868
+ const w = "window-" + slots[i];
2869
+ const character = cast[i];
2870
+ const pane = el(w);
2871
+ pane.setAttribute("data-character", character);
2872
+ pane.setAttribute("aria-label", speciesOfCharacter(character) + "'s own view of the shared world");
2873
+ el(w + "-name").innerHTML = esc(speciesOfCharacter(character)) + '<span class="mono char-id">' + esc(character) + "</span>";
2874
+ el(w + "-chatq").setAttribute("aria-label", "type a command for " + character);
722
2875
  }
2876
+ }
2877
+
2878
+ // Booting builds the shared world and draws the opening state. It never
2879
+ // starts a turn: nothing ticks until the deck's own play control (or a
2880
+ // pane's) is clicked. Opening the world is asynchronous and the controls
2881
+ // that boot are not, so a boot overtaken by a later one drops its own
2882
+ // session on the floor rather than binding it to the stage the newer boot
2883
+ // has already drawn.
2884
+ let bootSeq = 0;
2885
+ let bootRun = null;
2886
+ function boot(note) {
2887
+ bootRun = openWorld(note);
2888
+ return bootRun;
2889
+ }
2890
+
2891
+ async function openWorld(note) {
2892
+ const seq = bootSeq += 1;
2893
+ autoOn = false;
2894
+ // A room is bound to the store it was opened over, and recasting mints a
2895
+ // brand new one — so a burrow that restarts leaves the link behind rather
2896
+ // than quietly syncing a world nobody else is in. Saying so is the whole
2897
+ // handling; sharing again opens a fresh link over the new world. Picking a
2898
+ // different burrow is the same drop for the same reason, and says so in
2899
+ // its own words.
2900
+ dropRoom(note || "the burrow restarted \\u2014 share again to link up.");
2901
+ claimedElsewhere = {};
2902
+ wavingNow = [];
2903
+ const playBtn = el("autoToggle");
2904
+ playBtn.setAttribute("aria-pressed", "false");
2905
+ playBtn.textContent = "\\u25B6 play";
2906
+ for (const character of Object.keys(tickers)) tickers[character].pause();
2907
+ tickers = {};
723
2908
  globalTurn = 0;
724
- knownRoomIds.clear();
725
2909
  freshlyDugRoom = null;
2910
+ lastSnapshot = null;
726
2911
  speechBubbles.clear();
727
2912
  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;
2913
+ // Rebuilt rather than pruned: a character that had a pane last boot can be
2914
+ // an npc in this one, and a stale seat would send its ending to a pane now
2915
+ // showing somebody else.
2916
+ finished = {};
2917
+ turnsTaken = {};
2918
+ slotOf = {};
2919
+
2920
+ // ONE draw, the engine's own, and the same list the session is built from:
2921
+ // a second independent draw here would leave the page showing animals the
2922
+ // shared world was never opened for. The stage follows the draw rather
2923
+ // than the slider, so a roster too small for the chosen count draws every
2924
+ // animal it has and no empty pane. The npcs draw from what the panes left,
2925
+ // out of a roster grown to hold everyone, so no animal is ever both.
2926
+ const npcCount = chosenNpcCount();
2927
+ // Read fresh, never cached at load: each burrow authors its own animals,
2928
+ // so the roster belongs to the scenario rather than to the page.
2929
+ roster = rosterOf(scenario());
2930
+ rootRoom = rootRoomOf(scenario());
2931
+ cast = window.tmctMud.pickMudRoster(roster, { count: chosenPlayerCount() });
2932
+ const pool = window.tmctMud.expandMudRoster(roster, cast.length + npcCount)
2933
+ .filter(function (id) { return cast.indexOf(id) === -1; });
2934
+ npcs = window.tmctMud.pickMudRoster(pool, { count: npcCount });
2935
+ slots = DATA.paneSlots.slice(0, cast.length);
2936
+ showPlayerCount(cast.length);
2937
+ showNpcCount(npcs.length);
2938
+ renderStage();
2939
+ const opened = await window.tmctMud.createMudSession(scenario().worldPayload, { characters: everyone() });
2940
+ if (seq !== bootSeq) return;
2941
+ session = opened;
2942
+ for (let i = 0; i < cast.length; i += 1) slotOf[cast[i]] = slots[i];
2943
+ bindPanes();
2944
+ for (const character of cast) {
2945
+ const w = paneIdFor(character);
735
2946
  el(w + "-play").disabled = false;
736
2947
  el(w + "-step").disabled = false;
737
2948
  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
2949
  }
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();
2950
+ for (const character of everyone()) {
2951
+ turnsTaken[character] = 0;
2952
+ ensureTicker(character);
2953
+ }
2954
+ renderSoon();
747
2955
  }
748
2956
 
749
- wireRail();
2957
+ wireDeck();
2958
+ wireNetPanel();
2959
+ renderWire();
2960
+ renderNodes();
750
2961
  boot();
2962
+ // Off the boot path on purpose: this fetches the shared P2P asset so the
2963
+ // panel can show a real node name and burrow name before anyone clicks
2964
+ // anything. A failure here costs sharing, never the digging.
2965
+ ensureIdentity().catch(function () { /* sharing stays unavailable; the burrow does not */ });
2966
+ if (invite) {
2967
+ prepareJoinCard().catch(function (err) {
2968
+ el("joinProblem").textContent = "the networking asset didn't load (" + (err && err.message ? err.message : err) + ").";
2969
+ el("joinProblem").hidden = false;
2970
+ el("joinBtn").hidden = true;
2971
+ });
2972
+ }
751
2973
  })();`;
752
2974
  }