@polycode-projects/the-mechanical-code-talker 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +178 -9
  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 +401 -0
  6. package/corpus/worlds/index.json.gz +0 -0
  7. package/corpus/worlds/manifest.json +49 -9
  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-hollow.jsonl.gz +0 -0
  11. package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
  12. package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
  13. package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
  14. package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
  15. package/corpus/worlds/src/mud-hollow.jsonl +82 -0
  16. package/corpus/worlds/src/mud-warren.jsonl +124 -0
  17. package/corpus/worlds/src/spider-fly.jsonl +1 -1
  18. package/data/sprites/animal-icon.toml +11 -5
  19. package/data/sprites/book-icon.toml +9 -5
  20. package/data/sprites/cabinet-icon.toml +10 -5
  21. package/data/sprites/cellar-icon.toml +16 -6
  22. package/data/sprites/container-icon.toml +7 -3
  23. package/data/sprites/desk-icon.toml +8 -5
  24. package/data/sprites/dog-icon.toml +8 -5
  25. package/data/sprites/dog-with-colour-icon.toml +13 -12
  26. package/data/sprites/drawing-room-icon.toml +14 -5
  27. package/data/sprites/egg-icon.toml +6 -3
  28. package/data/sprites/fly-icon.toml +10 -5
  29. package/data/sprites/furniture-icon.toml +5 -3
  30. package/data/sprites/garden-icon.toml +10 -3
  31. package/data/sprites/key-icon.toml +3 -1
  32. package/data/sprites/kitchen-icon.toml +15 -6
  33. package/data/sprites/lamp-icon.toml +9 -4
  34. package/data/sprites/letter-icon.toml +7 -4
  35. package/data/sprites/library-icon.toml +14 -3
  36. package/data/sprites/pan-icon.toml +7 -3
  37. package/data/sprites/person-icon.toml +6 -2
  38. package/data/sprites/poodle-icon.toml +1 -1
  39. package/data/sprites/portable-icon.toml +6 -4
  40. package/data/sprites/portrait-icon.toml +7 -4
  41. package/data/sprites/room-icon.toml +11 -2
  42. package/data/sprites/spider-icon.toml +9 -3
  43. package/data/sprites/study-icon.toml +10 -2
  44. package/package.json +5 -4
  45. package/src/adapters/memory/core.mjs +20 -0
  46. package/src/domain/ask-vocab.mjs +71 -0
  47. package/src/domain/ask.mjs +168 -0
  48. package/src/domain/game-config.mjs +11 -0
  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 +18 -0
  52. package/src/domain/mud-facts.mjs +15 -0
  53. package/src/domain/reference-pack.mjs +31 -7
  54. package/src/domain/router/drive.mjs +35 -9
  55. package/src/domain/router/registry.mjs +24 -4
  56. package/src/domain/router/resolver.mjs +102 -40
  57. package/src/domain/scene-compose.mjs +117 -0
  58. package/src/domain/spider-fly-world.mjs +37 -1
  59. package/src/domain/sprite-facts.mjs +0 -0
  60. package/src/domain/sprite-request.mjs +156 -0
  61. package/src/domain/sprite-templates.mjs +169 -20
  62. package/src/services/adventure-editor.mjs +8 -14
  63. package/src/services/adventure-viz.mjs +209 -157
  64. package/src/services/adventure.mjs +526 -391
  65. package/src/services/chat-page-viz.mjs +69 -25
  66. package/src/services/chat.mjs +200 -51
  67. package/src/services/code-explorer-viz.mjs +102 -62
  68. package/src/services/extract-facts.mjs +4 -7
  69. package/src/services/ingest-viz.mjs +68 -82
  70. package/src/services/ledger-viz.mjs +136 -67
  71. package/src/services/memory-panel-viz.mjs +62 -0
  72. package/src/services/mud-editor.mjs +10 -15
  73. package/src/services/mud-turn.mjs +6 -6
  74. package/src/services/mud-viz.mjs +1016 -208
  75. package/src/services/p2p-room.mjs +90 -23
  76. package/src/services/plan-pddl.mjs +3 -1
  77. package/src/services/plan-viz.mjs +123 -64
  78. package/src/services/research-viz.mjs +160 -108
  79. package/src/services/spider-fly-turn.mjs +15 -23
  80. package/src/services/spider-fly-viz.mjs +146 -161
  81. package/src/services/spider-fly.mjs +69 -11
  82. package/src/services/sprite-catalog-viz.mjs +414 -240
  83. package/src/services/viz-boot.mjs +71 -0
  84. package/src/services/viz-room-graph.mjs +203 -0
  85. package/src/services/viz-theme.mjs +90 -1
  86. package/src/services/viz-ticker.mjs +22 -0
  87. package/src/surfaces/web/adventure-browser-entry.mjs +49 -33
  88. package/src/surfaces/web/chat-browser-entry.mjs +30 -105
  89. package/src/surfaces/web/code-explorer-browser-entry.mjs +168 -24
  90. package/src/surfaces/web/ingest-browser-entry.mjs +3 -13
  91. package/src/surfaces/web/ledger-browser-entry.mjs +7 -47
  92. package/src/surfaces/web/memory-ask-browser.bundle.js +127 -124
  93. package/src/surfaces/web/memory-stats.mjs +11 -0
  94. package/src/surfaces/web/mud-browser-entry.mjs +71 -29
  95. package/src/surfaces/web/plan-browser-entry.mjs +22 -40
  96. package/src/surfaces/web/research-browser-entry.mjs +26 -41
  97. package/src/surfaces/web/spider-fly-browser-entry.mjs +45 -28
  98. package/src/surfaces/web/sprites-browser-entry.mjs +14 -27
  99. package/src/surfaces/web/turn-session.mjs +120 -0
  100. package/src/tools/definitions.mjs +30 -0
  101. package/src/tools/handlers/index.mjs +6 -3
  102. package/src/tools/handlers/kit.mjs +19 -2
  103. package/src/tools/handlers/tmct-ask.mjs +11 -6
  104. package/src/tools/handlers/tmct-ingest.mjs +5 -1
  105. package/src/tools/handlers/tmct-related.mjs +4 -4
  106. package/src/tools/handlers/tmct-sprite.mjs +147 -0
  107. package/src/tools/memory-fallthrough.mjs +9 -2
  108. package/src/tools/server.mjs +25 -1
@@ -57,30 +57,40 @@
57
57
  // The burrow survey is ONE svg routine (burrowSvg over burrowGraph) drawn
58
58
  // twice: the omniscient board in the top row, and each pane's own
59
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).
60
+ // burrowGraph/burrowSvg are thin wrappers over viz-room-graph.mjs's shared
61
+ // directedGridLayout/roomGraphSvg (the same pair adventure-viz.mjs draws its
62
+ // own manor board from), passing the `root`/`nudgeCollisions`/`turf` options a
63
+ // burrow needs and a manor does not (a mud character can dig down AND south
64
+ // out of one room, and both land on the same grid cell otherwise).
64
65
  //
65
66
  // ONE ticker instance per pane (viz-ticker.mjs's createTicker), so
66
67
  // each can play/step independently, plus the deck's own "play" control that
67
68
  // calls .play()/.pause() on both pane tickers at once. Nothing plays until
68
69
  // that control is clicked. Every tick, from ANY pane, is funneled through one
69
- // shared async queue (serializeTick, spliced below) so two characters' turns
70
- // can never interleave their reads/writes of the one shared memoryDir —
71
- // mud-browser-entry.mjs's own header names this as the caller's
72
- // responsibility, and this is where that responsibility is discharged. The
73
- // queue is also what makes the deck's GLOBAL turn counter well-defined: it
74
- // increments in the exact order turns actually executed, never a race
75
- // between panes.
76
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
77
- import { createTicker } from "./viz-ticker.mjs";
70
+ // shared async queue (serializeTick, wrapping viz-ticker.mjs's own
71
+ // createSerialQueue) so two characters' turns can never interleave their
72
+ // reads/writes of the one shared memoryDir — mud-browser-entry.mjs's own
73
+ // header names this as the caller's responsibility, and this is where that
74
+ // responsibility is discharged. The queue is also what makes the deck's
75
+ // GLOBAL turn counter well-defined: it increments in the exact order turns
76
+ // actually executed, never a race between panes.
77
+ import {
78
+ THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, scenarioLabel,
79
+ wordBeforeCursor, rowsForWorld, appendLogLine,
80
+ } from "./viz-theme.mjs";
81
+ import { createTicker, createSerialQueue } from "./viz-ticker.mjs";
82
+ import { directedGridLayout, roomGraphSvg, levelsOf, EXIT_DELTA } from "./viz-room-graph.mjs";
78
83
  import {
79
84
  roomSceneObjects, scenePlacement, spriteClassForObject, spriteAncestryRows, factsForSubject,
80
85
  visibleRoomOf, roomKindForRoom, allRoomIds,
81
86
  } from "./adventure-viz.mjs";
82
- import { renderMudEditorText, wordBeforeCursor } from "./mud-editor.mjs";
87
+ import { renderMudEditorText } from "./mud-editor.mjs";
88
+ import {
89
+ wireStateLabel, nodeRowsFor, nodeInitials, inviteLinkFor, inviteParamsFrom,
90
+ } from "./chat-page-viz.mjs";
83
91
  import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
92
+ import { fnv1a32 } from "../domain/hash.mjs";
93
+ import { predatorSubjects } from "../domain/mud-facts.mjs";
84
94
 
85
95
  const DEFAULT_TITLE = "tmct — the mud";
86
96
  // No display-face embedding pipeline exists anywhere in this project yet
@@ -110,6 +120,7 @@ const DEFAULT_MAX_TURNS = 400;
110
120
  const MUD_NOTE_LINES = [
111
121
  "Burrowing animals share one world here. The players slider picks how many get a window of their own; the npcs slider adds more animals that dig and forage without one. Each one only knows what it has dug up, asked about, or been told. Nobody sees the whole burrow except you, watching from the survey above.",
112
122
  `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.`,
123
+ "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.",
113
124
  ];
114
125
 
115
126
  /** A character id's species — "mole-1" -> "mole", "groundhog-1" ->
@@ -146,22 +157,9 @@ export function carriedItemsFor(rows, state, character) {
146
157
  * keeps the level unchanged — what tells the survey where the turf line
147
158
  * falls. A room unreachable from `root` (should not happen — every dug room
148
159
  * writes a two-way exit back) is simply absent from the map rather than
149
- * guessed at. Pure, self-contained. */
150
- export function levelsOf(state, root = "garden") {
151
- const levels = new Map([[root, 0]]);
152
- const queue = [root];
153
- while (queue.length) {
154
- const room = queue.shift();
155
- const level = levels.get(room);
156
- for (const [direction, target] of state.exits.get(room) ?? []) {
157
- if (levels.has(target)) continue;
158
- const delta = direction === "down" ? -1 : direction === "up" ? 1 : 0;
159
- levels.set(target, level + delta);
160
- queue.push(target);
161
- }
162
- }
163
- return levels;
164
- }
160
+ * guessed at. Re-exported from viz-room-graph.mjs's shared implementation,
161
+ * which this module also splices into its own inline page script. */
162
+ export { levelsOf };
165
163
 
