@polycode-projects/the-mechanical-code-talker 4.0.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 (68) 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 +34 -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/domain/grammar/ace.mjs +40 -4
  46. package/src/domain/grammar/lexicon-core.json +2 -1
  47. package/src/domain/grammar/lexicon.mjs +18 -0
  48. package/src/domain/reference-pack.mjs +31 -7
  49. package/src/domain/spider-fly-world.mjs +1 -1
  50. package/src/domain/sprite-templates.mjs +8 -6
  51. package/src/services/adventure-viz.mjs +130 -47
  52. package/src/services/adventure.mjs +440 -367
  53. package/src/services/chat-page-viz.mjs +37 -9
  54. package/src/services/chat.mjs +99 -18
  55. package/src/services/code-explorer-viz.mjs +52 -14
  56. package/src/services/extract-facts.mjs +4 -7
  57. package/src/services/ingest-viz.mjs +54 -26
  58. package/src/services/ledger-viz.mjs +105 -56
  59. package/src/services/memory-panel-viz.mjs +24 -0
  60. package/src/services/mud-viz.mjs +940 -21
  61. package/src/services/plan-viz.mjs +119 -61
  62. package/src/services/research-viz.mjs +151 -57
  63. package/src/services/spider-fly-turn.mjs +1 -1
  64. package/src/services/spider-fly-viz.mjs +67 -43
  65. package/src/services/sprite-catalog-viz.mjs +175 -51
  66. package/src/services/viz-theme.mjs +15 -0
  67. package/src/surfaces/web/memory-ask-browser.bundle.js +107 -107
  68. package/src/surfaces/web/mud-browser-entry.mjs +44 -2
@@ -73,13 +73,16 @@
73
73
  // queue is also what makes the deck's GLOBAL turn counter well-defined: it
74
74
  // increments in the exact order turns actually executed, never a race
75
75
  // between panes.
76
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
76
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, scenarioLabel } from "./viz-theme.mjs";
77
77
  import { createTicker } from "./viz-ticker.mjs";
78
78
  import {
79
79
  roomSceneObjects, scenePlacement, spriteClassForObject, spriteAncestryRows, factsForSubject,
80
80
  visibleRoomOf, roomKindForRoom, allRoomIds,
81
81
  } from "./adventure-viz.mjs";
82
82
  import { renderMudEditorText, wordBeforeCursor } from "./mud-editor.mjs";
83
+ import {
84
+ wireStateLabel, nodeRowsFor, nodeInitials, inviteLinkFor, inviteParamsFrom,
85
+ } from "./chat-page-viz.mjs";
83
86
  import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
84
87
 
85
88
  const DEFAULT_TITLE = "tmct — the mud";
@@ -110,6 +113,7 @@ const DEFAULT_MAX_TURNS = 400;
110
113
  const MUD_NOTE_LINES = [
111
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.",
112
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.",
113
117
  ];
114
118
 