166
164
  /** Which characters currently stand in `room` — the survey's own per-room
167
165
  * roster, both for the omniscient board and a pane's own visited-only one.
@@ -172,62 +170,14 @@ export function charactersInRoom(state, room, characters) {
172
170
 
173
171
  /** The rooms in `roomIds` laid out on an integer grid FROM the world's own
174
172
  * has-exit-* directions — never a force-directed guess, so a room north of
175
- * another sits one row above it and a room dug DOWN sits one row below.
176
- * Adapted from adventure-viz.mjs's `visitedRoomGraph`, with two differences
177
- * a burrow needs and a manor does not: a cell already taken nudges right
178
- * rather than stacking two rooms on one square (dig down and dig south from
179
- * the same room otherwise collide), and every node carries its own `level`
180
- * so the renderer can draw the turf line between the surface and the soil.
181
- * An edge is drawn only between two rooms BOTH in `roomIds`; `hints` names
182
- * every exit from an included room toward one that is not — the direction
183
- * only, never the excluded room's own name, so a fog-of-war caller can draw
184
- * "there's a way on" and nothing more. Pure. */
173
+ * another sits one row above it and a room dug DOWN sits one row below. A
174
+ * thin wrapper over viz-room-graph.mjs's shared `directedGridLayout`, always
175
+ * passing `nudgeCollisions: true` the one thing a burrow needs that a
176
+ * manor does not: a cell already taken nudges right rather than stacking two
177
+ * rooms on one square (dig down and dig south from the same room otherwise
178
+ * collide). Pure. */
185
179
  export function burrowGraph(state, roomIds, root = "garden") {
186
- const DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
187
- const known = new Set(roomIds || []);
188
- const positions = new Map();
189
- const taken = new Set();
190
- const edges = [];
191
- const edgeKeys = new Set();
192
- const hints = [];
193
- const place = (room, x, y) => {
194
- let px = x;
195
- for (let guard = 0; taken.has(px + "," + y) && guard < 64; guard += 1) px += 1;
196
- positions.set(room, { x: px, y });
197
- taken.add(px + "," + y);
198
- };
199
- let offsetX = 0;
200
- for (const start of [root, ...[...known].sort()]) {
201
- if (!known.has(start) || positions.has(start)) continue;
202
- place(start, offsetX, 0);
203
- const queue = [start];
204
- while (queue.length) {
205
- const room = queue.shift();
206
- const pos = positions.get(room);
207
- const dirs = state.exits.get(room);
208
- for (const direction of [...(dirs?.keys() ?? [])].sort()) {
209
- const target = dirs.get(direction);
210
- if (!known.has(target)) { hints.push({ from: room, direction }); continue; }
211
- const key = [room, target].sort().join("\0");
212
- if (!edgeKeys.has(key)) { edgeKeys.add(key); edges.push({ from: room, to: target, direction }); }
213
- if (positions.has(target)) continue;
214
- const d = DELTA[direction] ?? [0, 0];
215
- place(target, pos.x + d[0], pos.y + d[1]);
216
- queue.push(target);
217
- }
218
- }
219
- offsetX = Math.max(...[...positions.values()].map((p) => p.x)) + 2;
220
- }
221
- const levels = levelsOf(state, root);
222
- const xs = [...positions.values()].map((p) => p.x);
223
- const ys = [...positions.values()].map((p) => p.y);
224
- const minX = Math.min(0, ...xs);
225
- const minY = Math.min(0, ...ys);
226
- const nodes = [...known].filter((room) => positions.has(room)).sort().map((room) => {
227
- const p = positions.get(room);
228
- return { id: room, x: p.x - minX, y: p.y - minY, level: levels.has(room) ? levels.get(room) : null };
229
- });
230
- return { nodes, edges, hints };
180
+ return directedGridLayout(state, roomIds, { root, nudgeCollisions: true });
231
181
  }
232
182
 