115
119
  /** A character id's species — "mole-1" -> "mole", "groundhog-1" ->
@@ -269,6 +273,106 @@ export function isCreature(state, subject) {
269
273
  return state.placements.get(subject)?.predicate === "mgx:currently-in";
270
274
  }
271
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);
374
+ }
375
+
272
376
  /** The self-contained mud.html page. Pure — identical output for identical
273
377
  * input; every other piece of state (the live world, chat, ticks) is
274
378
  * computed in the browser once the sibling bundle loads.
@@ -280,7 +384,12 @@ export function isCreature(state, subject) {
280
384
  * the roster cannot fill draws as many panes as it has animals.
281
385
  * `worldPayload` is `{ name, facts, rules, opening }`, read once at build
282
386
  * time through the real worlds-pack provider (see this module's own
283
- * header). `spriteTemplates` is the
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
284
393
  * large-tier sprite set (data/sprites-large/*.toml) so every species
285
394
  * resolves its own art instead of falling back to the flat animal icon.
286
395
  * `mudConfig` defaults to game-config.mjs's own DEFAULT_GAME_CONFIG.mud —
@@ -293,13 +402,21 @@ export function renderMudHtml({
293
402
  title = DEFAULT_TITLE,
294
403
  worldPayload,
295
404
  characters = [],
405
+ scenarios = [],
296
406
  spriteTemplates = [],
297
407
  mudConfig = DEFAULT_GAME_CONFIG.mud,
298
408
  engineBundleJs = "",
299
409
  } = {}) {
300
- const openingCount = Math.max(1, Math.min(DEFAULT_PLAYER_COUNT, characters.length || DEFAULT_PLAYER_COUNT));
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
+ );
301
418
  const pageData = embedJson({
302
- worldPayload, characters, spriteTemplates, mudConfig,
419
+ scenarios: scenarioList, spriteTemplates, mudConfig,
303
420
  paneSlots: PANE_SLOTS,
304
421
  playerCounts: PLAYER_COUNTS,
305
422
  defaultPlayerCount: DEFAULT_PLAYER_COUNT,
@@ -333,7 +450,14 @@ ${MUD_STYLE}
333
450
  <div class="deck-controls">
334
451
  <button type="button" class="deck-play" id="autoToggle" aria-pressed="false">&#9654; play</button>
335
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>` : ""}
336
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>
337
461
  <button type="button" class="deck-info-btn" id="deckInfoBtn" aria-expanded="false" aria-controls="deckInfoPopup" aria-label="about this demo">?</button>
338
462
  <span class="mono deck-turns" id="globalTurnCount">turns: 0</span>
339
463
  </div>
@@ -365,8 +489,7 @@ ${MUD_STYLE}
365
489
  </label>
366
490
  </div>
367
491
  <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>
492
+ ${MUD_NOTE_LINES.map((line) => `<p>${escapeHtml(line)}</p>`).join("\n ")}
370
493
  <button type="button" class="deck-info-popup-close" id="deckInfoClose" aria-label="close">&times;</button>
371
494
  </div>
372
495
  </section>
@@ -379,6 +502,61 @@ ${MUD_STYLE}
379
502
  <div class="world-map-key" id="worldMapKey"></div>
380
503
  </section>
381
504
  </div>
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">
514
+ </label>
515
+ <label class="net-field">this burrow
516
+ <input type="text" id="worldNameInput" spellcheck="false" aria-label="the name of the shared burrow">
517
+ </label>
518
+ </div>
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>
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>
382
560
  <div class="mud-stage" id="mudStage" data-panes="${openingCount}">
383
561
  ${paneHtml}
384
562
  </div>
@@ -430,6 +608,7 @@ export function paneMarkup(slot) {
430
608
  return ` <section class="mud-window pane-${escapeHtml(slot)}" id="${w}" data-slot="${escapeHtml(slot)}" data-character="">
431
609
  <div class="pane-head">
432
610
  <h2 id="${w}-name">waiting</h2>
611
+ <span class="pane-node" id="${w}-node" hidden></span>
433
612
  <span class="mono pane-turn" id="${w}-turn">turn 0</span>
434
613
  </div>
435
614
  <div class="room-stage">
@@ -473,7 +652,11 @@ export function paneMarkup(slot) {
473
652
  </div>
474
653
  </div>
475
654
  <div class="chat-console">
476
- <div class="chatpills" id="${w}-chatpills" role="group" aria-label="quick commands"></div>
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>
477
660
  <form class="chatask" id="${w}-chatform">
478
661
  <span class="prompt mono">tmct&gt;</span>
479
662
  <input id="${w}-chatq" type="text" placeholder="look" aria-label="type a command" disabled>
@@ -530,6 +713,12 @@ const MUD_STYLE = `
530
713
  padding: .32rem .7rem; border: 1px solid var(--soil-mid); border-radius: 3px; background: rgba(255,255,255,.5);
531
714
  }
532
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);
720
+ }
721
+ .deck-select:hover { border-color: var(--burrow-glow); }
533
722
  .deck-play { background: var(--mud-ink) !important; color: var(--parchment); border-color: var(--mud-ink) !important; padding: .38rem 1.1rem !important; }
534
723
  .deck-play[aria-pressed="true"] { background: var(--burrow-glow) !important; border-color: var(--burrow-glow) !important; color: var(--mud-ink); }
535
724
  .deck-turns { margin-left: auto; font-size: .74rem; color: var(--soil-mid); }
@@ -885,6 +1074,138 @@ const MUD_STYLE = `
885
1074
  .edit-stage { grid-template-columns: 1fr; }
886
1075
  #editorText { min-height: 18rem; }
887
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
+ }
888
1209
  `;
889
1210
 
890
1211
  /** The inlined page script, as a plain function body handed to
@@ -918,9 +1239,37 @@ function pageScript() {
918
1239
  const allRoomIds = ${allRoomIds.toString()};
919
1240
  const renderMudEditorText = ${renderMudEditorText.toString()};
920
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()};
921
1253
 
922
1254
  const el = (id) => document.getElementById(id);
923
- const roster = DATA.characters.map(function (c) { return 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());
924
1273
  let slots = [];
925
1274
  const ACTOR_COLORS = ["var(--actor-a)", "var(--actor-b)", "var(--actor-c)", "var(--actor-d)"];
926
1275
  const NPC_COLOR = "var(--chalk)";
@@ -956,6 +1305,15 @@ function pageScript() {
956
1305
  const liveWait = function () { return wait(delayMs); };
957
1306
 
958
1307
  let freshlyDugRoom = null;
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;
959
1317
  // The last omniscient read, kept so the pounce can draw the predator's own
960
1318
  // den after the engine has already moved the character out of every room.
961
1319
  let lastSnapshot = null;
@@ -991,10 +1349,15 @@ function pageScript() {
991
1349
  const bootAt = bootSeq;
992
1350
  return serializeTick(async function () {
993
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 };
994
1356
  globalTurn += 1;
995
1357
  const result = await session.windows[character].autoplayTick(globalTurn);
996
1358
  if (!result.outOfPlay) turnsTaken[character] = (turnsTaken[character] || 0) + 1;
997
1359
  afterEngineTurn(character, result);
1360
+ await broadcastLocalChange();
998
1361
  return result;
999
1362
  });
1000
1363
  }
@@ -1089,7 +1452,7 @@ function pageScript() {
1089
1452
  for (const action of result.actions || []) {
1090
1453
  if (action.kind !== "ask" && action.kind !== "talk") continue;
1091
1454
  const other = action.teller || action.target || action.object;
1092
- noteSpeech(result.room, character, { text: "what food do you know about?" });
1455
+ if (action.text) noteSpeech(result.room, character, { text: action.text });
1093
1456
  if (other && action.thing) noteSpeech(result.room, other, { thing: action.thing });
1094
1457
  else if (other && action.text) noteSpeech(result.room, other, { text: action.text });
1095
1458
  }
@@ -1146,13 +1509,42 @@ function pageScript() {
1146
1509
 
1147
1510
  function sendCommand(character, line) {
1148
1511
  appendChat(character, "u", line);
1149
- return serializeTick(function () { return session.windows[character].turn(line); }).then(function (res) {
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) {
1150
1530
  appendChat(character, "a", res.answer);
1531
+ await broadcastLocalChange();
1151
1532
  renderSoon();
1152
1533
  return res;
1153
1534
  });
1154
1535
  }
1155
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
+
1156
1548
  // Wired as the pane is built. The stage is rebuilt on every boot, so a
1157
1549
  // pane's listeners live and die with it; each one still reads its slot's
1158
1550
  // character at click time, because the cast is bound after the markup.
@@ -1177,6 +1569,10 @@ function pageScript() {
1177
1569
  const character = characterInSlot(slot);
1178
1570
  if (character) ensureTicker(character).stepOnce();
1179
1571
  });
1572
+ el(w + "-wave").addEventListener("click", function () {
1573
+ const character = characterInSlot(slot);
1574
+ if (character) sendCommand(character, "wave");
1575
+ });
1180
1576
 
1181
1577
  const log = el(w + "-chatlog");
1182
1578
  log.addEventListener("click", function (e) {
@@ -1317,17 +1713,33 @@ function pageScript() {
1317
1713
 
1318
1714
  // A sprite card: adventure.html's own frame-plus-nameplate, carrying the one
1319
1715
  // 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) {
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 || {};
1322
1722
  const action = command
1323
1723
  ? ' actionable" data-command="' + esc(command) + '" role="button" tabindex="0" title="' + esc(command) + '"'
1324
1724
  : '" title="' + esc(label) + '"';
1325
- return '<div class="sprite-card ' + (extraClass || "") + action + ' data-subject="' + esc(subject) + '">'
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>' : "")
1326
1728
  + inner + '<div class="sprite-label">' + esc(label) + "</div>"
1729
+ + (opts.node ? '<div class="sprite-node">via ' + esc(opts.node) + "</div>" : "")
1327
1730
  + (mass === null || mass === undefined ? "" : '<div class="sprite-mass">mass ' + esc(mass) + "</div>")
1328
1731
  + "</div>";
1329
1732
  }
1330
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;
1741
+ }
1742
+
1331
1743
  function renderRoomView(character, rows, state, here, roomIds, roomMates) {
1332
1744
  const w = paneIdFor(character);
1333
1745
  drawRoomBackdrop(el(w + "-canvas"), roomKindForRoom(rows, here), here);
@@ -1337,12 +1749,14 @@ function pageScript() {
1337
1749
  character, character,
1338
1750
  '<div class="sprite">' + spriteSvgFor(speciesOfCharacter(character), rows, character) + "</div>",
1339
1751
  null, "self", massOf(state, character),
1752
+ { waving: isWavingNow(character), node: nodeLabelFor(character) },
1340
1753
  );
1341
1754
 
1342
1755
  const others = roomMates.map(function (mate) {
1343
1756
  return spriteCardHtml(mate, mate,
1344
1757
  '<div class="sprite">' + spriteSvgFor(speciesOfCharacter(mate), rows, mate) + "</div>",
1345
- commands[mate], "", massOf(state, mate));
1758
+ commands[mate], "", massOf(state, mate),
1759
+ { waving: isWavingNow(mate), node: nodeLabelFor(mate) });
1346
1760
  });
1347
1761
  const wall = [];
1348
1762
  for (const obj of mudRoomSceneObjects(rows, state, here, character)) {
@@ -1571,14 +1985,14 @@ function pageScript() {
1571
1985
 
1572
1986
  function renderMinimap(character, rows, state, here) {
1573
1987
  const visited = session.windows[character].visitedRoomIds();
1574
- const graph = burrowGraph(state, visited, DATA.rootRoom);
1988
+ const graph = burrowGraph(state, visited, rootRoom);
1575
1989
  el(paneIdFor(character) + "-minimap").innerHTML = burrowSvg(graph, {
1576
1990
  compact: true, here: here, label: "the ground " + character + " has covered",
1577
1991
  });
1578
1992
  }
1579
1993
 
1580
1994
  function renderWorldMap(rows, state, roomIds) {
1581
- const graph = burrowGraph(state, roomIds, DATA.rootRoom);
1995
+ const graph = burrowGraph(state, roomIds, rootRoom);
1582
1996
  const occupants = {};
1583
1997
  for (const room of roomIds) {
1584
1998
  const here = charactersInRoom(state, room, everyone());
@@ -1636,13 +2050,35 @@ function pageScript() {
1636
2050
  const snap = await session.snapshot();
1637
2051
  lastSnapshot = snap;
1638
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();
2068
+ }
2069
+ }
1639
2070
  for (const character of everyone()) {
1640
2071
  const reason = window.tmctMud.outOfPlayReasonOf(snap.state, character);
1641
2072
  if (reason) markOutOfPlay(character, reason, null);
1642
2073
  }
1643
2074
  renderWorldMap(snap.rows, snap.state, roomIds);
1644
2075
  for (const character of cast) {
1645
- el(paneIdFor(character) + "-turn").textContent = "turn " + turnsFor(character);
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 : "";
1646
2082
  if (finished[character]) continue;
1647
2083
  const place = snap.state.placements.get(character);
1648
2084
  const here = place ? place.object : null;
@@ -1655,6 +2091,12 @@ function pageScript() {
1655
2091
  renderMinimap(character, snap.rows, snap.state, here);
1656
2092
  }
1657
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);
1658
2100
  }
1659
2101
 
1660
2102
  // ---- edit mode -----------------------------------------------------------
@@ -1675,14 +2117,14 @@ function pageScript() {
1675
2117
  let editing = false;
1676
2118
 
1677
2119
  function worldOnlyRows(rows) {
1678
- const prefix = "world:" + DATA.worldPayload.name;
2120
+ const prefix = "world:" + scenario().worldPayload.name;
1679
2121
  return (rows || []).filter(function (r) {
1680
2122
  return typeof r.provenance === "string" && r.provenance.indexOf(prefix) === 0;
1681
2123
  });
1682
2124
  }
1683
2125
 
1684
2126
  function renderEditMap() {
1685
- const graph = burrowGraph(editState, allRoomIds(editRows), DATA.rootRoom);
2127
+ const graph = burrowGraph(editState, allRoomIds(editRows), rootRoom);
1686
2128
  el("editMapBoard").innerHTML = graph.nodes.length
1687
2129
  ? burrowSvg(graph, { here: selectedRoomId, label: "every room this world writes" })
1688
2130
  : '<span class="edit-empty">no rooms written yet</span>';
@@ -1799,6 +2241,7 @@ function pageScript() {
1799
2241
  status.className = "edit-status pending";
1800
2242
  status.textContent = "reading the burrow\\u2026";
1801
2243
  const result = await serializeTick(function () { return session.applyEdit(el("editorText").value); });
2244
+ await broadcastLocalChange();
1802
2245
  const snap = await session.snapshot();
1803
2246
  allStoreRows = snap.rows;
1804
2247
  editRows = worldOnlyRows(snap.rows);
@@ -1883,6 +2326,433 @@ function pageScript() {
1883
2326
  await renderAll();
1884
2327
  }
1885
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
+
1886
2756
  // ---- the control deck ----------------------------------------------------
1887
2757
  // The players slider runs on detents (1, 2, 4), so its own value is the
1888
2758
  // index of the count, never the count.
@@ -1942,6 +2812,22 @@ function pageScript() {
1942
2812
  npcCountSlider.addEventListener("input", function () { showNpcCount(chosenNpcCount()); });
1943
2813
  npcCountSlider.addEventListener("change", function () { boot(); });
1944
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
+ }
1945
2831
  el("editModeBtn").addEventListener("click", function () {
1946
2832
  if (!session) return;
1947
2833
  if (editing) exitEditMode(); else enterEditMode();
@@ -1996,9 +2882,24 @@ function pageScript() {
1996
2882
  // session on the floor rather than binding it to the stage the newer boot
1997
2883
  // has already drawn.
1998
2884
  let bootSeq = 0;
1999
- async function boot() {
2885
+ let bootRun = null;
2886
+ function boot(note) {
2887
+ bootRun = openWorld(note);
2888
+ return bootRun;
2889
+ }
2890
+
2891
+ async function openWorld(note) {
2000
2892
  const seq = bootSeq += 1;
2001
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 = [];
2002
2903
  const playBtn = el("autoToggle");
2003
2904
  playBtn.setAttribute("aria-pressed", "false");
2004
2905
  playBtn.textContent = "\\u25B6 play";
@@ -2023,6 +2924,10 @@ function pageScript() {
2023
2924
  // animal it has and no empty pane. The npcs draw from what the panes left,
2024
2925
  // out of a roster grown to hold everyone, so no animal is ever both.
2025
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());
2026
2931
  cast = window.tmctMud.pickMudRoster(roster, { count: chosenPlayerCount() });
2027
2932
  const pool = window.tmctMud.expandMudRoster(roster, cast.length + npcCount)
2028
2933
  .filter(function (id) { return cast.indexOf(id) === -1; });
@@ -2031,7 +2936,7 @@ function pageScript() {
2031
2936
  showPlayerCount(cast.length);
2032
2937
  showNpcCount(npcs.length);
2033
2938
  renderStage();
2034
- const opened = await window.tmctMud.createMudSession(DATA.worldPayload, { characters: everyone() });
2939
+ const opened = await window.tmctMud.createMudSession(scenario().worldPayload, { characters: everyone() });
2035
2940
  if (seq !== bootSeq) return;
2036
2941
  session = opened;
2037
2942
  for (let i = 0; i < cast.length; i += 1) slotOf[cast[i]] = slots[i];
@@ -2050,6 +2955,20 @@ function pageScript() {
2050
2955
  }
2051
2956
 
2052
2957
  wireDeck();
2958
+ wireNetPanel();
2959
+ renderWire();
2960
+ renderNodes();
2053
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
+ }
2054
2973
  })();`;
2055
2974
  }