233
183
  /** The directions `here` can still be dug in: every direction the room's kind
@@ -269,6 +219,106 @@ export function isCreature(state, subject) {
269
219
  return state.placements.get(subject)?.predicate === "mgx:currently-in";
270
220
  }
271
221
 
222
+ // ---- the shared burrow: reading the P2P layer's own facts back ---------------
223
+ // All four readers below work on plain fact rows and nothing else, so the page
224
+ // draws a claim, a wave and a node name identically whether the world is shared
225
+ // with three machines or with none. Pure and `.toString()`-splice safe.
226
+
227
+ /** Every assertion time a provenance string carries, in milliseconds. A tag is
228
+ * `<kind>:<id>@<ISO>`, and a fact asserted more than once holds several joined
229
+ * by " | " — a peer's relabelled copy of a tag keeps the time the fact was
230
+ * actually asserted, so reading them all back is what lets a claim be settled
231
+ * by its oldest and a wave by its newest. Pure, self-contained. */
232
+ export function provenanceStamps(provenance) {
233
+ const stamps = [];
234
+ const text = String(provenance || "");
235
+ const pattern = /@(\d{4}-\d{2}-\d{2}T[0-9:.]+Z)/g;
236
+ let found = pattern.exec(text);
237
+ while (found !== null) {
238
+ const at = Date.parse(found[1]);
239
+ if (!Number.isNaN(at)) stamps.push(at);
240
+ found = pattern.exec(text);
241
+ }
242
+ return stamps;
243
+ }
244
+
245
+ /** A peer id as the fact store actually holds it. `appendFacts` normalizes a
246
+ * term by stripping its CURIE prefix and lowercasing, so the `peer:<id>` a
247
+ * claim is written with reads back as the bare id — a comparison against a
248
+ * raw peer id has to normalize the same way or it never matches. Pure,
249
+ * self-contained. */
250
+ export function peerTerm(peerId) {
251
+ return String(peerId || "").replace(/^[a-z][\w.-]*:/i, "").toLowerCase();
252
+ }
253
+
254
+ /** Which peer plays `character`, or null when nobody has claimed it. Claims are
255
+ * add-only and never retracted, so two peers claiming the same animal both
256
+ * write and every page settles it the same way: the OLDEST claim wins, with
257
+ * the lower peer term breaking a dead heat so two machines reading the same
258
+ * rows can never disagree. Pure, self-contained (provenanceStamps is spliced
259
+ * alongside it). */
260
+ export function claimantOf(rows, character) {
261
+ let owner = null;
262
+ let ownerAt = Infinity;
263
+ for (const row of rows || []) {
264
+ if (row.subject !== character || row.predicate !== "mgx:playedBy") continue;
265
+ let at = Infinity;
266
+ for (const stamp of provenanceStamps(row.provenance)) if (stamp < at) at = stamp;
267
+ if (at < ownerAt || (at === ownerAt && owner !== null && String(row.object) < owner)) {
268
+ ownerAt = at;
269
+ owner = String(row.object);
270
+ }
271
+ }
272
+ return owner;
273
+ }
274
+
275
+ /** Which characters are waving right now — a read-time recency question over
276
+ * the newest tag on each `mgx:waved` fact, never stored state and never a
277
+ * retraction. A wave older than the window simply stops being read as one.
278
+ * Pure, self-contained. */
279
+ export function wavingCharacters(rows, nowMs, windowMs = 8000) {
280
+ const waving = [];
281
+ for (const row of rows || []) {
282
+ if (row.predicate !== "mgx:waved") continue;
283
+ let newest = null;
284
+ for (const stamp of provenanceStamps(row.provenance)) if (newest === null || stamp > newest) newest = stamp;
285
+ if (newest === null) continue;
286
+ const age = nowMs - newest;
287
+ if (age < 0 || age > windowMs) continue;
288
+ if (waving.indexOf(row.subject) === -1) waving.push(row.subject);
289
+ }
290
+ return waving;
291
+ }
292
+
293
+ /** The offer blob inside whatever somebody actually pasted: the `offer`
294
+ * parameter of a whole invite link, or the bare blob when that is what
295
+ * arrived. Both are what people really paste, and telling them apart by
296
+ * looking is cheaper than asking them to. Pure, self-contained. */
297
+ export function offerBlobIn(pasted) {
298
+ const text = String(pasted || "").trim();
299
+ const marker = text.indexOf("offer=");
300
+ if (marker === -1) return text;
301
+ const rest = text.slice(marker + "offer=".length);
302
+ const end = rest.search(/[&#\s]/);
303
+ return decodeURIComponent(end === -1 ? rest : rest.slice(0, end));
304
+ }
305
+
306
+ /** The name a peer chose for its node, from its latest `mgx:nodeName` fact,
307
+ * falling back to a shortened peer id. A label never waits for a name to
308
+ * arrive. Pure, self-contained. */
309
+ export function nodeNameFor(rows, peerId) {
310
+ const want = peerTerm(peerId);
311
+ let name = null;
312
+ let nameAt = -Infinity;
313
+ for (const row of rows || []) {
314
+ if (row.predicate !== "mgx:nodeName" || peerTerm(row.subject) !== want) continue;
315
+ let at = -Infinity;
316
+ for (const stamp of provenanceStamps(row.provenance)) if (stamp > at) at = stamp;
317
+ if (at >= nameAt) { nameAt = at; name = String(row.object); }
318
+ }
319
+ return name || want.slice(0, 8);
320
+ }
321
+
272
322
  /** The self-contained mud.html page. Pure — identical output for identical
273
323
  * input; every other piece of state (the live world, chat, ticks) is
274
324
  * computed in the browser once the sibling bundle loads.
@@ -280,7 +330,12 @@ export function isCreature(state, subject) {
280
330
  * the roster cannot fill draws as many panes as it has animals.
281
331
  * `worldPayload` is `{ name, facts, rules, opening }`, read once at build
282
332
  * time through the real worlds-pack provider (see this module's own
283
- * header). `spriteTemplates` is the
333
+ * header). `scenarios` is the list of burrows the deck's dropdown offers,
334
+ * `[{ label, worldPayload, characters }]` — each one a whole world with its
335
+ * own roster, so picking one recasts the page over that world's animals
336
+ * rather than reskinning this one. `worldPayload`/`characters` name the
337
+ * scenario the page opens with; a page given no `scenarios` is the
338
+ * one-burrow case and ships no dropdown at all. `spriteTemplates` is the
284
339
  * large-tier sprite set (data/sprites-large/*.toml) so every species
285
340
  * resolves its own art instead of falling back to the flat animal icon.
286
341
  * `mudConfig` defaults to game-config.mjs's own DEFAULT_GAME_CONFIG.mud —
@@ -293,13 +348,21 @@ export function renderMudHtml({
293
348
  title = DEFAULT_TITLE,
294
349
  worldPayload,
295
350
  characters = [],
351
+ scenarios = [],
296
352
  spriteTemplates = [],
297
353
  mudConfig = DEFAULT_GAME_CONFIG.mud,
298
354
  engineBundleJs = "",
299
355
  } = {}) {
300
- const openingCount = Math.max(1, Math.min(DEFAULT_PLAYER_COUNT, characters.length || DEFAULT_PLAYER_COUNT));
356
+ const scenarioList = scenarios.length
357
+ ? scenarios
358
+ : [{ label: scenarioLabel(worldPayload?.name), worldPayload, characters }];
359
+ const opening = scenarioList[0];
360
+ const openingCount = Math.max(
361
+ 1,
362
+ Math.min(DEFAULT_PLAYER_COUNT, (opening.characters || []).length || DEFAULT_PLAYER_COUNT),
363
+ );
301
364
  const pageData = embedJson({
302
- worldPayload, characters, spriteTemplates, mudConfig,
365
+ scenarios: scenarioList, spriteTemplates, mudConfig,
303
366
  paneSlots: PANE_SLOTS,
304
367
  playerCounts: PLAYER_COUNTS,
305
368
  defaultPlayerCount: DEFAULT_PLAYER_COUNT,
@@ -333,7 +396,14 @@ ${MUD_STYLE}
333
396
  <div class="deck-controls">
334
397
  <button type="button" class="deck-play" id="autoToggle" aria-pressed="false">&#9654; play</button>
335
398
  <button type="button" id="resetBtn">reset</button>
399
+ ${scenarioList.length > 1 ? ` <select id="scenarioSelect" class="deck-select" aria-label="which burrow to play">
400
+ ${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " selected" : ""}>${escapeHtml(s.label || scenarioLabel(s.worldPayload?.name))}</option>`).join("\n")}
401
+ </select>` : ""}
336
402
  <button type="button" id="editModeBtn" aria-pressed="false">edit</button>
403
+ <button type="button" id="shareBtn">share</button>
404
+ <button type="button" id="joinOpenBtn">join</button>
405
+ <button type="button" class="state-pill" id="statePill" aria-expanded="false" aria-controls="netPanel"
406
+ title="whether this burrow is shared with anyone"><i class="state-dot"></i><span id="statePillWord">not shared</span></button>
337
407
  <button type="button" class="deck-info-btn" id="deckInfoBtn" aria-expanded="false" aria-controls="deckInfoPopup" aria-label="about this demo">?</button>
338
408
  <span class="mono deck-turns" id="globalTurnCount">turns: 0</span>
339
409
  </div>
@@ -365,8 +435,7 @@ ${MUD_STYLE}
365
435
  </label>
366
436
  </div>
367
437
  <div class="deck-info-popup mud-note" id="deckInfoPopup" role="dialog" aria-label="about this demo" hidden>
368
- <p>${escapeHtml(MUD_NOTE_LINES[0])}</p>
369
- <p>${escapeHtml(MUD_NOTE_LINES[1])}</p>
438
+ ${MUD_NOTE_LINES.map((line) => `<p>${escapeHtml(line)}</p>`).join("\n ")}
370
439
  <button type="button" class="deck-info-popup-close" id="deckInfoClose" aria-label="close">&times;</button>
371
440
  </div>
372
441
  </section>
@@ -379,6 +448,61 @@ ${MUD_STYLE}
379
448
  <div class="world-map-key" id="worldMapKey"></div>
380
449
  </section>
381
450
  </div>
451
+ <section class="net-panel" id="netPanel" aria-label="the shared burrow" hidden>
452
+ <div class="net-head">
453
+ <span class="net-title">the shared burrow</span>
454
+ <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>
455
+ <button type="button" class="net-close" id="netPanelClose" aria-label="close">&times;</button>
456
+ </div>
457
+ <div class="net-fields">
458
+ <label class="net-field">this node
459
+ <input type="text" id="nodeNameInput" spellcheck="false" aria-label="the name this node goes by">
460
+ </label>
461
+ <label class="net-field">this burrow
462
+ <input type="text" id="worldNameInput" spellcheck="false" aria-label="the name of the shared burrow">
463
+ </label>
464
+ </div>
465
+ <div class="net-block" id="sharePanel" hidden>
466
+ <p class="net-hint">Each link invites one person. Share again for the next.</p>
467
+ <textarea id="shareLink" readonly rows="2" aria-label="the invite link"></textarea>
468
+ </div>
469
+ <div class="net-block" id="invitePanel" hidden>
470
+ <label class="net-label" for="inviteBox">paste the invite you were sent</label>
471
+ <textarea id="inviteBox" rows="2" spellcheck="false"></textarea>
472
+ <button type="button" id="inviteBtn">make my reply</button>
473
+ <p class="net-problem" id="inviteProblem" role="alert" hidden></p>
474
+ </div>
475
+ <div class="net-block" id="replyPanel">
476
+ <label class="net-label" for="replyBox">paste their reply here</label>
477
+ <textarea id="replyBox" rows="2" spellcheck="false"></textarea>
478
+ <button type="button" id="replyBtn">connect</button>
479
+ <p class="net-problem" id="replyProblem" role="alert" hidden></p>
480
+ </div>
481
+ <div class="net-block" id="answerPanel" hidden>
482
+ <label class="net-label" for="replyOut">send this back the same way the invite reached you</label>
483
+ <textarea id="replyOut" readonly rows="2"></textarea>
484
+ <button type="button" id="copyReplyBtn">copy the reply</button>
485
+ </div>
486
+ <div class="net-block">
487
+ <div class="net-label">nodes <span class="mono" id="nodeCount"></span></div>
488
+ <ul class="node-list" id="nodeList"></ul>
489
+ <p class="net-hint" id="nodeEmpty">Nobody else is digging here yet.</p>
490
+ </div>
491
+ </section>
492
+ <div class="join-card" id="joinCard" hidden>
493
+ <div class="join-inner" role="dialog" aria-label="an invitation to a shared burrow">
494
+ <p class="join-eyebrow" id="joinEyebrow">you have been invited to dig in</p>
495
+ <h2 class="join-world" id="joinWorld">a shared burrow</h2>
496
+ <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>
497
+ <button type="button" id="joinBtn">create my reply</button>
498
+ <p class="net-problem" id="joinProblem" role="alert" hidden></p>
499
+ <div class="join-reply" id="joinReplyWrap" hidden>
500
+ <textarea id="joinReply" readonly rows="2" aria-label="your reply"></textarea>
501
+ <button type="button" id="joinCopyBtn">copy the reply</button>
502
+ </div>
503
+ <button type="button" class="join-dismiss" id="joinDismiss">dig on your own instead</button>
504
+ </div>
505
+ </div>
382
506
  <div class="mud-stage" id="mudStage" data-panes="${openingCount}">
383
507
  ${paneHtml}
384
508
  </div>
@@ -430,6 +554,7 @@ export function paneMarkup(slot) {
430
554
  return ` <section class="mud-window pane-${escapeHtml(slot)}" id="${w}" data-slot="${escapeHtml(slot)}" data-character="">
431
555
  <div class="pane-head">
432
556
  <h2 id="${w}-name">waiting</h2>
557
+ <span class="pane-node" id="${w}-node" hidden></span>
433
558
  <span class="mono pane-turn" id="${w}-turn">turn 0</span>
434
559
  </div>
435
560
  <div class="room-stage">
@@ -473,7 +598,11 @@ export function paneMarkup(slot) {
473
598
  </div>
474
599
  </div>
475
600
  <div class="chat-console">
476
- <div class="chatpills" id="${w}-chatpills" role="group" aria-label="quick commands"></div>
601
+ <div class="pill-strip">
602
+ <button type="button" class="pill wave-btn" id="${w}-wave" title="wave, so everyone in this room sees it"
603
+ aria-label="wave">&#128075; wave</button>
604
+ <div class="chatpills" id="${w}-chatpills" role="group" aria-label="quick commands"></div>
605
+ </div>
477
606
  <form class="chatask" id="${w}-chatform">
478
607
  <span class="prompt mono">tmct&gt;</span>
479
608
  <input id="${w}-chatq" type="text" placeholder="look" aria-label="type a command" disabled>
@@ -530,6 +659,12 @@ const MUD_STYLE = `
530
659
  padding: .32rem .7rem; border: 1px solid var(--soil-mid); border-radius: 3px; background: rgba(255,255,255,.5);
531
660
  }
532
661
  .deck button:hover:not(:disabled), .pane-controls button:hover:not(:disabled) { border-color: var(--burrow-glow); }
662
+ .deck-select {
663
+ font-family: ${MONO_STACK}; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em;
664
+ padding: .32rem .7rem; border: 1px solid var(--soil-mid); border-radius: 3px;
665
+ background: rgba(255,255,255,.5); color: var(--mud-ink);
666
+ }
667
+ .deck-select:hover { border-color: var(--burrow-glow); }
533
668
  .deck-play { background: var(--mud-ink) !important; color: var(--parchment); border-color: var(--mud-ink) !important; padding: .38rem 1.1rem !important; }
534
669
  .deck-play[aria-pressed="true"] { background: var(--burrow-glow) !important; border-color: var(--burrow-glow) !important; color: var(--mud-ink); }
535
670
  .deck-turns { margin-left: auto; font-size: .74rem; color: var(--soil-mid); }
@@ -885,6 +1020,138 @@ const MUD_STYLE = `
885
1020
  .edit-stage { grid-template-columns: 1fr; }
886
1021
  #editorText { min-height: 18rem; }
887
1022
  }
1023
+
1024
+ /* ---- the control panel, in a Lemmings register, on the burrow's own palette ----
1025
+ The 1991 game put everything a player touches in one chunky beveled strip
1026
+ under the level. This deck keeps that shape — one bordered block, a play
1027
+ button that reads as the "go" key, a lit corner readout — but in the same
1028
+ soil/parchment/burrow-glow palette every other panel on the page already
1029
+ uses, so it reads as this world's own control panel rather than a
1030
+ different game's console dropped on top of it. Everything below overrides
1031
+ the parchment deck above it deliberately, so the panel is one block a
1032
+ reader can find rather than a dozen edits. */
1033
+ :root {
1034
+ --lem-face: var(--chalk); --lem-face-hi: #FBF7EF; --lem-face-lo: var(--soil-light);
1035
+ --lem-go: var(--mud-ink); --lem-go-hi: var(--burrow-glow);
1036
+ --lem-alert: #A5432B;
1037
+ --lem-readout: var(--burrow-glow); --lem-readout-bg: var(--soil-deep);
1038
+ --lem-chalk: var(--soil-mid);
1039
+ }
1040
+ .deck-slider, .deck h3 { color: var(--lem-chalk); }
1041
+ .deck-slider input[type="range"] { accent-color: var(--burrow-glow); }
1042
+ .deck button, .pane-controls button, .net-panel button, .join-inner button {
1043
+ background: var(--lem-face); color: var(--mud-ink); border: 1px solid var(--soil-mid); border-radius: 3px;
1044
+ padding: .32rem .7rem; box-shadow: 0 1px 0 rgba(0,0,0,.12);
1045
+ }
1046
+ .deck button:hover:not(:disabled), .pane-controls button:hover:not(:disabled),
1047
+ .net-panel button:hover:not(:disabled), .join-inner button:hover:not(:disabled) {
1048
+ background: var(--lem-face-hi); border-color: var(--burrow-glow);
1049
+ }
1050
+ .deck button:active:not(:disabled), .pane-controls button:active:not(:disabled),
1051
+ .net-panel button:active:not(:disabled), .join-inner button:active:not(:disabled) {
1052
+ background: var(--lem-face-lo); box-shadow: inset 0 1px 2px rgba(0,0,0,.25);
1053
+ }
1054
+ .deck-play { background: var(--lem-go) !important; color: var(--parchment) !important; border-color: var(--lem-go) !important; }
1055
+ .deck-play[aria-pressed="true"] { background: var(--lem-alert) !important; border-color: var(--lem-alert) !important; color: var(--parchment) !important; }
1056
+ /* The corner readout: a lit panel display, in the burrow's own amber rather than DOS-console green. */
1057
+ .deck-turns { background: var(--lem-readout-bg); color: var(--lem-readout);
1058
+ border: 1px solid var(--soil-mid); border-radius: 2px; padding: .1rem .5rem; }
1059
+ .deck-info-btn { background: var(--lem-face); color: var(--mud-ink); border: 1px solid var(--soil-mid); }
1060
+ .deck-info-btn:hover, .deck-info-btn[aria-expanded="true"] { background: var(--lem-face-hi); border-color: var(--burrow-glow); color: var(--mud-ink); }
1061
+ #editModeBtn[aria-pressed="true"] { background: var(--burrow-glow); border-color: var(--burrow-glow); color: var(--mud-ink); }
1062
+
1063
+ /* ---- whether the burrow is shared, said in one word on the panel ---- */
1064
+ .state-pill { display: inline-flex; align-items: center; gap: .34rem;
1065
+ font-family: ${MONO_STACK}; font-size: .68rem; text-transform: uppercase; letter-spacing: .08em; }
1066
+ .state-dot { width: .46rem; height: .46rem; border-radius: 50%; background: var(--lem-face-lo); flex: 0 0 auto; }
1067
+ .state-pill[data-tone="waiting"] .state-dot { background: #E0912A; }
1068
+ .state-pill[data-tone="working"] .state-dot { background: #E0912A; }
1069
+ .state-pill[data-tone="live"] .state-dot { background: var(--lem-go); }
1070
+ .state-pill[data-tone="failed"] .state-dot { background: var(--lem-alert); }
1071
+
1072
+ .net-panel {
1073
+ background: var(--soil-deep); color: var(--parchment);
1074
+ border: 1px solid var(--burrow-glow); border-radius: 3px;
1075
+ box-shadow: 0 2px 0 rgba(0,0,0,.3);
1076
+ padding: .7rem .8rem; margin-bottom: 1rem;
1077
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: .6rem .9rem; align-items: start;
1078
+ }
1079
+ .net-head { grid-column: 1 / -1; display: flex; align-items: baseline; gap: .6rem; flex-wrap: wrap; }
1080
+ .net-title { font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em; opacity: .85; }
1081
+ .net-state { font-size: .74rem; display: flex; gap: .4rem; flex-wrap: wrap; align-items: baseline; }
1082
+ .net-state b { font-family: ${MONO_STACK}; font-size: .66rem; text-transform: uppercase; letter-spacing: .08em; }
1083
+ .net-state[data-tone="live"] b { color: var(--lem-go-hi); }
1084
+ .net-state[data-tone="failed"] b { color: #FF8C70; }
1085
+ .net-state[data-tone="waiting"] b, .net-state[data-tone="working"] b { color: #F0C070; }
1086
+ .net-state span { opacity: .8; }
1087
+ .net-close { margin-left: auto; }
1088
+ .net-fields, .net-block { display: flex; flex-direction: column; gap: .3rem; min-width: 0; }
1089
+ .net-fields { grid-column: 1 / -1; flex-direction: row; flex-wrap: wrap; gap: .8rem; }
1090
+ .net-field, .net-label { font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .1em; opacity: .85; }
1091
+ .net-field { display: flex; flex-direction: column; gap: .2rem; }
1092
+ .net-panel input[type="text"], .net-panel textarea {
1093
+ font-family: ${MONO_STACK}; font-size: .68rem; color: var(--lem-readout); background: var(--lem-readout-bg);
1094
+ border: 1px solid #000; border-radius: 2px; padding: .26rem .4rem; width: 100%; box-sizing: border-box;
1095
+ resize: vertical; overflow-wrap: anywhere;
1096
+ }
1097
+ .net-panel input[readonly], .net-panel textarea[readonly] { opacity: .85; }
1098
+ .net-panel button { align-self: flex-start; font-family: ${MONO_STACK}; font-size: .66rem;
1099
+ text-transform: uppercase; letter-spacing: .08em; }
1100
+ .net-hint { margin: 0; font-size: .7rem; opacity: .75; }
1101
+ .net-problem { margin: 0; font-size: .72rem; color: #FFB4A0; }
1102
+ .node-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .22rem; }
1103
+ .node-row { display: flex; align-items: center; gap: .4rem; font-size: .74rem; }
1104
+ .node-row[data-away="true"] { opacity: .55; }
1105
+ .node-avatar { width: 1.25rem; height: 1.25rem; border-radius: 50%; background: var(--lem-face);
1106
+ color: #16162A; font-family: ${MONO_STACK}; font-size: .52rem; display: inline-flex;
1107
+ align-items: center; justify-content: center; flex: 0 0 auto; }
1108
+ .node-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1109
+ .node-self { font-family: ${MONO_STACK}; font-size: .54rem; text-transform: uppercase; letter-spacing: .08em; opacity: .6; }
1110
+ .node-hand { opacity: 0; }
1111
+ .node-row[data-waving="true"] .node-hand { opacity: 1; animation: hand-wave .8s ease-in-out infinite; }
1112
+ .node-when { margin-left: auto; font-family: ${MONO_STACK}; font-size: .58rem; opacity: .6; }
1113
+
1114
+ .join-card { position: fixed; inset: 0; z-index: 40; display: flex; align-items: center; justify-content: center;
1115
+ background: rgba(8,8,42,.86); padding: 1rem; }
1116
+ /* Setting display at all outranks the browser's own [hidden] rule, so every
1117
+ block here that is hidden by attribute has to say so a second time. */
1118
+ .join-card[hidden], .net-panel[hidden], .net-block[hidden], .join-reply[hidden] { display: none; }
1119
+ .join-inner { background: var(--soil-deep);
1120
+ color: var(--parchment); border: 1px solid var(--burrow-glow); border-radius: 3px;
1121
+ box-shadow: 0 10px 26px rgba(0,0,0,.5);
1122
+ padding: 1.1rem 1.2rem; max-width: 32rem; width: 100%; display: flex; flex-direction: column; gap: .55rem; }
1123
+ .join-eyebrow { margin: 0; font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em; opacity: .8; }
1124
+ .join-world { font-family: ${DISPLAY_STACK}; font-size: 1.4rem; margin: 0; }
1125
+ .join-body { margin: 0; font-size: .8rem; opacity: .9; max-width: 52ch; }
1126
+ .join-reply { display: flex; flex-direction: column; gap: .3rem; }
1127
+ .join-inner textarea { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--lem-readout);
1128
+ background: var(--lem-readout-bg); border: 1px solid #000; border-radius: 2px; padding: .3rem .4rem;
1129
+ width: 100%; box-sizing: border-box; overflow-wrap: anywhere; }
1130
+ .join-inner button { align-self: flex-start; font-family: ${MONO_STACK}; font-size: .68rem;
1131
+ text-transform: uppercase; letter-spacing: .08em; }
1132
+ .join-dismiss { background: transparent !important; box-shadow: none !important; color: #C6C6E8 !important;
1133
+ text-decoration: underline; padding-left: 0 !important; }
1134
+
1135
+ /* ---- a wave, and the node a character is played from ---- */
1136
+ .pill-strip { display: flex; align-items: flex-start; gap: .22rem; min-width: 0; }
1137
+ .pill-strip .chatpills { flex: 1 1 auto; min-width: 0; }
1138
+ .wave-btn { flex: 0 0 auto; }
1139
+ .sprite-card { position: relative; }
1140
+ .wave-hand { position: absolute; top: -0.55rem; right: -0.35rem; font-size: 1.1rem; line-height: 1;
1141
+ transform-origin: 70% 80%; animation: hand-wave .8s ease-in-out infinite; pointer-events: none; }
1142
+ @keyframes hand-wave { 0%, 100% { transform: rotate(-14deg); } 50% { transform: rotate(20deg); } }
1143
+ .sprite-node {
1144
+ margin-top: 1px; max-width: 100%; box-sizing: border-box;
1145
+ font-family: ${MONO_STACK}; font-size: .34rem; line-height: 1.3; letter-spacing: .01em; text-align: center;
1146
+ color: var(--soil-mid); background: rgba(239,230,216,.7); border-radius: 2px; padding: 0 .2rem;
1147
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1148
+ }
1149
+ .pane-node { font-family: ${MONO_STACK}; font-size: .56rem; color: var(--soil-mid); opacity: .85; }
1150
+ .mud-window.remote .pane-controls button { display: none; }
1151
+ .mud-window.remote .room-view { box-shadow: inset 0 0 0 2px rgba(60,60,180,.35); }
1152
+ @media (prefers-reduced-motion: reduce) {
1153
+ .wave-hand, .node-row[data-waving="true"] .node-hand { animation: none; }
1154
+ }
888
1155
  `;
889
1156
 
890
1157
  /** The inlined page script, as a plain function body handed to
@@ -896,12 +1163,24 @@ function pageScript() {
896
1163
  "use strict";
897
1164
  const DATA = MUD_PAGE_DATA;
898
1165
  const createTicker = ${createTicker.toString()};
1166
+ const createSerialQueue = ${createSerialQueue.toString()};
899
1167
  const escapeHtml = ${escapeHtml.toString()};
900
1168
  const esc = escapeHtml;
1169
+ const rowsForWorld = ${rowsForWorld.toString()};
1170
+ const appendLogLine = ${appendLogLine.toString()};
1171
+ const fnv1a32 = ${fnv1a32.toString()};
1172
+ const predatorSubjects = ${predatorSubjects.toString()};
901
1173
  const paneMarkup = ${paneMarkup.toString()};
902
1174
  const speciesOfCharacter = ${speciesOfCharacter.toString()};
903
1175
  const mudRoomSceneObjects = ${mudRoomSceneObjects.toString()};
904
1176
  const carriedItemsFor = ${carriedItemsFor.toString()};
1177
+ // EXIT_DELTA is data, not a function — directedGridLayout/roomGraphSvg both
1178
+ // close over it as a module-level const, which a \`.toString()\` splice never
1179
+ // carries (a spliced function's source text is its own body only), so it
1180
+ // travels here as JSON instead.
1181
+ const EXIT_DELTA = ${JSON.stringify(EXIT_DELTA)};
1182
+ const directedGridLayout = ${directedGridLayout.toString()};
1183
+ const roomGraphSvg = ${roomGraphSvg.toString()};
905
1184
  const levelsOf = ${levelsOf.toString()};
906
1185
  const charactersInRoom = ${charactersInRoom.toString()};
907
1186
  const burrowGraph = ${burrowGraph.toString()};
@@ -918,9 +1197,37 @@ function pageScript() {
918
1197
  const allRoomIds = ${allRoomIds.toString()};
919
1198
  const renderMudEditorText = ${renderMudEditorText.toString()};
920
1199
  const wordBeforeCursor = ${wordBeforeCursor.toString()};
1200
+ const provenanceStamps = ${provenanceStamps.toString()};
1201
+ const peerTerm = ${peerTerm.toString()};
1202
+ const claimantOf = ${claimantOf.toString()};
1203
+ const wavingCharacters = ${wavingCharacters.toString()};
1204
+ const nodeNameFor = ${nodeNameFor.toString()};
1205
+ const offerBlobIn = ${offerBlobIn.toString()};
1206
+ const wireStateLabel = ${wireStateLabel.toString()};
1207
+ const nodeRowsFor = ${nodeRowsFor.toString()};
1208
+ const nodeInitials = ${nodeInitials.toString()};
1209
+ const inviteLinkFor = ${inviteLinkFor.toString()};
1210
+ const inviteParamsFrom = ${inviteParamsFrom.toString()};
921
1211
 
922
1212
  const el = (id) => document.getElementById(id);
923
- const roster = DATA.characters.map(function (c) { return c.id; });
1213
+ // Which of the shipped burrows is loaded. Every read of the world — the
1214
+ // session it is opened over, the roster it is cast from, the provenance
1215
+ // prefix edit mode filters on — goes through this one index, so switching
1216
+ // scenarios needs no second copy of any of them.
1217
+ let scenarioIndex = 0;
1218
+ const scenario = function () { return DATA.scenarios[scenarioIndex]; };
1219
+ const rosterOf = function (s) { return (s.characters || []).map(function (c) { return c.id; }); };
1220
+ // Where the survey starts laying rooms out: the burrow's own origin, the
1221
+ // same fact the dig mechanic measures its reach from. Falling back to the
1222
+ // shipped default only matters for a world that declares no origin at all,
1223
+ // and burrowGraph already copes by starting from whichever room sorts first.
1224
+ const rootRoomOf = function (s) {
1225
+ const facts = (s.worldPayload && s.worldPayload.facts) || [];
1226
+ const origin = facts.find(function (f) { return f.predicate === "mgx:is-origin" && f.object === "true"; });
1227
+ return origin ? origin.subject : DATA.rootRoom;
1228
+ };
1229
+ let roster = rosterOf(scenario());
1230
+ let rootRoom = rootRoomOf(scenario());
924
1231
  let slots = [];
925
1232
  const ACTOR_COLORS = ["var(--actor-a)", "var(--actor-b)", "var(--actor-c)", "var(--actor-d)"];
926
1233
  const NPC_COLOR = "var(--chalk)";
@@ -933,12 +1240,8 @@ function pageScript() {
933
1240
  // directly, so two characters' turns can never interleave their reads and
934
1241
  // writes of the one shared memoryDir, and the global turn counter always
935
1242
  // increments in real execution order.
936
- let tickChain = Promise.resolve();
937
- function serializeTick(fn) {
938
- const run = tickChain.then(fn, fn);
939
- tickChain = run.catch(function () {});
940
- return run;
941
- }
1243
+ let tickQueue = createSerialQueue();
1244
+ function serializeTick(fn) { return tickQueue.run(fn); }
942
1245
 
943
1246
  let session = null;
944
1247
  let cast = [];
@@ -956,6 +1259,15 @@ function pageScript() {
956
1259
  const liveWait = function () { return wait(delayMs); };
957
1260
 
958
1261
  let freshlyDugRoom = null;
1262
+ // Which animals another node has claimed, character -> that node's name.
1263
+ // Refreshed from the store on every redraw, which is also every turn, so a
1264
+ // claim that arrives from a peer takes effect on the next tick rather than
1265
+ // being cached until something else happens to look.
1266
+ let claimedElsewhere = {};
1267
+ // Whoever is waving right now, and the timer that lets a wave stop being
1268
+ // current. A wave is never retracted — it just stops being recent.
1269
+ let wavingNow = [];
1270
+ let waveRedrawTimer = null;
959
1271
  // The last omniscient read, kept so the pounce can draw the predator's own
960
1272
  // den after the engine has already moved the character out of every room.
961
1273
  let lastSnapshot = null;
@@ -991,10 +1303,15 @@ function pageScript() {
991
1303
  const bootAt = bootSeq;
992
1304
  return serializeTick(async function () {
993
1305
  if (bootAt !== bootSeq || finished[character]) return { outOfPlay: true };
1306
+ // An animal another node claimed is played there, not here. Its moves
1307
+ // still arrive as facts and it still draws, talks and can be waved at —
1308
+ // this page just never takes its turn for it.
1309
+ if (claimedElsewhere[character]) return { playedElsewhere: true };
994
1310
  globalTurn += 1;
995
1311
  const result = await session.windows[character].autoplayTick(globalTurn);
996
1312
  if (!result.outOfPlay) turnsTaken[character] = (turnsTaken[character] || 0) + 1;
997
1313
  afterEngineTurn(character, result);
1314
+ await broadcastLocalChange();
998
1315
  return result;
999
1316
  });
1000
1317
  }
@@ -1027,12 +1344,10 @@ function pageScript() {
1027
1344
  }
1028
1345
 
1029
1346
  // Whichever individual the WORLD marks dangerous, never a species this page
1030
- // hardcodes — the same fact adventure.mjs's own predator check reads.
1347
+ // hardcodes — the same domain/mud-facts.mjs reader mud-turn.mjs's own
1348
+ // predator check reads server-side.
1031
1349
  function predatorInRows(rows) {
1032
- for (const row of rows) {
1033
- if (row.predicate === "mgx:is-predator" && row.object === "true") return row.subject;
1034
- }
1035
- return null;
1350
+ return predatorSubjects(rows)[0] || null;
1036
1351
  }
1037
1352
 
1038
1353
  // The pane holds on the den for the length of one pounce before it grays out.
@@ -1089,7 +1404,7 @@ function pageScript() {
1089
1404
  for (const action of result.actions || []) {
1090
1405
  if (action.kind !== "ask" && action.kind !== "talk") continue;
1091
1406
  const other = action.teller || action.target || action.object;
1092
- noteSpeech(result.room, character, { text: "what food do you know about?" });
1407
+ if (action.text) noteSpeech(result.room, character, { text: action.text });
1093
1408
  if (other && action.thing) noteSpeech(result.room, other, { thing: action.thing });
1094
1409
  else if (other && action.text) noteSpeech(result.room, other, { text: action.text });
1095
1410
  }
@@ -1115,23 +1430,7 @@ function pageScript() {
1115
1430
  // thing stays readable through the popup, and stays in the DOM for anything
1116
1431
  // reading the log.
1117
1432
  function appendChat(character, cls, text) {
1118
- const log = el(paneIdFor(character) + "-chatlog");
1119
- const d = document.createElement("div");
1120
- d.className = cls;
1121
- d.textContent = text;
1122
- log.appendChild(d);
1123
- // A line-clamped box reports scrollHeight EQUAL to its clamped height, so
1124
- // the overflow has to be read against the same box with the clamp lifted.
1125
- d.classList.add("unclamped");
1126
- const wholeHeight = d.scrollHeight;
1127
- d.classList.remove("unclamped");
1128
- if (wholeHeight - d.clientHeight > 2) {
1129
- d.classList.add("clipped");
1130
- d.setAttribute("role", "button");
1131
- d.setAttribute("tabindex", "0");
1132
- d.setAttribute("title", "read the whole line");
1133
- }
1134
- log.scrollTop = log.scrollHeight;
1433
+ appendLogLine(el(paneIdFor(character) + "-chatlog"), cls, text, { clip: true });
1135
1434
  }
1136
1435
 
1137
1436
  function openLogPopup(slot, text) {
@@ -1146,13 +1445,42 @@ function pageScript() {
1146
1445
 
1147
1446
  function sendCommand(character, line) {
1148
1447
  appendChat(character, "u", line);
1149
- return serializeTick(function () { return session.windows[character].turn(line); }).then(function (res) {
1448
+ const owner = claimedElsewhere[character];
1449
+ if (owner) {
1450
+ const refusal = character + " is played from " + owner + ". You can watch it and wave at it, but its turns are theirs.";
1451
+ appendChat(character, "a", refusal);
1452
+ return Promise.resolve({ answer: refusal, end: false });
1453
+ }
1454
+ // A wave is a gesture, not a world command — the engine has no verb for it
1455
+ // and would answer with an honest miss. Both ways in (this typed line and
1456
+ // the pane's own hand button) write the same fact.
1457
+ if (/^\\/?wave$/i.test(line.trim())) {
1458
+ return waveAs(character).then(function (here) {
1459
+ appendChat(character, "a", here
1460
+ ? "you wave. Everyone in the " + here + " sees it."
1461
+ : character + " is standing nowhere, so there is nobody to wave at.");
1462
+ return { answer: "", end: false };
1463
+ });
1464
+ }
1465
+ return serializeTick(function () { return session.windows[character].turn(line); }).then(async function (res) {
1150
1466
  appendChat(character, "a", res.answer);
1467
+ await broadcastLocalChange();
1151
1468
  renderSoon();
1152
1469
  return res;
1153
1470
  });
1154
1471
  }
1155
1472
 
1473
+ // A wave is an ordinary add-only fact in the shared world, so it needs no
1474
+ // network to work: one browser renders it exactly the way four do, and the
1475
+ // room broadcast below is the only part sharing adds.
1476
+ async function waveAs(character) {
1477
+ if (!session || finished[character]) return null;
1478
+ const here = await serializeTick(function () { return session.wave(character); });
1479
+ if (here) await broadcastLocalChange();
1480
+ renderSoon();
1481
+ return here;
1482
+ }
1483
+
1156
1484
  // Wired as the pane is built. The stage is rebuilt on every boot, so a
1157
1485
  // pane's listeners live and die with it; each one still reads its slot's
1158
1486
  // character at click time, because the cast is bound after the markup.
@@ -1177,6 +1505,10 @@ function pageScript() {
1177
1505
  const character = characterInSlot(slot);
1178
1506
  if (character) ensureTicker(character).stepOnce();
1179
1507
  });
1508
+ el(w + "-wave").addEventListener("click", function () {
1509
+ const character = characterInSlot(slot);
1510
+ if (character) sendCommand(character, "wave");
1511
+ });
1180
1512
 
1181
1513
  const log = el(w + "-chatlog");
1182
1514
  log.addEventListener("click", function (e) {
@@ -1220,9 +1552,7 @@ function pageScript() {
1220
1552
 
1221
1553
  // ---- room-view rendering -------------------------------------------------
1222
1554
  function hashOf(text) {
1223
- let h = 0;
1224
- for (let i = 0; i < String(text).length; i += 1) h = (h * 31 + String(text).charCodeAt(i)) % 100000;
1225
- return h;
1555
+ return fnv1a32(String(text));
1226
1556
  }
1227
1557
 
1228
1558
  function spriteSvgFor(species, rows, instanceKey) {
@@ -1317,17 +1647,33 @@ function pageScript() {
1317
1647
 
1318
1648
  // A sprite card: adventure.html's own frame-plus-nameplate, carrying the one
1319
1649
  // command this room currently grants on its subject (none, for a thing the
1320
- // room offers nothing on) and the subject's own mass under the name.
1321
- function spriteCardHtml(subject, label, inner, command, extraClass, mass) {
1650
+ // room offers nothing on) and the subject's own mass under the name. The
1651
+ // last argument holds the two things only a shared burrow has: a hand over a
1652
+ // character waving right now, and the node another peer plays it from — one
1653
+ // more line under the name, in the same font, never a second label system.
1654
+ function spriteCardHtml(subject, label, inner, command, extraClass, mass, extra) {
1655
+ const opts = extra || {};
1322
1656
  const action = command
1323
1657
  ? ' actionable" data-command="' + esc(command) + '" role="button" tabindex="0" title="' + esc(command) + '"'
1324
1658
  : '" title="' + esc(label) + '"';
1325
- return '<div class="sprite-card ' + (extraClass || "") + action + ' data-subject="' + esc(subject) + '">'
1659
+ return '<div class="sprite-card ' + (extraClass || "") + (opts.waving ? " waving" : "") + action
1660
+ + ' data-subject="' + esc(subject) + '">'
1661
+ + (opts.waving ? '<div class="wave-hand" aria-hidden="true">\\u{1F44B}</div>' : "")
1326
1662
  + inner + '<div class="sprite-label">' + esc(label) + "</div>"
1663
+ + (opts.node ? '<div class="sprite-node">via ' + esc(opts.node) + "</div>" : "")
1327
1664
  + (mass === null || mass === undefined ? "" : '<div class="sprite-mass">mass ' + esc(mass) + "</div>")
1328
1665
  + "</div>";
1329
1666
  }
1330
1667
 
1668
+ // A character another node plays carries that node's name; one this node
1669
+ // plays, or one nobody has claimed, carries nothing extra.
1670
+ function nodeLabelFor(character) {
1671
+ return claimedElsewhere[character] || null;
1672
+ }
1673
+ function isWavingNow(character) {
1674
+ return wavingNow.indexOf(character) !== -1;
1675
+ }
1676
+
1331
1677
  function renderRoomView(character, rows, state, here, roomIds, roomMates) {
1332
1678
  const w = paneIdFor(character);
1333
1679
  drawRoomBackdrop(el(w + "-canvas"), roomKindForRoom(rows, here), here);
@@ -1337,12 +1683,14 @@ function pageScript() {
1337
1683
  character, character,
1338
1684
  '<div class="sprite">' + spriteSvgFor(speciesOfCharacter(character), rows, character) + "</div>",
1339
1685
  null, "self", massOf(state, character),
1686
+ { waving: isWavingNow(character), node: nodeLabelFor(character) },
1340
1687
  );
1341
1688
 
1342
1689
  const others = roomMates.map(function (mate) {
1343
1690
  return spriteCardHtml(mate, mate,
1344
1691
  '<div class="sprite">' + spriteSvgFor(speciesOfCharacter(mate), rows, mate) + "</div>",
1345
- commands[mate], "", massOf(state, mate));
1692
+ commands[mate], "", massOf(state, mate),
1693
+ { waving: isWavingNow(mate), node: nodeLabelFor(mate) });
1346
1694
  });
1347
1695
  const wall = [];
1348
1696
  for (const obj of mudRoomSceneObjects(rows, state, here, character)) {
@@ -1498,87 +1846,24 @@ function pageScript() {
1498
1846
  // ---- the burrow survey ---------------------------------------------------
1499
1847
  // One routine, drawn twice: the omniscient board in the top row (every room
1500
1848
  // the world has) and each pane's own known ground (only what that character
1501
- // has dug or walked, with a fading stub where it knows a way carries on).
1849
+ // has dug or walked, with a fading stub where it knows a way carries on). A
1850
+ // thin wrapper over the shared roomGraphSvg, fixed to the burrow's own
1851
+ // "turf" treatment (ground line, tunnels/shafts, per-room occupant dots)
1852
+ // and its own "burrow" wrapper class.
1502
1853
  function burrowSvg(graph, options) {
1503
- if (!graph.nodes.length) return "";
1504
- const opts = options || {};
1505
- const compact = !!opts.compact;
1506
- const cellX = compact ? 44 : 76, cellY = compact ? 26 : 54;
1507
- const roomW = compact ? 34 : 62, roomH = compact ? 14 : 26;
1508
- const maxX = Math.max.apply(null, graph.nodes.map(function (n) { return n.x; }));
1509
- const maxY = Math.max.apply(null, graph.nodes.map(function (n) { return n.y; }));
1510
- const w = (maxX + 1) * cellX, h = (maxY + 1) * cellY;
1511
- const byRoom = {};
1512
- for (const n of graph.nodes) byRoom[n.id] = n;
1513
- const cx = function (n) { return (n.x + 0.5) * cellX; };
1514
- const cy = function (n) { return (n.y + 0.5) * cellY; };
1515
-
1516
- // The turf band is only drawn when the layout genuinely separates the
1517
- // surface from the soil — a lateral dig can push a surface room onto a
1518
- // lower row, and a ground line above it would say something false. The
1519
- // room fills carry the same information either way, which is all the
1520
- // thumbnail-sized copy has room for.
1521
- const surfaceRows = graph.nodes.filter(function (n) { return n.level === 0; }).map(function (n) { return n.y; });
1522
- const soilRows = graph.nodes.filter(function (n) { return n.level !== 0; }).map(function (n) { return n.y; });
1523
- let turf = "";
1524
- if (!compact && surfaceRows.length && soilRows.length && Math.max.apply(null, surfaceRows) < Math.min.apply(null, soilRows)) {
1525
- const groundY = (Math.max.apply(null, surfaceRows) + 0.5) * cellY + roomH / 2 + (compact ? 3 : 7);
1526
- turf = '<rect class="turf" x="0" y="0" width="' + w + '" height="' + groundY + '"></rect>'
1527
- + '<line class="ground-line" x1="0" y1="' + groundY + '" x2="' + w + '" y2="' + groundY + '"></line>';
1528
- }
1529
-
1530
- const tunnels = graph.edges.map(function (e) {
1531
- const a = byRoom[e.from], b = byRoom[e.to];
1532
- if (!a || !b) return "";
1533
- const shaft = e.direction === "up" || e.direction === "down" ? " shaft" : "";
1534
- return '<line class="tunnel' + shaft + '" x1="' + cx(a) + '" y1="' + cy(a) + '" x2="' + cx(b) + '" y2="' + cy(b) + '"></line>';
1535
- }).join("");
1536
-
1537
- const HINT_DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
1538
- const hints = graph.hints.map(function (hi) {
1539
- const from = byRoom[hi.from];
1540
- if (!from) return "";
1541
- const d = HINT_DELTA[hi.direction] || [0, 0];
1542
- return '<circle class="hint" cx="' + (cx(from) + d[0] * cellX * 0.36) + '" cy="' + (cy(from) + d[1] * cellY * 0.36) + '" r="' + (compact ? 2 : 3.2) + '"></circle>';
1543
- }).join("");
1544
-
1545
- const occupants = opts.occupants || {};
1546
- const rooms = graph.nodes.map(function (n) {
1547
- const here = opts.here === n.id ? " here" : "";
1548
- const fresh = opts.fresh === n.id ? " freshly-dug" : "";
1549
- const surface = n.level === 0 ? " surface" : "";
1550
- const label = esc(n.id);
1551
- const fit = label.length * (compact ? 3.6 : 4.3) > roomW - 6
1552
- ? ' textLength="' + (roomW - 6) + '" lengthAdjust="spacingAndGlyphs"' : "";
1553
- const cast = occupants[n.id] || [];
1554
- // The room's own width is the budget: a dozen animals down one hole must
1555
- // still read as a dozen dots inside that room rather than a line running
1556
- // out of it, so the gap closes as the crowd grows.
1557
- const step = Math.min(compact ? 6 : 9, (roomW - 6) / Math.max(1, cast.length));
1558
- const dots = cast.map(function (c, i) {
1559
- const spread = (i - (cast.length - 1) / 2) * step;
1560
- return '<circle class="occupant" cx="' + (cx(n) + spread) + '" cy="' + (cy(n) + roomH / 2) + '" r="' + (compact ? 2.4 : 3.6)
1561
- + '" fill="' + c.color + '"><title>' + esc(c.character) + "</title></circle>";
1562
- }).join("");
1563
- return '<g class="room' + surface + here + fresh + '" data-room="' + label + '"><rect x="' + (cx(n) - roomW / 2) + '" y="' + (cy(n) - roomH / 2)
1564
- + '" width="' + roomW + '" height="' + roomH + '" rx="3"></rect><text x="' + cx(n) + '" y="' + (cy(n) + (compact ? 2 : 2.5))
1565
- + '"' + fit + ">" + label + "</text>" + dots + "</g>";
1566
- }).join("");
1567
-
1568
- return '<div class="burrow"><svg viewBox="0 0 ' + w + " " + h + '" preserveAspectRatio="xMidYMid meet" role="img" aria-label="'
1569
- + esc(opts.label || "the burrow") + '">' + turf + tunnels + hints + rooms + "</svg></div>";
1854
+ return roomGraphSvg(graph, Object.assign({ turf: true, wrapClass: "burrow" }, options || {}));
1570
1855
  }
1571
1856
 
1572
1857
  function renderMinimap(character, rows, state, here) {
1573
1858
  const visited = session.windows[character].visitedRoomIds();
1574
- const graph = burrowGraph(state, visited, DATA.rootRoom);
1859
+ const graph = burrowGraph(state, visited, rootRoom);
1575
1860
  el(paneIdFor(character) + "-minimap").innerHTML = burrowSvg(graph, {
1576
1861
  compact: true, here: here, label: "the ground " + character + " has covered",
1577
1862
  });
1578
1863
  }
1579
1864
 
1580
1865
  function renderWorldMap(rows, state, roomIds) {
1581
- const graph = burrowGraph(state, roomIds, DATA.rootRoom);
1866
+ const graph = burrowGraph(state, roomIds, rootRoom);
1582
1867
  const occupants = {};
1583
1868
  for (const room of roomIds) {
1584
1869
  const here = charactersInRoom(state, room, everyone());
@@ -1636,13 +1921,35 @@ function pageScript() {
1636
1921
  const snap = await session.snapshot();
1637
1922
  lastSnapshot = snap;
1638
1923
  const roomIds = allRoomIds(snap.rows);
1924
+ const nowMs = Date.now();
1925
+ wavingNow = wavingCharacters(snap.rows, nowMs);
1926
+ // Every claimed animal, not just the ones this page cast: a character
1927
+ // another node plays walks into this room as an ordinary room-mate, and
1928
+ // its label has to name where it is played from just the same.
1929
+ claimedElsewhere = {};
1930
+ if (myPeerId) {
1931
+ const claimed = {};
1932
+ for (const row of snap.rows) if (row.predicate === "mgx:playedBy") claimed[row.subject] = true;
1933
+ for (const character of Object.keys(claimed)) {
1934
+ const owner = claimantOf(snap.rows, character);
1935
+ if (owner === null || peerTerm(owner) === peerTerm(myPeerId)) continue;
1936
+ claimedElsewhere[character] = nodeNameFor(snap.rows, owner);
1937
+ const ticker = tickers[character];
1938
+ if (ticker) ticker.pause();
1939
+ }
1940
+ }
1639
1941
  for (const character of everyone()) {
1640
1942
  const reason = window.tmctMud.outOfPlayReasonOf(snap.state, character);
1641
1943
  if (reason) markOutOfPlay(character, reason, null);
1642
1944
  }
1643
1945
  renderWorldMap(snap.rows, snap.state, roomIds);
1644
1946
  for (const character of cast) {
1645
- el(paneIdFor(character) + "-turn").textContent = "turn " + turnsFor(character);
1947
+ const pane = paneIdFor(character);
1948
+ el(pane + "-turn").textContent = "turn " + turnsFor(character);
1949
+ const owner = claimedElsewhere[character];
1950
+ el(pane).classList.toggle("remote", Boolean(owner));
1951
+ el(pane + "-node").hidden = !owner;
1952
+ el(pane + "-node").textContent = owner ? "played from " + owner : "";
1646
1953
  if (finished[character]) continue;
1647
1954
  const place = snap.state.placements.get(character);
1648
1955
  const here = place ? place.object : null;
@@ -1655,6 +1962,12 @@ function pageScript() {
1655
1962
  renderMinimap(character, snap.rows, snap.state, here);
1656
1963
  }
1657
1964
  freshlyDugRoom = null;
1965
+ // A wave carries no retraction, so nothing tells the page when one stops
1966
+ // being current — the recency window does, and only a redraw can notice it
1967
+ // has passed. One follow-up per second while any hand is up, and none at
1968
+ // all once they are all down.
1969
+ clearTimeout(waveRedrawTimer);
1970
+ if (wavingNow.length) waveRedrawTimer = setTimeout(renderSoon, 1000);
1658
1971
  }
1659
1972
 
1660
1973
  // ---- edit mode -----------------------------------------------------------
@@ -1675,14 +1988,11 @@ function pageScript() {
1675
1988
  let editing = false;
1676
1989
 
1677
1990
  function worldOnlyRows(rows) {
1678
- const prefix = "world:" + DATA.worldPayload.name;
1679
- return (rows || []).filter(function (r) {
1680
- return typeof r.provenance === "string" && r.provenance.indexOf(prefix) === 0;
1681
- });
1991
+ return rowsForWorld(rows, scenario().worldPayload.name);
1682
1992
  }
1683
1993
 
1684
1994
  function renderEditMap() {
1685
- const graph = burrowGraph(editState, allRoomIds(editRows), DATA.rootRoom);
1995
+ const graph = burrowGraph(editState, allRoomIds(editRows), rootRoom);
1686
1996
  el("editMapBoard").innerHTML = graph.nodes.length
1687
1997
  ? burrowSvg(graph, { here: selectedRoomId, label: "every room this world writes" })
1688
1998
  : '<span class="edit-empty">no rooms written yet</span>';
@@ -1799,6 +2109,7 @@ function pageScript() {
1799
2109
  status.className = "edit-status pending";
1800
2110
  status.textContent = "reading the burrow\\u2026";
1801
2111
  const result = await serializeTick(function () { return session.applyEdit(el("editorText").value); });
2112
+ await broadcastLocalChange();
1802
2113
  const snap = await session.snapshot();
1803
2114
  allStoreRows = snap.rows;
1804
2115
  editRows = worldOnlyRows(snap.rows);
@@ -1883,6 +2194,433 @@ function pageScript() {
1883
2194
  await renderAll();
1884
2195
  }
1885
2196
 
2197
+ // ---- the shared burrow: nodes, the wire, and the two-paste handshake -----
2198
+ // Every piece of networking arrives from ./vendor/p2p.js, the site's own
2199
+ // shared P2P asset, imported the first time somebody actually asks to share
2200
+ // rather than at boot. The room owns signaling, the mesh and the merge; this
2201
+ // block owns what a person sees and clicks, and nothing else.
2202
+ const P2P_ASSET = "./vendor/p2p.js";
2203
+ const NODE_NAME_KEY = "tmct.mud.nodeName";
2204
+ const invite = inviteParamsFrom(window.location.search);
2205
+
2206
+ let p2p = null;
2207
+ let p2pLoad = null;
2208
+ let room = null;
2209
+ let myPeerId = null;
2210
+ let myDisplayName = "";
2211
+ let worldId = "";
2212
+ let worldName = "";
2213
+ let nodeClockTimer = null;
2214
+
2215
+ function loadP2p() {
2216
+ if (!p2pLoad) p2pLoad = import(P2P_ASSET).then(function (mod) { p2p = mod; return mod; });
2217
+ return p2pLoad;
2218
+ }
2219
+ window.tmctP2pLoad = loadP2p;
2220
+
2221
+ function readStoredNodeName() {
2222
+ try { return localStorage.getItem(NODE_NAME_KEY) || ""; } catch { return ""; }
2223
+ }
2224
+ function writeStoredNodeName(name) {
2225
+ try { localStorage.setItem(NODE_NAME_KEY, name); } catch { /* private mode — the name still holds for this visit */ }
2226
+ }
2227
+
2228
+ async function ensureIdentity() {
2229
+ const mod = await loadP2p();
2230
+ if (!myPeerId) myPeerId = mod.generatePeerId();
2231
+ if (!myDisplayName) {
2232
+ myDisplayName = readStoredNodeName() || mod.generateDisplayName();
2233
+ el("nodeNameInput").value = myDisplayName;
2234
+ }
2235
+ if (!worldId) worldId = invite && invite.world ? invite.world : mod.generateWorldId();
2236
+ if (!worldName) {
2237
+ worldName = invite && invite.worldName ? invite.worldName : mod.generateDisplayName();
2238
+ el("worldNameInput").value = worldName;
2239
+ }
2240
+ }
2241
+
2242
+ async function ensureRoom() {
2243
+ if (room) return room;
2244
+ const mod = await loadP2p();
2245
+ await ensureIdentity();
2246
+ if (!session && bootRun) await bootRun;
2247
+ if (!session) throw new Error("the burrow hasn't finished opening");
2248
+ // The one place this page decides what is worth replicating: whatever the
2249
+ // engine calls live world state, plus the P2P layer's own four predicates.
2250
+ // The check is handed over rather than imported, so the sync filter never
2251
+ // learns the world engine's private predicate names.
2252
+ const extraPredicates = (window.tmctMud.P2P_PREDICATES || []).slice();
2253
+ const isState = window.tmctMud.isMudStatePredicate;
2254
+ room = mod.createP2pRoom({
2255
+ memoryDir: session.memoryDir,
2256
+ myPeerId: myPeerId,
2257
+ myDisplayName: myDisplayName,
2258
+ worldId: worldId,
2259
+ worldName: worldName,
2260
+ transportFactory: function () { return mod.createTransport({ iceServers: [] }); },
2261
+ syncableFacts: function (rows) { return mod.mudSyncableFacts(rows, isState, extraPredicates); },
2262
+ });
2263
+ room.onStateChanged(function (state) {
2264
+ // The join card exists to produce one reply. Once the channel is open it
2265
+ // has done its job, and a full-screen card over a live burrow is just
2266
+ // something in the way.
2267
+ if (state === "connected") el("joinCard").hidden = true;
2268
+ renderWire();
2269
+ });
2270
+ room.onPeersChanged(function () { renderNodes(); renderWire(); });
2271
+ room.onFactsChanged(function () { renderNodes(); renderSoon(); });
2272
+ await room.start();
2273
+ // The burrow's name is written into the graph the moment the room starts,
2274
+ // and this version retracts nothing — so the field stops taking edits.
2275
+ el("worldNameInput").readOnly = true;
2276
+ window.tmctP2pRoom = room;
2277
+ await claimMyAnimals();
2278
+ renderWire();
2279
+ renderNodes();
2280
+ if (!nodeClockTimer) nodeClockTimer = setInterval(renderNodes, 10000);
2281
+ return room;
2282
+ }
2283
+
2284
+ // Every animal this page drives is claimed the moment the burrow is shared,
2285
+ // panes and npcs alike: two peers each running scripted turns for one mole
2286
+ // would be two hands on one lemming. The claim is add-only and the oldest
2287
+ // wins, so a page that loses one simply stops driving that animal and
2288
+ // watches somebody else play it instead.
2289
+ async function claimMyAnimals() {
2290
+ if (!session || !room) return;
2291
+ await serializeTick(function () { return session.claimCharacters(everyone(), myPeerId); });
2292
+ await broadcastLocalChange();
2293
+ renderSoon();
2294
+ }
2295
+
2296
+ // The room diffs the store against what it last saw and broadcasts whatever
2297
+ // changed. The page calls it after every local turn, wave, claim and edit —
2298
+ // a broadcast that fails is a dead channel, reported by its own close
2299
+ // handler, and must never fail the turn that happened to carry it.
2300
+ function broadcastLocalChange() {
2301
+ if (!room) return Promise.resolve({ broadcast: 0 });
2302
+ return room.afterLocalChange().catch(function () { return { broadcast: 0 }; });
2303
+ }
2304
+
2305
+ function dropRoom(note) {
2306
+ if (!room) return;
2307
+ room.close();
2308
+ room = null;
2309
+ window.tmctP2pRoom = null;
2310
+ clearInterval(nodeClockTimer);
2311
+ nodeClockTimer = null;
2312
+ el("shareLink").value = "";
2313
+ el("replyOut").value = "";
2314
+ el("worldNameInput").readOnly = false;
2315
+ renderWire();
2316
+ renderNodes();
2317
+ // Last, not first: renderWire restates the note for the state it has just
2318
+ // read, which for a room that is gone is the idle one ("this browser holds
2319
+ // the only copy"). WHY the room went is the thing worth saying, so it is
2320
+ // written over the top rather than under it.
2321
+ if (note) el("wireStateNote").textContent = note;
2322
+ }
2323
+
2324
+ // The state machine is shared with chat.html and the words mostly are too.
2325
+ // Two of them are not: nothing is taught here and nothing is a node below,
2326
+ // so those two notes are said in this page's own terms rather than forking
2327
+ // the whole table for a burrow.
2328
+ const MUD_WIRE_NOTES = {
2329
+ idle: "this browser holds the only copy of this burrow.",
2330
+ connected: "what you dig from here reaches every node in the burrow, and theirs reaches you.",
2331
+ };
2332
+
2333
+ function renderWire() {
2334
+ const state = room ? room.state : "idle";
2335
+ const label = wireStateLabel(state);
2336
+ const note = MUD_WIRE_NOTES[state] || label.note;
2337
+ const wire = el("wireState");
2338
+ wire.dataset.tone = label.tone;
2339
+ el("wireStateWord").textContent = label.word;
2340
+ el("wireStateNote").textContent = note;
2341
+ const pill = el("statePill");
2342
+ pill.dataset.tone = label.tone;
2343
+ el("statePillWord").textContent = label.pill;
2344
+ pill.title = note;
2345
+ el("sharePanel").hidden = !el("shareLink").value;
2346
+ // Once the channel is open the reply has been used; leaving "send this
2347
+ // back" on screen would be asking for something already done.
2348
+ el("answerPanel").hidden = !el("replyOut").value || (room && room.state === "connected");
2349
+ }
2350
+
2351
+ function relativeWhen(at, nowMs) {
2352
+ if (at === null || at === undefined) return "\\u2014";
2353
+ const seconds = Math.max(0, Math.round((nowMs - at) / 1000));
2354
+ if (seconds < 5) return "now";
2355
+ if (seconds < 60) return seconds + "s";
2356
+ if (seconds < 3600) return Math.round(seconds / 60) + "m";
2357
+ if (seconds < 86400) return Math.round(seconds / 3600) + "h";
2358
+ return Math.round(seconds / 86400) + "d";
2359
+ }
2360
+
2361
+ function renderNodes() {
2362
+ const listEl = el("nodeList");
2363
+ if (!room || !p2p) {
2364
+ el("nodeCount").textContent = "";
2365
+ listEl.textContent = "";
2366
+ el("nodeEmpty").hidden = false;
2367
+ return;
2368
+ }
2369
+ const rows = nodeRowsFor({
2370
+ peers: room.peers(),
2371
+ factRows: room.factRows(),
2372
+ myPeerId: myPeerId,
2373
+ myDisplayName: myDisplayName,
2374
+ nameFor: room.displayNameFor,
2375
+ latestTimestampOf: p2p.latestProvenanceTimestamp,
2376
+ });
2377
+ const nowMs = Date.now();
2378
+ const waving = wavingCharacters(room.factRows(), nowMs);
2379
+ el("nodeCount").textContent = rows.length + (rows.length === 1 ? " node" : " nodes");
2380
+ listEl.textContent = "";
2381
+ for (const entry of rows) {
2382
+ const item = document.createElement("li");
2383
+ item.className = "node-row";
2384
+ item.dataset.self = String(entry.isSelf);
2385
+ item.dataset.away = String(!entry.connected);
2386
+ item.dataset.peer = entry.peerId;
2387
+ item.dataset.waving = String(waving.some(function (character) {
2388
+ const owner = claimantOf(room.factRows(), character);
2389
+ return owner !== null && peerTerm(owner) === peerTerm(entry.peerId);
2390
+ }));
2391
+ const avatar = document.createElement("span");
2392
+ avatar.className = "node-avatar";
2393
+ avatar.textContent = nodeInitials(entry.name);
2394
+ avatar.title = entry.connected
2395
+ ? "connected"
2396
+ : "away \\u2014 closed the tab or dropped offline; everything it dug stays";
2397
+ const name = document.createElement("span");
2398
+ name.className = "node-name";
2399
+ name.textContent = entry.name;
2400
+ const hand = document.createElement("span");
2401
+ hand.className = "node-hand";
2402
+ hand.textContent = "\\u{1F44B}";
2403
+ const when = document.createElement("span");
2404
+ when.className = "node-when";
2405
+ when.textContent = relativeWhen(entry.lastActiveAt, nowMs);
2406
+ item.appendChild(avatar);
2407
+ item.appendChild(name);
2408
+ if (entry.isSelf) {
2409
+ const self = document.createElement("span");
2410
+ self.className = "node-self";
2411
+ self.textContent = "you";
2412
+ item.appendChild(self);
2413
+ }
2414
+ item.appendChild(hand);
2415
+ item.appendChild(when);
2416
+ listEl.appendChild(item);
2417
+ }
2418
+ el("nodeEmpty").hidden = rows.length > 1;
2419
+ }
2420
+
2421
+ async function copyText(text) {
2422
+ try {
2423
+ if (navigator.clipboard && navigator.clipboard.writeText) {
2424
+ await navigator.clipboard.writeText(text);
2425
+ return true;
2426
+ }
2427
+ } catch { /* fall through — the box on the page still holds the text */ }
2428
+ return false;
2429
+ }
2430
+
2431
+ const openNetPanel = function () {
2432
+ el("netPanel").hidden = false;
2433
+ el("statePill").setAttribute("aria-expanded", "true");
2434
+ };
2435
+ const closeNetPanel = function () {
2436
+ el("netPanel").hidden = true;
2437
+ el("statePill").setAttribute("aria-expanded", "false");
2438
+ };
2439
+
2440
+ function wireNetPanel() {
2441
+ el("netPanelClose").addEventListener("click", closeNetPanel);
2442
+ el("statePill").addEventListener("click", function () {
2443
+ if (el("netPanel").hidden) openNetPanel(); else closeNetPanel();
2444
+ });
2445
+
2446
+ el("shareBtn").addEventListener("click", async function () {
2447
+ const shareBtn = el("shareBtn");
2448
+ shareBtn.disabled = true;
2449
+ try {
2450
+ const active = await ensureRoom();
2451
+ const minted = await active.startSharing();
2452
+ el("shareLink").value = inviteLinkFor(window.location.href, {
2453
+ blob: minted.blob, world: worldId, worldName: worldName,
2454
+ });
2455
+ el("replyProblem").hidden = true;
2456
+ openNetPanel();
2457
+ renderWire();
2458
+ await copyText(el("shareLink").value);
2459
+ } catch (err) {
2460
+ el("replyProblem").textContent = "couldn't create an invite (" + (err && err.message ? err.message : err) + ").";
2461
+ el("replyProblem").hidden = false;
2462
+ openNetPanel();
2463
+ } finally {
2464
+ shareBtn.disabled = false;
2465
+ }
2466
+ });
2467
+
2468
+ // The inviter's page has exactly one paste target, so there is no wrong box
2469
+ // to choose. A rejected paste keeps its text so the copy can be fixed.
2470
+ el("replyBtn").addEventListener("click", async function () {
2471
+ let active = room;
2472
+ if (!active) {
2473
+ try { active = await ensureRoom(); } catch { return; }
2474
+ }
2475
+ const outcome = await active.completeInvite(el("replyBox").value);
2476
+ if (outcome && outcome.error) {
2477
+ el("replyProblem").textContent = outcome.message;
2478
+ el("replyProblem").hidden = false;
2479
+ return;
2480
+ }
2481
+ el("replyProblem").hidden = true;
2482
+ el("replyBox").value = "";
2483
+ renderWire();
2484
+ });
2485
+
2486
+ el("copyReplyBtn").addEventListener("click", function () { copyText(el("replyOut").value); });
2487
+
2488
+ // The link flow lands somebody on the join card before they have dug
2489
+ // anything. This is the other case: a burrow already open, an invite that
2490
+ // arrived in a message, and no reason to throw the burrow away to take it.
2491
+ el("joinOpenBtn").addEventListener("click", function () {
2492
+ el("invitePanel").hidden = false;
2493
+ openNetPanel();
2494
+ el("inviteBox").focus();
2495
+ });
2496
+
2497
+ el("inviteBtn").addEventListener("click", async function () {
2498
+ const problem = el("inviteProblem");
2499
+ const pasted = el("inviteBox").value.trim();
2500
+ if (!pasted) {
2501
+ problem.textContent = "paste the invite you were sent, then try again";
2502
+ problem.hidden = false;
2503
+ return;
2504
+ }
2505
+ if (room) {
2506
+ problem.textContent = "this burrow is already shared. Reset it first, then take the invite.";
2507
+ problem.hidden = false;
2508
+ return;
2509
+ }
2510
+ // A whole link and a bare blob are both what people actually paste, so
2511
+ // both are read. The blob's own envelope decides either way.
2512
+ const blob = offerBlobIn(pasted);
2513
+ const mod = await loadP2p();
2514
+ const decoded = mod.decodeInviteBlob(blob);
2515
+ if (decoded.error || decoded.value.kind !== "offer") {
2516
+ problem.textContent = decoded.error
2517
+ ? "this invite looks cut short \\u2014 ask for it to be sent again"
2518
+ : "that's a reply, not an invite \\u2014 it belongs on the page that sent the invite";
2519
+ problem.hidden = false;
2520
+ return;
2521
+ }
2522
+ // The world id has to be the invite's before the room is opened: a room
2523
+ // that minted its own would refuse the invite as belonging elsewhere.
2524
+ await ensureIdentity();
2525
+ worldId = decoded.value.world || worldId;
2526
+ if (decoded.value.worldName) {
2527
+ worldName = decoded.value.worldName;
2528
+ el("worldNameInput").value = worldName;
2529
+ }
2530
+ try {
2531
+ const active = await ensureRoom();
2532
+ const outcome = await active.acceptInvite(blob);
2533
+ if (outcome && outcome.error) {
2534
+ problem.textContent = outcome.message;
2535
+ problem.hidden = false;
2536
+ return;
2537
+ }
2538
+ problem.hidden = true;
2539
+ el("inviteBox").value = "";
2540
+ el("replyOut").value = outcome.blob;
2541
+ renderWire();
2542
+ await copyText(outcome.blob);
2543
+ } catch (err) {
2544
+ problem.textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
2545
+ problem.hidden = false;
2546
+ }
2547
+ });
2548
+
2549
+ el("nodeNameInput").addEventListener("change", function () {
2550
+ const name = el("nodeNameInput").value.trim();
2551
+ if (!name || name === myDisplayName) { el("nodeNameInput").value = myDisplayName; return; }
2552
+ myDisplayName = name;
2553
+ writeStoredNodeName(name);
2554
+ if (!room) { renderNodes(); return; }
2555
+ room.setMyDisplayName(name).then(renderNodes).catch(function () { /* the next broadcast carries it */ });
2556
+ });
2557
+ el("worldNameInput").addEventListener("change", function () {
2558
+ const input = el("worldNameInput");
2559
+ if (input.readOnly) { input.value = worldName; return; }
2560
+ const name = input.value.trim();
2561
+ if (name) worldName = name;
2562
+ input.value = worldName;
2563
+ });
2564
+
2565
+ el("joinBtn").addEventListener("click", async function () {
2566
+ const joinBtn = el("joinBtn");
2567
+ joinBtn.disabled = true;
2568
+ try {
2569
+ const active = await ensureRoom();
2570
+ const outcome = await active.acceptInvite(invite.offer);
2571
+ if (outcome && outcome.error) {
2572
+ el("joinProblem").textContent = outcome.message;
2573
+ el("joinProblem").hidden = false;
2574
+ joinBtn.disabled = false;
2575
+ joinBtn.textContent = "try again";
2576
+ return;
2577
+ }
2578
+ el("joinProblem").hidden = true;
2579
+ el("joinReply").value = outcome.blob;
2580
+ el("replyOut").value = outcome.blob;
2581
+ el("joinReplyWrap").hidden = false;
2582
+ joinBtn.textContent = "reply created";
2583
+ el("joinDismiss").textContent = "close this and start digging";
2584
+ renderWire();
2585
+ await copyText(outcome.blob);
2586
+ } catch (err) {
2587
+ el("joinProblem").textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
2588
+ el("joinProblem").hidden = false;
2589
+ joinBtn.disabled = false;
2590
+ }
2591
+ });
2592
+ el("joinCopyBtn").addEventListener("click", function () { copyText(el("joinReply").value); });
2593
+ el("joinDismiss").addEventListener("click", function () {
2594
+ el("joinCard").hidden = true;
2595
+ if (el("replyOut").value) openNetPanel();
2596
+ });
2597
+ }
2598
+
2599
+ // A link that lost part of itself on the way reads as a mangled link, never
2600
+ // as no invite at all.
2601
+ async function prepareJoinCard() {
2602
+ el("joinCard").hidden = false;
2603
+ el("joinWorld").textContent = invite.worldName || "a shared burrow";
2604
+ el("joinBtn").focus();
2605
+ const mod = await loadP2p();
2606
+ const decoded = mod.decodeInviteBlob(invite.offer);
2607
+ if (decoded.error || decoded.value.kind !== "offer") {
2608
+ el("joinBtn").hidden = true;
2609
+ el("joinEyebrow").textContent = "this link didn't arrive in one piece";
2610
+ el("joinWorld").textContent = invite.worldName || "an invitation";
2611
+ el("joinBody").textContent = decoded.error
2612
+ ? "Part of the link was lost on the way here. Ask for it to be sent again, and check the whole thing travels."
2613
+ : "That link carries a reply rather than an invite. It belongs in the box on the page that sent the invite.";
2614
+ el("joinDismiss").textContent = "dig on your own instead";
2615
+ return;
2616
+ }
2617
+ if (decoded.value.world) invite.world = decoded.value.world;
2618
+ if (decoded.value.worldName) {
2619
+ invite.worldName = decoded.value.worldName;
2620
+ el("joinWorld").textContent = decoded.value.worldName;
2621
+ }
2622
+ }
2623
+
1886
2624
  // ---- the control deck ----------------------------------------------------
1887
2625
  // The players slider runs on detents (1, 2, 4), so its own value is the
1888
2626
  // index of the count, never the count.
@@ -1942,6 +2680,22 @@ function pageScript() {
1942
2680
  npcCountSlider.addEventListener("input", function () { showNpcCount(chosenNpcCount()); });
1943
2681
  npcCountSlider.addEventListener("change", function () { boot(); });
1944
2682
  el("resetBtn").addEventListener("click", function () { boot(); });
2683
+ // Picking a burrow is a recast like any other, so it runs the same boot
2684
+ // the sliders and reset run. Edit mode follows the world rather than the
2685
+ // page: whatever is half-typed is flushed into the burrow it was typed
2686
+ // over, and the editor reopens on the one that just loaded.
2687
+ const scenarioSelect = el("scenarioSelect");
2688
+ if (scenarioSelect) {
2689
+ scenarioSelect.addEventListener("change", async function () {
2690
+ const picked = Number(scenarioSelect.value);
2691
+ if (!DATA.scenarios[picked] || picked === scenarioIndex) return;
2692
+ const wasEditing = editing;
2693
+ if (wasEditing) await exitEditMode();
2694
+ scenarioIndex = picked;
2695
+ await boot("a different burrow opened \\u2014 still linked.");
2696
+ if (wasEditing) await enterEditMode();
2697
+ });
2698
+ }
1945
2699
  el("editModeBtn").addEventListener("click", function () {
1946
2700
  if (!session) return;
1947
2701
  if (editing) exitEditMode(); else enterEditMode();
@@ -1996,9 +2750,31 @@ function pageScript() {
1996
2750
  // session on the floor rather than binding it to the stage the newer boot
1997
2751
  // has already drawn.
1998
2752
  let bootSeq = 0;
1999
- async function boot() {
2753
+ let bootRun = null;
2754
+ function boot(note) {
2755
+ bootRun = openWorld(note);
2756
+ return bootRun;
2757
+ }
2758
+
2759
+ async function openWorld(note) {
2000
2760
  const seq = bootSeq += 1;
2001
2761
  autoOn = false;
2762
+ // A live link survives a recast: once the new burrow's store is open the
2763
+ // room re-binds to it (below), pushing the fresh world to every connected
2764
+ // node instead of quietly abandoning them. The recast moves the world one
2765
+ // epoch forward, read from the store the peers have all converged on, so
2766
+ // nobody's leftover snapshots from the old run can outrank the new one.
2767
+ const liveRoom = room;
2768
+ let nextEpoch = 0;
2769
+ if (liveRoom && session) {
2770
+ try {
2771
+ nextEpoch = (await session.snapshot()).state.epoch + 1;
2772
+ } catch (err) {
2773
+ nextEpoch = 1;
2774
+ }
2775
+ }
2776
+ claimedElsewhere = {};
2777
+ wavingNow = [];
2002
2778
  const playBtn = el("autoToggle");
2003
2779
  playBtn.setAttribute("aria-pressed", "false");
2004
2780
  playBtn.textContent = "\\u25B6 play";
@@ -2008,7 +2784,7 @@ function pageScript() {
2008
2784
  freshlyDugRoom = null;
2009
2785
  lastSnapshot = null;
2010
2786
  speechBubbles.clear();
2011
- tickChain = Promise.resolve();
2787
+ tickQueue = createSerialQueue();
2012
2788
  // Rebuilt rather than pruned: a character that had a pane last boot can be
2013
2789
  // an npc in this one, and a stale seat would send its ending to a pane now
2014
2790
  // showing somebody else.
@@ -2023,6 +2799,10 @@ function pageScript() {
2023
2799
  // animal it has and no empty pane. The npcs draw from what the panes left,
2024
2800
  // out of a roster grown to hold everyone, so no animal is ever both.
2025
2801
  const npcCount = chosenNpcCount();
2802
+ // Read fresh, never cached at load: each burrow authors its own animals,
2803
+ // so the roster belongs to the scenario rather than to the page.
2804
+ roster = rosterOf(scenario());
2805
+ rootRoom = rootRoomOf(scenario());
2026
2806
  cast = window.tmctMud.pickMudRoster(roster, { count: chosenPlayerCount() });
2027
2807
  const pool = window.tmctMud.expandMudRoster(roster, cast.length + npcCount)
2028
2808
  .filter(function (id) { return cast.indexOf(id) === -1; });
@@ -2031,9 +2811,23 @@ function pageScript() {
2031
2811
  showPlayerCount(cast.length);
2032
2812
  showNpcCount(npcs.length);
2033
2813
  renderStage();
2034
- const opened = await window.tmctMud.createMudSession(DATA.worldPayload, { characters: everyone() });
2814
+ const opened = await window.tmctMud.createMudSession(scenario().worldPayload, { characters: everyone(), epoch: nextEpoch });
2035
2815
  if (seq !== bootSeq) return;
2036
2816
  session = opened;
2817
+ if (liveRoom) {
2818
+ try {
2819
+ await liveRoom.rebind({ memoryDir: opened.memoryDir, worldName: worldName, myDisplayName: myDisplayName });
2820
+ renderWire();
2821
+ renderNodes();
2822
+ // After renderWire, the same way dropRoom writes its own reason: the
2823
+ // render restates the state's stock note, and WHY this recast kept
2824
+ // the link is the thing worth saying over it.
2825
+ el("wireStateNote").textContent = note || "the burrow recast \\u2014 still linked.";
2826
+ await claimMyAnimals();
2827
+ } catch (err) {
2828
+ dropRoom("the link didn't survive the recast \\u2014 share again to link up.");
2829
+ }
2830
+ }
2037
2831
  for (let i = 0; i < cast.length; i += 1) slotOf[cast[i]] = slots[i];
2038
2832
  bindPanes();
2039
2833
  for (const character of cast) {
@@ -2050,6 +2844,20 @@ function pageScript() {
2050
2844
  }
2051
2845
 
2052
2846
  wireDeck();
2847
+ wireNetPanel();
2848
+ renderWire();
2849
+ renderNodes();
2053
2850
  boot();
2851
+ // Off the boot path on purpose: this fetches the shared P2P asset so the
2852
+ // panel can show a real node name and burrow name before anyone clicks
2853
+ // anything. A failure here costs sharing, never the digging.
2854
+ ensureIdentity().catch(function () { /* sharing stays unavailable; the burrow does not */ });
2855
+ if (invite) {
2856
+ prepareJoinCard().catch(function (err) {
2857
+ el("joinProblem").textContent = "the networking asset didn't load (" + (err && err.message ? err.message : err) + ").";
2858
+ el("joinProblem").hidden = false;
2859
+ el("joinBtn").hidden = true;
2860
+ });
2861
+ }
2054
2862
  })();`;
2055
2863
  }