@polycode-projects/the-mechanical-code-talker 6.0.20 → 6.0.22

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 (38) hide show
  1. package/README.md +5 -15
  2. package/bin/tmct.mjs +8 -3
  3. package/package.json +1 -5
  4. package/src/adapters/memory/core.mjs +108 -96
  5. package/src/adapters/memory/corpus-bands.mjs +6 -0
  6. package/src/domain/agent-traits.mjs +1 -1
  7. package/src/domain/answer-variants.json +1 -1
  8. package/src/domain/cli-verbs.mjs +1 -0
  9. package/src/domain/memory/causal-stability.mjs +22 -5
  10. package/src/domain/memory/fact-order.mjs +3 -3
  11. package/src/domain/memory/provenance-time.mjs +35 -0
  12. package/src/domain/memory/retraction.mjs +3 -5
  13. package/src/domain/memory/trust.mjs +11 -11
  14. package/src/domain/news-feed.mjs +1 -1
  15. package/src/domain/seeded-random.mjs +6 -6
  16. package/src/services/adventure.mjs +10 -41
  17. package/src/services/chat-page-viz.mjs +12 -1111
  18. package/src/services/chat-session.mjs +20 -6
  19. package/src/services/chat.mjs +269 -197
  20. package/src/services/extract-facts.mjs +1 -1
  21. package/src/services/import-file.mjs +1 -1
  22. package/src/services/mud-viz.mjs +21 -1082
  23. package/src/services/mudiii-viz.mjs +0 -4
  24. package/src/services/news.mjs +44 -24
  25. package/src/services/pill-complete.mjs +5 -8
  26. package/src/services/predator-prey.mjs +3 -5
  27. package/src/surfaces/web/memory-ask-browser.bundle.js +107 -107
  28. package/src/surfaces/web/mud-browser-entry.mjs +8 -48
  29. package/test-benchmarks/agentbench/README.md +7 -10
  30. package/src/adapters/p2p/webrtc-transport.mjs +0 -169
  31. package/src/domain/p2p/facts.mjs +0 -102
  32. package/src/domain/p2p/peer-id.mjs +0 -47
  33. package/src/domain/p2p/provenance-relabel.mjs +0 -37
  34. package/src/domain/p2p/sync-filter.mjs +0 -43
  35. package/src/domain/p2p/wire.mjs +0 -126
  36. package/src/services/p2p-room.mjs +0 -848
  37. package/src/services/share-overlay-viz.mjs +0 -623
  38. package/src/surfaces/web/p2p-browser-entry.mjs +0 -39
@@ -85,14 +85,6 @@ import {
85
85
  visibleRoomOf, roomKindForRoom, allRoomIds,
86
86
  } from "./adventure-viz.mjs";
87
87
  import { renderMudEditorText } from "./mud-editor.mjs";
88
- import {
89
- wireStateLabel, nodeRowsFor, nodeInitials, inviteLinkFor, inviteParamsFrom, tapeRowFor, tapeClock,
90
- } from "./chat-page-viz.mjs";
91
- import {
92
- SHARE_OVERLAY_CSS, shareOverlayHtml, shareStepStates, activeWaves, offerBlobIn, peerTerm,
93
- shareMessageFor, replyMessageFor, whatsAppShareUrl, isProbablyMobile, copyTextToClipboard, flashCopyTip,
94
- } from "./share-overlay-viz.mjs";
95
- export { offerBlobIn, peerTerm };
96
88
  import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
97
89
  import { fnv1a32 } from "../domain/hash.mjs";
98
90
  import { predatorSubjects } from "../domain/mud-facts.mjs";
@@ -125,25 +117,8 @@ const DEFAULT_MAX_TURNS = 400;
125
117
  const MUD_NOTE_LINES = [
126
118
  "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.",
127
119
  `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.`,
128
- "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.",
129
120
  ];
130
121
 
131
- // The shared sharing overlay, in this page's own words — same markup, same
132
- // ids, same ladder as chat.html's; only the vocabulary is a burrow's.
133
- const MUD_SHARE_COPY = {
134
- thing: "burrow",
135
- lede: "This burrow lives in your browser and nowhere else. Sharing it opens a direct line (WebRTC) to one other browser: you send an invite, they send a reply, you paste it in. No account, no server, no copy anywhere in between.",
136
- worldLabel: "the burrow you are sharing",
137
- nodeLabel: "your node name",
138
- namesNote: "both names are facts in the burrow, not settings.",
139
- invitedEyebrow: "you have been invited to dig in",
140
- joinBody: "Nothing runs until you press the button. It makes one reply for you to send back the way the invite reached you.",
141
- dismiss: "dig on your own instead",
142
- rosterTitle: "who digs this burrow",
143
- nodeEmpty: "nobody else is digging here yet.",
144
- idleNote: "this browser holds the only copy of this burrow.",
145
- };
146
-
147
122
  /** A character id's species — "mole-1" -> "mole", "groundhog-1" ->
148
123
  * "groundhog" — mirroring spider-fly-viz.mjs's own classOfAgentId. Self-
149
124
  * contained, `.toString()`-splice safe. */
@@ -243,84 +218,6 @@ export function isCreature(state, subject) {
243
218
  return state.placements.get(subject)?.predicate === "mgx:currently-in";
244
219
  }
245
220
 
246
- // ---- the shared burrow: reading the P2P layer's own facts back ---------------
247
- // All four readers below work on plain fact rows and nothing else, so the page
248
- // draws a claim, a wave and a node name identically whether the world is shared
249
- // with three machines or with none. Pure and `.toString()`-splice safe.
250
-
251
- /** Every assertion time a provenance string carries, in milliseconds. A tag is
252
- * `<kind>:<id>@<ISO>`, and a fact asserted more than once holds several joined
253
- * by " | " — a peer's relabelled copy of a tag keeps the time the fact was
254
- * actually asserted, so reading them all back is what lets a claim be settled
255
- * by its oldest and a wave by its newest. Pure, self-contained. */
256
- export function provenanceStamps(provenance) {
257
- const stamps = [];
258
- const text = String(provenance || "");
259
- const pattern = /@(\d{4}-\d{2}-\d{2}T[0-9:.]+Z)/g;
260
- let found = pattern.exec(text);
261
- while (found !== null) {
262
- const at = Date.parse(found[1]);
263
- if (!Number.isNaN(at)) stamps.push(at);
264
- found = pattern.exec(text);
265
- }
266
- return stamps;
267
- }
268
-
269
- /** Which peer plays `character`, or null when nobody has claimed it. Claims are
270
- * add-only and never retracted, so two peers claiming the same animal both
271
- * write and every page settles it the same way: the OLDEST claim wins, with
272
- * the lower peer term breaking a dead heat so two machines reading the same
273
- * rows can never disagree. Pure, self-contained (provenanceStamps is spliced
274
- * alongside it). */
275
- export function claimantOf(rows, character) {
276
- let owner = null;
277
- let ownerAt = Infinity;
278
- for (const row of rows || []) {
279
- if (row.subject !== character || row.predicate !== "mgx:playedBy") continue;
280
- let at = Infinity;
281
- for (const stamp of provenanceStamps(row.provenance)) if (stamp < at) at = stamp;
282
- if (at < ownerAt || (at === ownerAt && owner !== null && String(row.object) < owner)) {
283
- ownerAt = at;
284
- owner = String(row.object);
285
- }
286
- }
287
- return owner;
288
- }
289
-
290
- /** Which characters are waving right now — a read-time recency question over
291
- * the newest tag on each `mgx:waved` fact, never stored state and never a
292
- * retraction. A wave older than the window simply stops being read as one.
293
- * Pure, self-contained. */
294
- export function wavingCharacters(rows, nowMs, windowMs = 8000) {
295
- const waving = [];
296
- for (const row of rows || []) {
297
- if (row.predicate !== "mgx:waved") continue;
298
- let newest = null;
299
- for (const stamp of provenanceStamps(row.provenance)) if (newest === null || stamp > newest) newest = stamp;
300
- if (newest === null) continue;
301
- const age = nowMs - newest;
302
- if (age < 0 || age > windowMs) continue;
303
- if (waving.indexOf(row.subject) === -1) waving.push(row.subject);
304
- }
305
- return waving;
306
- }
307
-
308
- /** The name a peer chose for its node, from its latest `mgx:nodeName` fact,
309
- * falling back to a shortened peer id. A label never waits for a name to
310
- * arrive. Pure, self-contained. */
311
- export function nodeNameFor(rows, peerId) {
312
- const want = peerTerm(peerId);
313
- let name = null;
314
- let nameAt = -Infinity;
315
- for (const row of rows || []) {
316
- if (row.predicate !== "mgx:nodeName" || peerTerm(row.subject) !== want) continue;
317
- let at = -Infinity;
318
- for (const stamp of provenanceStamps(row.provenance)) if (stamp > at) at = stamp;
319
- if (at >= nameAt) { nameAt = at; name = String(row.object); }
320
- }
321
- return name || want.slice(0, 8);
322
- }
323
-
324
221
  /** The self-contained mud.html page. Pure — identical output for identical
325
222
  * input; every other piece of state (the live world, chat, ticks) is
326
223
  * computed in the browser once the sibling bundle loads.
@@ -388,22 +285,14 @@ ${engineBundleJs ? "" : `<link rel="icon" href="./favicon.svg" type="image/svg+x
388
285
  <style>
389
286
  ${THEME_TOKENS_CSS}
390
287
  ${MUD_STYLE}
391
- ${SHARE_OVERLAY_CSS}
392
- ${MUD_SHARE_SKIN}
393
288
  </style>
394
289
  </head>
395
290
  <body>
396
291
  <main>
397
292
  <header class="mud-topbar">
398
293
  <h1 class="eyebrow">${demoEyebrowHtml("mud", "mud")}</h1>
399
- <div class="mud-topbar-actions">
400
- <button type="button" class="state-pill" id="statePill" aria-expanded="false" aria-controls="netPanel"
401
- title="whether this burrow is shared with anyone"><i class="state-dot"></i><span id="statePillWord">not shared</span></button>
402
- <button type="button" id="shareBtn">share</button>
403
- <button type="button" id="joinOpenBtn">join</button>
404
- <a class="mud-topbar-help" href="./help.html#sharing" target="_blank" rel="noopener"
405
- title="how sharing works, in a new tab" aria-label="how sharing works, opens in a new tab">?</a>
406
- </div>
294
+ <a class="mud-topbar-help" href="./help.html" target="_blank" rel="noopener"
295
+ title="how this page works, in a new tab" aria-label="how this page works, opens in a new tab">?</a>
407
296
  </header>
408
297
  <div class="deck-row">
409
298
  <section class="deck" aria-label="simulation controls">
@@ -463,7 +352,6 @@ ${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " select
463
352
  <div class="world-map-key" id="worldMapKey"></div>
464
353
  </section>
465
354
  </div>
466
- ${shareOverlayHtml({ copy: MUD_SHARE_COPY, withTape: true })}
467
355
  <div class="mud-stage" id="mudStage" data-panes="${openingCount}">
468
356
  ${paneHtml}
469
357
  </div>
@@ -515,7 +403,6 @@ export function paneMarkup(slot) {
515
403
  return ` <section class="mud-window pane-${escapeHtml(slot)}" id="${w}" data-slot="${escapeHtml(slot)}" data-character="">
516
404
  <div class="pane-head">
517
405
  <h2 id="${w}-name">waiting</h2>
518
- <span class="pane-node" id="${w}-node" hidden></span>
519
406
  <span class="mono pane-turn" id="${w}-turn">turn 0</span>
520
407
  </div>
521
408
  <div class="room-stage">
@@ -559,11 +446,7 @@ export function paneMarkup(slot) {
559
446
  </div>
560
447
  </div>
561
448
  <div class="chat-console">
562
- <div class="pill-strip">
563
- <button type="button" class="pill wave-btn" id="${w}-wave" title="wave, so everyone in this room sees it"
564
- aria-label="wave">&#128075; wave</button>
565
- <div class="chatpills" id="${w}-chatpills" role="group" aria-label="quick commands"></div>
566
- </div>
449
+ <div class="chatpills" id="${w}-chatpills" role="group" aria-label="quick commands"></div>
567
450
  <form class="chatask" id="${w}-chatform">
568
451
  <span class="prompt mono">tmct&gt;</span>
569
452
  <input id="${w}-chatq" type="text" placeholder="look" aria-label="type a command" disabled>
@@ -596,21 +479,14 @@ const MUD_STYLE = `
596
479
  .eyebrow { font-family: ${MONO_STACK}; font-weight: 500; font-size: .72rem; letter-spacing: .16em; text-transform: uppercase; color: var(--parchment); opacity: .9; margin: 0 0 .8rem; }
597
480
  ${EYEBROW_LINKS_CSS}
598
481
 
599
- /* ---- the header: brand on the left, the sharing chrome on the right ----
600
- The same arrangement chat.html's topbar holds — sharing is page chrome,
601
- not a simulation control, so it lives above the deck rather than in it. */
482
+ /* ---- the header: brand on the left, the way into the help page on the right ---- */
602
483
  .mud-topbar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin: 0 0 .8rem; }
603
484
  .mud-topbar .eyebrow { margin: 0; }
604
- .mud-topbar-actions { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
605
- .mud-topbar-actions button:not(.state-pill), .mud-topbar-help {
485
+ .mud-topbar-help {
606
486
  background: var(--lem-face); color: var(--mud-ink); border: 1px solid var(--soil-mid); border-radius: 3px;
607
- padding: .32rem .7rem; box-shadow: 0 1px 0 rgba(0, 0, 0, .12); text-decoration: none; font-size: .82rem;
487
+ padding: .32rem .55rem; box-shadow: 0 1px 0 rgba(0, 0, 0, .12); text-decoration: none; font-size: .82rem; line-height: 1.2;
608
488
  }
609
- .mud-topbar-actions button:not(.state-pill):hover, .mud-topbar-help:hover { background: var(--lem-face-hi); border-color: var(--burrow-glow); }
610
- .mud-topbar-help { padding: .32rem .55rem; line-height: 1.2; }
611
- /* the pill wears the deck's lit-readout idiom: this is the one word that
612
- says whether the burrow is shared, and it reads like an instrument. */
613
- .mud-topbar-actions .state-pill { background: var(--lem-readout-bg); color: var(--lem-readout); border: 1px solid #000; border-radius: 2px; padding: .3rem .6rem; cursor: pointer; }
489
+ .mud-topbar-help:hover { background: var(--lem-face-hi); border-color: var(--burrow-glow); }
614
490
  h2 { font-family: ${DISPLAY_STACK}; font-size: 1rem; margin: 0; text-transform: capitalize; }
615
491
  h3 { font-family: ${MONO_STACK}; font-size: .58rem; margin: 0 0 .3rem; text-transform: uppercase; letter-spacing: .12em; color: var(--soil-mid); }
616
492
  button { font: inherit; color: inherit; background: none; cursor: pointer; }
@@ -1031,8 +907,7 @@ const MUD_STYLE = `
1031
907
  .deck button:hover:not(:disabled), .pane-controls button:hover:not(:disabled) {
1032
908
  background: var(--lem-face-hi); border-color: var(--burrow-glow);
1033
909
  }
1034
- .deck button:active:not(:disabled), .pane-controls button:active:not(:disabled),
1035
- .mud-topbar-actions button:active:not(:disabled) {
910
+ .deck button:active:not(:disabled), .pane-controls button:active:not(:disabled) {
1036
911
  background: var(--lem-face-lo); box-shadow: inset 0 1px 2px rgba(0,0,0,.25);
1037
912
  }
1038
913
  .deck-play { background: var(--lem-go) !important; color: var(--parchment) !important; border-color: var(--lem-go) !important; }
@@ -1043,60 +918,6 @@ const MUD_STYLE = `
1043
918
  .deck-info-btn { background: var(--lem-face); color: var(--mud-ink); border: 1px solid var(--soil-mid); }
1044
919
  .deck-info-btn:hover, .deck-info-btn[aria-expanded="true"] { background: var(--lem-face-hi); border-color: var(--burrow-glow); color: var(--mud-ink); }
1045
920
  #editModeBtn[aria-pressed="true"] { background: var(--burrow-glow); border-color: var(--burrow-glow); color: var(--mud-ink); }
1046
-
1047
- /* ---- whether the burrow is shared, said in one word on the panel ---- */
1048
- .state-pill { display: inline-flex; align-items: center; gap: .34rem;
1049
- font-family: ${MONO_STACK}; font-size: .68rem; text-transform: uppercase; letter-spacing: .08em; }
1050
- .state-dot { width: .46rem; height: .46rem; border-radius: 50%; background: var(--lem-face-lo); flex: 0 0 auto; }
1051
- .state-pill[data-tone="waiting"] .state-dot { background: #E0912A; }
1052
- .state-pill[data-tone="working"] .state-dot { background: #E0912A; }
1053
- .state-pill[data-tone="live"] .state-dot { background: var(--lem-go); }
1054
- .state-pill[data-tone="failed"] .state-dot { background: var(--lem-alert); }
1055
-
1056
- /* ---- a wave, and the node a character is played from ---- */
1057
- .pill-strip { display: flex; align-items: flex-start; gap: .22rem; min-width: 0; }
1058
- .pill-strip .chatpills { flex: 1 1 auto; min-width: 0; }
1059
- .wave-btn { flex: 0 0 auto; }
1060
- .sprite-card { position: relative; }
1061
- .wave-hand { position: absolute; top: -0.55rem; right: -0.35rem; font-size: 1.1rem; line-height: 1;
1062
- transform-origin: 70% 80%; animation: hand-wave .8s ease-in-out infinite; pointer-events: none; }
1063
- @keyframes hand-wave { 0%, 100% { transform: rotate(-14deg); } 50% { transform: rotate(20deg); } }
1064
- .sprite-node {
1065
- margin-top: 1px; max-width: 100%; box-sizing: border-box;
1066
- font-family: ${MONO_STACK}; font-size: .34rem; line-height: 1.3; letter-spacing: .01em; text-align: center;
1067
- color: var(--soil-mid); background: rgba(239,230,216,.7); border-radius: 2px; padding: 0 .2rem;
1068
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1069
- }
1070
- .pane-node { font-family: ${MONO_STACK}; font-size: .56rem; color: var(--soil-mid); opacity: .85; }
1071
- .mud-window.remote .pane-controls button { display: none; }
1072
- .mud-window.remote .room-view { box-shadow: inset 0 0 0 2px rgba(60,60,180,.35); }
1073
-
1074
- @media (prefers-reduced-motion: reduce) {
1075
- .wave-hand { animation: none; }
1076
- }
1077
- `;
1078
-
1079
- // The shared overlay, re-pointed at the burrow's own palette: soil-deep card,
1080
- // parchment ink, the burrow-glow as the working accent, moss as the live one.
1081
- // Only variables change — every rule stays the component's.
1082
- const MUD_SHARE_SKIN = `
1083
- .shareOverlay {
1084
- --so-scrim: rgba(10, 6, 2, .74);
1085
- --so-card: var(--soil-deep);
1086
- --so-ink: var(--parchment);
1087
- --so-muted: var(--chalk);
1088
- --so-line: var(--soil-light);
1089
- --so-accent: var(--burrow-glow);
1090
- --so-good: #9DB36A;
1091
- --so-warn: var(--burrow-glow);
1092
- --so-alert: #FF9C82;
1093
- --so-good-soft: rgba(157, 179, 106, .18);
1094
- --so-accent-soft: rgba(232, 163, 61, .14);
1095
- --so-alert-soft: rgba(255, 156, 130, .14);
1096
- --so-display: ${DISPLAY_STACK};
1097
- --so-body: ${SANS_STACK};
1098
- --so-mono: ${MONO_STACK};
1099
- }
1100
921
  `;
1101
922
 
1102
923
  /** The inlined page script, as a plain function body handed to
@@ -1142,27 +963,6 @@ function pageScript() {
1142
963
  const allRoomIds = ${allRoomIds.toString()};
1143
964
  const renderMudEditorText = ${renderMudEditorText.toString()};
1144
965
  const wordBeforeCursor = ${wordBeforeCursor.toString()};
1145
- const provenanceStamps = ${provenanceStamps.toString()};
1146
- const peerTerm = ${peerTerm.toString()};
1147
- const claimantOf = ${claimantOf.toString()};
1148
- const wavingCharacters = ${wavingCharacters.toString()};
1149
- const nodeNameFor = ${nodeNameFor.toString()};
1150
- const offerBlobIn = ${offerBlobIn.toString()};
1151
- const wireStateLabel = ${wireStateLabel.toString()};
1152
- const tapeRowFor = ${tapeRowFor.toString()};
1153
- const tapeClock = ${tapeClock.toString()};
1154
- const nodeRowsFor = ${nodeRowsFor.toString()};
1155
- const nodeInitials = ${nodeInitials.toString()};
1156
- const inviteLinkFor = ${inviteLinkFor.toString()};
1157
- const inviteParamsFrom = ${inviteParamsFrom.toString()};
1158
- const shareStepStates = ${shareStepStates.toString()};
1159
- const activeWaves = ${activeWaves.toString()};
1160
- const shareMessageFor = ${shareMessageFor.toString()};
1161
- const replyMessageFor = ${replyMessageFor.toString()};
1162
- const whatsAppShareUrl = ${whatsAppShareUrl.toString()};
1163
- const isProbablyMobile = ${isProbablyMobile.toString()};
1164
- const copyText = ${copyTextToClipboard.toString()};
1165
- const flashTip = ${flashCopyTip.toString()};
1166
966
 
1167
967
  const el = (id) => document.getElementById(id);
1168
968
  // Which of the shipped burrows is loaded. Every read of the world — the
@@ -1214,15 +1014,6 @@ function pageScript() {
1214
1014
  const liveWait = function () { return wait(delayMs); };
1215
1015
 
1216
1016
  let freshlyDugRoom = null;
1217
- // Which animals another node has claimed, character -> that node's name.
1218
- // Refreshed from the store on every redraw, which is also every turn, so a
1219
- // claim that arrives from a peer takes effect on the next tick rather than
1220
- // being cached until something else happens to look.
1221
- let claimedElsewhere = {};
1222
- // Whoever is waving right now, and the timer that lets a wave stop being
1223
- // current. A wave is never retracted — it just stops being recent.
1224
- let wavingNow = [];
1225
- let waveRedrawTimer = null;
1226
1017
  // The last omniscient read, kept so the pounce can draw the predator's own
1227
1018
  // den after the engine has already moved the character out of every room.
1228
1019
  let lastSnapshot = null;
@@ -1258,15 +1049,10 @@ function pageScript() {
1258
1049
  const bootAt = bootSeq;
1259
1050
  return serializeTick(async function () {
1260
1051
  if (bootAt !== bootSeq || finished[character]) return { outOfPlay: true };
1261
- // An animal another node claimed is played there, not here. Its moves
1262
- // still arrive as facts and it still draws, talks and can be waved at —
1263
- // this page just never takes its turn for it.
1264
- if (claimedElsewhere[character]) return { playedElsewhere: true };
1265
1052
  globalTurn += 1;
1266
1053
  const result = await session.windows[character].autoplayTick(globalTurn);
1267
1054
  if (!result.outOfPlay) turnsTaken[character] = (turnsTaken[character] || 0) + 1;
1268
1055
  afterEngineTurn(character, result);
1269
- await broadcastLocalChange();
1270
1056
  return result;
1271
1057
  });
1272
1058
  }
@@ -1400,42 +1186,13 @@ function pageScript() {
1400
1186
 
1401
1187
  function sendCommand(character, line) {
1402
1188
  appendChat(character, "u", line);
1403
- const owner = claimedElsewhere[character];
1404
- if (owner) {
1405
- const refusal = character + " is played from " + owner + ". You can watch it and wave at it, but its turns are theirs.";
1406
- appendChat(character, "a", refusal);
1407
- return Promise.resolve({ answer: refusal, end: false });
1408
- }
1409
- // A wave is a gesture, not a world command — the engine has no verb for it
1410
- // and would answer with an honest miss. Both ways in (this typed line and
1411
- // the pane's own hand button) write the same fact.
1412
- if (/^\\/?wave$/i.test(line.trim())) {
1413
- return waveAs(character).then(function (here) {
1414
- appendChat(character, "a", here
1415
- ? "you wave. Everyone in the " + here + " sees it."
1416
- : character + " is standing nowhere, so there is nobody to wave at.");
1417
- return { answer: "", end: false };
1418
- });
1419
- }
1420
- return serializeTick(function () { return tmct.turn(line, { as: character }); }).then(async function (res) {
1189
+ return serializeTick(function () { return tmct.turn(line, { as: character }); }).then(function (res) {
1421
1190
  appendChat(character, "a", res.answer);
1422
- await broadcastLocalChange();
1423
1191
  renderSoon();
1424
1192
  return res;
1425
1193
  });
1426
1194
  }
1427
1195
 
1428
- // A wave is an ordinary add-only fact in the shared world, so it needs no
1429
- // network to work: one browser renders it exactly the way four do, and the
1430
- // room broadcast below is the only part sharing adds.
1431
- async function waveAs(character) {
1432
- if (!session || finished[character]) return null;
1433
- const here = await serializeTick(function () { return session.wave(character); });
1434
- if (here) await broadcastLocalChange();
1435
- renderSoon();
1436
- return here;
1437
- }
1438
-
1439
1196
  // Wired as the pane is built. The stage is rebuilt on every boot, so a
1440
1197
  // pane's listeners live and die with it; each one still reads its slot's
1441
1198
  // character at click time, because the cast is bound after the markup.
@@ -1460,10 +1217,6 @@ function pageScript() {
1460
1217
  const character = characterInSlot(slot);
1461
1218
  if (character) ensureTicker(character).stepOnce();
1462
1219
  });
1463
- el(w + "-wave").addEventListener("click", function () {
1464
- const character = characterInSlot(slot);
1465
- if (character) sendCommand(character, "wave");
1466
- });
1467
1220
 
1468
1221
  const log = el(w + "-chatlog");
1469
1222
  log.addEventListener("click", function (e) {
@@ -1602,33 +1355,18 @@ function pageScript() {
1602
1355
 
1603
1356
  // A sprite card: adventure.html's own frame-plus-nameplate, carrying the one
1604
1357
  // command this room currently grants on its subject (none, for a thing the
1605
- // room offers nothing on) and the subject's own mass under the name. The
1606
- // last argument holds the two things only a shared burrow has: a hand over a
1607
- // character waving right now, and the node another peer plays it from — one
1608
- // more line under the name, in the same font, never a second label system.
1609
- function spriteCardHtml(subject, label, inner, command, extraClass, mass, extra) {
1610
- const opts = extra || {};
1358
+ // room offers nothing on) and the subject's own mass under the name.
1359
+ function spriteCardHtml(subject, label, inner, command, extraClass, mass) {
1611
1360
  const action = command
1612
1361
  ? ' actionable" data-command="' + esc(command) + '" role="button" tabindex="0" title="' + esc(command) + '"'
1613
1362
  : '" title="' + esc(label) + '"';
1614
- return '<div class="sprite-card ' + (extraClass || "") + (opts.waving ? " waving" : "") + action
1363
+ return '<div class="sprite-card ' + (extraClass || "") + action
1615
1364
  + ' data-subject="' + esc(subject) + '">'
1616
- + (opts.waving ? '<div class="wave-hand" aria-hidden="true">\\u{1F44B}</div>' : "")
1617
1365
  + inner + '<div class="sprite-label">' + esc(label) + "</div>"
1618
- + (opts.node ? '<div class="sprite-node">via ' + esc(opts.node) + "</div>" : "")
1619
1366
  + (mass === null || mass === undefined ? "" : '<div class="sprite-mass">mass ' + esc(mass) + "</div>")
1620
1367
  + "</div>";
1621
1368
  }
1622
1369
 
1623
- // A character another node plays carries that node's name; one this node
1624
- // plays, or one nobody has claimed, carries nothing extra.
1625
- function nodeLabelFor(character) {
1626
- return claimedElsewhere[character] || null;
1627
- }
1628
- function isWavingNow(character) {
1629
- return wavingNow.indexOf(character) !== -1;
1630
- }
1631
-
1632
1370
  function renderRoomView(character, rows, state, here, roomIds, roomMates) {
1633
1371
  const w = paneIdFor(character);
1634
1372
  drawRoomBackdrop(el(w + "-canvas"), roomKindForRoom(rows, here), here);
@@ -1638,14 +1376,12 @@ function pageScript() {
1638
1376
  character, character,
1639
1377
  '<div class="sprite">' + spriteSvgFor(speciesOfCharacter(character), rows, character) + "</div>",
1640
1378
  null, "self", massOf(state, character),
1641
- { waving: isWavingNow(character), node: nodeLabelFor(character) },
1642
1379
  );
1643
1380
 
1644
1381
  const others = roomMates.map(function (mate) {
1645
1382
  return spriteCardHtml(mate, mate,
1646
1383
  '<div class="sprite">' + spriteSvgFor(speciesOfCharacter(mate), rows, mate) + "</div>",
1647
- commands[mate], "", massOf(state, mate),
1648
- { waving: isWavingNow(mate), node: nodeLabelFor(mate) });
1384
+ commands[mate], "", massOf(state, mate));
1649
1385
  });
1650
1386
  const wall = [];
1651
1387
  for (const obj of mudRoomSceneObjects(rows, state, here, character)) {
@@ -1876,23 +1612,6 @@ function pageScript() {
1876
1612
  const snap = await session.snapshot();
1877
1613
  lastSnapshot = snap;
1878
1614
  const roomIds = allRoomIds(snap.rows);
1879
- const nowMs = Date.now();
1880
- wavingNow = wavingCharacters(snap.rows, nowMs);
1881
- // Every claimed animal, not just the ones this page cast: a character
1882
- // another node plays walks into this room as an ordinary room-mate, and
1883
- // its label has to name where it is played from just the same.
1884
- claimedElsewhere = {};
1885
- if (myPeerId) {
1886
- const claimed = {};
1887
- for (const row of snap.rows) if (row.predicate === "mgx:playedBy") claimed[row.subject] = true;
1888
- for (const character of Object.keys(claimed)) {
1889
- const owner = claimantOf(snap.rows, character);
1890
- if (owner === null || peerTerm(owner) === peerTerm(myPeerId)) continue;
1891
- claimedElsewhere[character] = nodeNameFor(snap.rows, owner);
1892
- const ticker = tickers[character];
1893
- if (ticker) ticker.pause();
1894
- }
1895
- }
1896
1615
  for (const character of everyone()) {
1897
1616
  const reason = window.tmct.page.outOfPlayReasonOf(snap.state, character);
1898
1617
  if (reason) markOutOfPlay(character, reason, null);
@@ -1901,10 +1620,6 @@ function pageScript() {
1901
1620
  for (const character of cast) {
1902
1621
  const pane = paneIdFor(character);
1903
1622
  el(pane + "-turn").textContent = "turn " + turnsFor(character);
1904
- const owner = claimedElsewhere[character];
1905
- el(pane).classList.toggle("remote", Boolean(owner));
1906
- el(pane + "-node").hidden = !owner;
1907
- el(pane + "-node").textContent = owner ? "played from " + owner : "";
1908
1623
  if (finished[character]) continue;
1909
1624
  const place = snap.state.placements.get(character);
1910
1625
  const here = place ? place.object : null;
@@ -1917,12 +1632,6 @@ function pageScript() {
1917
1632
  renderMinimap(character, snap.rows, snap.state, here);
1918
1633
  }
1919
1634
  freshlyDugRoom = null;
1920
- // A wave carries no retraction, so nothing tells the page when one stops
1921
- // being current — the recency window does, and only a redraw can notice it
1922
- // has passed. One follow-up per second while any hand is up, and none at
1923
- // all once they are all down.
1924
- clearTimeout(waveRedrawTimer);
1925
- if (wavingNow.length) waveRedrawTimer = setTimeout(renderSoon, 1000);
1926
1635
  }
1927
1636
 
1928
1637
  // ---- edit mode -----------------------------------------------------------
@@ -2067,7 +1776,6 @@ function pageScript() {
2067
1776
  status.className = "edit-status pending";
2068
1777
  status.textContent = "reading the burrow\\u2026";
2069
1778
  const result = await serializeTick(function () { return session.applyEdit(el("editorText").value); });
2070
- await broadcastLocalChange();
2071
1779
  const snap = await session.snapshot();
2072
1780
  allStoreRows = snap.rows;
2073
1781
  editRows = worldOnlyRows(snap.rows);
@@ -2153,729 +1861,6 @@ function pageScript() {
2153
1861
  await renderAll();
2154
1862
  }
2155
1863
 
2156
- // ---- the shared burrow: nodes, the wire, and the two-paste handshake -----
2157
- // Every piece of networking arrives from ./vendor/p2p.js, the site's own
2158
- // shared P2P asset, imported the first time somebody actually asks to share
2159
- // rather than at boot. The room owns signaling, the mesh and the merge; this
2160
- // block owns what a person sees and clicks, and nothing else.
2161
- const P2P_ASSET = "./vendor/p2p.js";
2162
- const NODE_NAME_KEY = "tmct.mud.nodeName";
2163
- // Not the IndexedDB store beside it: that record is stamped with the site
2164
- // version and the seed, and is dropped whenever either moves. A node id has
2165
- // to outlive a deploy, because peers have already keyed this node's facts
2166
- // on it.
2167
- const NODE_ID_KEY = "tmct.mud.nodeId";
2168
- const invite = inviteParamsFrom(window.location.search);
2169
-
2170
- let p2p = null;
2171
- let p2pLoad = null;
2172
- let room = null;
2173
- let myPeerId = null;
2174
- let myNodeId = "";
2175
- let myDisplayName = "";
2176
- let worldId = "";
2177
- let worldName = "";
2178
- let nodeClockTimer = null;
2179
- let mintedBlob = "";
2180
- let nodeWaveTimer = null;
2181
- let channelCount = 0;
2182
-
2183
- // Which seat this page occupies in the handshake — "idle" until something
2184
- // happens, "sponsor" once an invite is minted here, "joiner" once an invite
2185
- // is opened here. The overlay's CSS reads it to decide which calls to
2186
- // action stand at each step; entry distinguishes an invite that arrived as
2187
- // a link (the hero card owns the reply) from one pasted in as text.
2188
- function setOverlayRole(role, entry) {
2189
- const overlay = el("netPanel");
2190
- overlay.dataset.role = role;
2191
- overlay.dataset.entry = entry || "";
2192
- }
2193
-
2194
- function loadP2p() {
2195
- if (!p2pLoad) {
2196
- p2pLoad = import(P2P_ASSET).then(function (mod) { p2p = mod; return mod; });
2197
- p2pLoad.catch(function (err) {
2198
- noteTape("note", "fault", "networking unavailable", err && err.message ? err.message : String(err));
2199
- });
2200
- }
2201
- return p2pLoad;
2202
- }
2203
- window.tmctP2pLoad = loadP2p;
2204
-
2205
- function readStoredNodeName() {
2206
- try { return localStorage.getItem(NODE_NAME_KEY) || ""; } catch { return ""; }
2207
- }
2208
- function writeStoredNodeName(name) {
2209
- try { localStorage.setItem(NODE_NAME_KEY, name); } catch { /* private mode — the name still holds for this visit */ }
2210
- }
2211
-
2212
- async function ensureIdentity() {
2213
- const mod = await loadP2p();
2214
- if (!myPeerId) myPeerId = mod.generatePeerId();
2215
- if (!myNodeId) {
2216
- try { myNodeId = localStorage.getItem(NODE_ID_KEY) || ""; } catch { myNodeId = ""; }
2217
- if (!myNodeId) {
2218
- myNodeId = mod.generateNodeId();
2219
- try { localStorage.setItem(NODE_ID_KEY, myNodeId); } catch { /* private mode — this visit still has an id, it just won't outlive the tab */ }
2220
- }
2221
- }
2222
- if (!myDisplayName) {
2223
- myDisplayName = readStoredNodeName() || mod.generateDisplayName();
2224
- el("nodeNameInput").value = myDisplayName;
2225
- }
2226
- if (!worldId) worldId = invite && invite.world ? invite.world : mod.generateWorldId();
2227
- if (!worldName) {
2228
- worldName = invite && invite.worldName ? invite.worldName : mod.generateDisplayName();
2229
- el("worldNameInput").value = worldName;
2230
- }
2231
- }
2232
-
2233
- // The one place a transport gets made, which makes it the one place every
2234
- // message crossing one can be seen. The room asks for transports through
2235
- // this factory and never learns it is being watched.
2236
- function instrumentedTransport() {
2237
- const transport = p2p.createTransport();
2238
- const channel = "ch" + (++channelCount);
2239
- transport.onMessage(function (message) { noteWire("in", message, channel); });
2240
- transport.onOpen(function () { noteTape("note", "link", "channel open", channel); });
2241
- transport.onClose(function () { noteTape("note", "link", "channel closed", channel); });
2242
- return {
2243
- createOffer: function () {
2244
- return transport.createOffer().then(function (sdp) { noteTape("note", "signal", "offer minted", channel); return sdp; });
2245
- },
2246
- createAnswerFor: function (offerSdp) {
2247
- return transport.createAnswerFor(offerSdp).then(function (sdp) { noteTape("note", "signal", "answer minted", channel); return sdp; });
2248
- },
2249
- completeWithAnswer: function (answerSdp) {
2250
- noteTape("note", "signal", "answer accepted", channel);
2251
- return transport.completeWithAnswer(answerSdp);
2252
- },
2253
- send: function (message) { transport.send(message); noteWire("out", message, channel); },
2254
- onMessage: function (fn) { transport.onMessage(fn); },
2255
- onOpen: function (fn) { transport.onOpen(fn); },
2256
- onClose: function (fn) { transport.onClose(fn); },
2257
- close: function () { transport.close(); },
2258
- get connectionState() { return transport.connectionState; },
2259
- };
2260
- }
2261
-
2262
- async function ensureRoom() {
2263
- if (room) return room;
2264
- const mod = await loadP2p();
2265
- await ensureIdentity();
2266
- if (!session && bootRun) await bootRun;
2267
- if (!session) throw new Error("the burrow hasn't finished opening");
2268
- // The one place this page decides what is worth replicating: whatever the
2269
- // engine calls live world state, plus the P2P layer's own four predicates.
2270
- // The check is handed over rather than imported, so the sync filter never
2271
- // learns the world engine's private predicate names.
2272
- const extraPredicates = (window.tmct.page.P2P_PREDICATES || []).slice();
2273
- const isState = window.tmct.page.isMudStatePredicate;
2274
- room = mod.createP2pRoom({
2275
- memoryDir: session.memoryDir,
2276
- myPeerId: myPeerId,
2277
- myDisplayName: myDisplayName,
2278
- myNodeId: myNodeId,
2279
- worldId: worldId,
2280
- worldName: worldName,
2281
- transportFactory: instrumentedTransport,
2282
- syncableFacts: function (rows) { return mod.mudSyncableFacts(rows, isState, extraPredicates); },
2283
- });
2284
- room.onStateChanged(function (state) {
2285
- noteTape("note", state === "failed" ? "fault" : "state", "state " + state, "");
2286
- // The overlay exists to make one connection. The moment the channel is
2287
- // open its work is done — the lights come back up, and the pill in the
2288
- // header is the way back in.
2289
- if (state === "connected") {
2290
- el("joinCard").hidden = true;
2291
- closeNetPanel();
2292
- flashTip(el("statePill"), "connected \\u2014 you're digging together");
2293
- }
2294
- renderWire();
2295
- });
2296
- room.onPeersChanged(function () { renderNodes(); renderWire(); });
2297
- room.onFactsChanged(function (payload) {
2298
- noteTape("note", "facts", "merged", payload.merged + (payload.merged === 1 ? " fact" : " facts"));
2299
- renderNodes();
2300
- renderSoon();
2301
- });
2302
- await room.start();
2303
- // The burrow's name is written into the graph the moment the room starts,
2304
- // and this version retracts nothing — so the field stops taking edits.
2305
- el("worldNameInput").readOnly = true;
2306
- window.tmctP2pRoom = room;
2307
- noteTape("note", "link", "world " + worldName, myDisplayName);
2308
- await claimMyAnimals();
2309
- renderWire();
2310
- renderNodes();
2311
- if (!nodeClockTimer) nodeClockTimer = setInterval(renderNodes, 10000);
2312
- return room;
2313
- }
2314
-
2315
- // Every animal this page drives is claimed the moment the burrow is shared,
2316
- // panes and npcs alike: two peers each running scripted turns for one mole
2317
- // would be two hands on one lemming. The claim is add-only and the oldest
2318
- // wins, so a page that loses one simply stops driving that animal and
2319
- // watches somebody else play it instead.
2320
- async function claimMyAnimals() {
2321
- if (!session || !room) return;
2322
- await serializeTick(function () { return session.claimCharacters(everyone(), myPeerId); });
2323
- await broadcastLocalChange();
2324
- renderSoon();
2325
- }
2326
-
2327
- // The room diffs the store against what it last saw and broadcasts whatever
2328
- // changed. The page calls it after every local turn, wave, claim and edit —
2329
- // a broadcast that fails is a dead channel, reported by its own close
2330
- // handler, and must never fail the turn that happened to carry it.
2331
- function broadcastLocalChange() {
2332
- if (!room) return Promise.resolve({ broadcast: 0 });
2333
- return room.afterLocalChange().catch(function () { return { broadcast: 0 }; });
2334
- }
2335
-
2336
- function dropRoom(note) {
2337
- if (!room) return;
2338
- room.close();
2339
- room = null;
2340
- window.tmctP2pRoom = null;
2341
- clearInterval(nodeClockTimer);
2342
- nodeClockTimer = null;
2343
- el("shareLink").value = "";
2344
- el("replyOut").value = "";
2345
- el("worldNameInput").readOnly = false;
2346
- mintedBlob = "";
2347
- setOverlayRole("idle", "");
2348
- noteTape("note", "link", "world closed", worldName);
2349
- renderWire();
2350
- renderNodes();
2351
- // Last, not first: renderWire restates the note for the state it has just
2352
- // read, which for a room that is gone is the idle one ("this browser holds
2353
- // the only copy"). WHY the room went is the thing worth saying, so it is
2354
- // written over the top rather than under it.
2355
- if (note) el("wireStateNote").textContent = note;
2356
- }
2357
-
2358
- // ---- the wire tape: every message, as it happens ------------------------
2359
- const TAPE_CAP = 240;
2360
- const TAPE_FAMILY_COLOR = {
2361
- facts: "var(--so-good)",
2362
- state: "var(--so-accent)",
2363
- greeting: "var(--so-warn)",
2364
- signal: "var(--so-warn)",
2365
- fault: "var(--so-alert)",
2366
- link: "var(--so-muted)",
2367
- };
2368
- const tapeCounts = new Map();
2369
- let wireMessageCount = 0;
2370
-
2371
- function pushTape(entry) {
2372
- // The meter counts real wire messages only; the tape below shows those
2373
- // plus the local notes (state changes, a channel opening) that explain
2374
- // them. Folding notes into the counts would make "12 op" mean two things.
2375
- if (entry.dir !== "note") {
2376
- wireMessageCount += 1;
2377
- tapeCounts.set(entry.type, (tapeCounts.get(entry.type) || 0) + 1);
2378
- }
2379
- const tapeEl = el("tape");
2380
- el("tapeEmpty").hidden = true;
2381
- const row = document.createElement("li");
2382
- row.className = "tape-row";
2383
- row.dataset.dir = entry.dir;
2384
- row.dataset.family = entry.family;
2385
- row.dataset.type = entry.type;
2386
- const bar = document.createElement("i");
2387
- bar.className = "tape-bar";
2388
- const clock = document.createElement("span");
2389
- clock.className = "tape-clock";
2390
- clock.textContent = tapeClock();
2391
- const type = document.createElement("span");
2392
- type.className = "tape-type";
2393
- type.textContent = entry.type;
2394
- const detail = document.createElement("span");
2395
- detail.className = "tape-detail";
2396
- detail.textContent = entry.detail || "";
2397
- row.appendChild(bar);
2398
- row.appendChild(clock);
2399
- row.appendChild(type);
2400
- row.appendChild(detail);
2401
- tapeEl.insertBefore(row, tapeEl.firstChild);
2402
- while (tapeEl.childElementCount > TAPE_CAP) tapeEl.removeChild(tapeEl.lastElementChild);
2403
- renderTapeMeter();
2404
- }
2405
-
2406
- function noteWire(direction, message, channel) {
2407
- const row = tapeRowFor(direction, message);
2408
- pushTape({ dir: direction, family: row.family, type: row.type, detail: row.detail || channel });
2409
- }
2410
- function noteTape(dir, family, type, detail) {
2411
- pushTape({ dir: dir, family: family, type: type, detail: detail });
2412
- }
2413
-
2414
- function renderTapeMeter() {
2415
- const tapeMeterEl = el("tapeMeter");
2416
- el("tapeTotal").textContent = wireMessageCount + (wireMessageCount === 1 ? " message" : " messages");
2417
- tapeMeterEl.textContent = "";
2418
- const counted = [...tapeCounts.entries()].sort(function (a, b) { return b[1] - a[1]; });
2419
- for (const pair of counted) {
2420
- const item = document.createElement("span");
2421
- item.className = "meter-item";
2422
- item.dataset.type = pair[0];
2423
- const bar = document.createElement("i");
2424
- bar.className = "meter-bar";
2425
- bar.style.background = TAPE_FAMILY_COLOR[tapeRowFor("in", { type: pair[0] }).family] || "var(--so-muted)";
2426
- const label = document.createElement("span");
2427
- label.textContent = pair[0];
2428
- const count = document.createElement("span");
2429
- count.className = "meter-n";
2430
- count.textContent = String(pair[1]);
2431
- item.appendChild(bar);
2432
- item.appendChild(label);
2433
- item.appendChild(count);
2434
- tapeMeterEl.appendChild(item);
2435
- }
2436
- }
2437
-
2438
- // The state machine is shared with chat.html and the words mostly are too.
2439
- // Two of them are not: nothing is taught here and nothing is a node below,
2440
- // so those two notes are said in this page's own terms rather than forking
2441
- // the whole table for a burrow.
2442
- const MUD_WIRE_NOTES = {
2443
- idle: "this browser holds the only copy of this burrow.",
2444
- connected: "what you dig from here reaches every node in the burrow, and theirs reaches you.",
2445
- };
2446
-
2447
- function renderWire() {
2448
- const state = room ? room.state : "idle";
2449
- const label = wireStateLabel(state);
2450
- const note = MUD_WIRE_NOTES[state] || label.note;
2451
- const wire = el("wireState");
2452
- wire.dataset.tone = label.tone;
2453
- el("wireStateWord").textContent = label.word;
2454
- el("wireStateNote").textContent = note;
2455
- const pill = el("statePill");
2456
- pill.dataset.tone = label.tone;
2457
- el("statePillWord").textContent = label.pill;
2458
- pill.title = note;
2459
- el("netPanel").dataset.tone = label.tone;
2460
- renderSteps();
2461
- }
2462
-
2463
- function renderSteps() {
2464
- const states = shareStepStates({
2465
- role: el("netPanel").dataset.role,
2466
- state: room ? room.state : "idle",
2467
- hasReply: Boolean(el("replyOut").value),
2468
- });
2469
- for (const step of states) {
2470
- const item = el("step-" + step.key);
2471
- if (item) item.dataset.status = step.status;
2472
- }
2473
- }
2474
-
2475
- // The scene's two browser cards: this one wears the node's own name, the
2476
- // far one wears the first peer's the moment there is one to name.
2477
- function renderScene() {
2478
- el("sceneYouName").textContent = myDisplayName || "you";
2479
- const peers = room ? room.peers() : [];
2480
- const first = peers.find(function (p) { return p.connected; }) || peers[0];
2481
- el("sceneThemName").textContent = first ? room.displayNameFor(first.peerId) : "your friend";
2482
- }
2483
-
2484
- function relativeWhen(at, nowMs) {
2485
- if (at === null || at === undefined) return "\\u2014";
2486
- const seconds = Math.max(0, Math.round((nowMs - at) / 1000));
2487
- if (seconds < 5) return "now";
2488
- if (seconds < 60) return seconds + "s";
2489
- if (seconds < 3600) return Math.round(seconds / 60) + "m";
2490
- if (seconds < 86400) return Math.round(seconds / 3600) + "h";
2491
- return Math.round(seconds / 86400) + "d";
2492
- }
2493
-
2494
- // A node is marked waving whether the wave came from one of its claimed
2495
- // animals (a pane's own wave button) or from the node itself (the roster's
2496
- // peer-scoped wave) — one hand, two ways to raise it.
2497
- function wavingPeerTerms(nowMs) {
2498
- const rows = room.factRows();
2499
- const terms = new Set();
2500
- for (const wave of activeWaves(rows, nowMs)) terms.add(wave.waver);
2501
- for (const character of wavingCharacters(rows, nowMs)) {
2502
- const owner = claimantOf(rows, character);
2503
- if (owner !== null) terms.add(peerTerm(owner));
2504
- }
2505
- return terms;
2506
- }
2507
-
2508
- function renderNodes() {
2509
- const listEl = el("nodeList");
2510
- if (!room || !p2p) {
2511
- el("nodeCount").textContent = "";
2512
- listEl.textContent = "";
2513
- el("nodeEmpty").hidden = false;
2514
- renderScene();
2515
- return;
2516
- }
2517
- const rows = nodeRowsFor({
2518
- peers: room.peers(),
2519
- factRows: room.factRows(),
2520
- myPeerId: myPeerId,
2521
- myDisplayName: myDisplayName,
2522
- nameFor: room.displayNameFor,
2523
- latestTimestampOf: p2p.latestProvenanceTimestamp,
2524
- });
2525
- const nowMs = Date.now();
2526
- const waving = wavingPeerTerms(nowMs);
2527
- el("nodeCount").textContent = rows.length + (rows.length === 1 ? " node" : " nodes");
2528
- listEl.textContent = "";
2529
- for (const entry of rows) {
2530
- const item = document.createElement("li");
2531
- item.className = "node-row";
2532
- item.dataset.self = String(entry.isSelf);
2533
- item.dataset.away = String(!entry.connected);
2534
- item.dataset.peer = entry.peerId;
2535
- item.dataset.waving = String(waving.has(peerTerm(entry.peerId)));
2536
- const avatar = document.createElement("span");
2537
- avatar.className = "node-avatar";
2538
- avatar.textContent = nodeInitials(entry.name);
2539
- avatar.title = entry.connected
2540
- ? "connected"
2541
- : "away \\u2014 closed the tab or dropped offline; everything it dug stays";
2542
- const dot = document.createElement("i");
2543
- dot.className = "node-dot";
2544
- avatar.appendChild(dot);
2545
- const hand = document.createElement("span");
2546
- hand.className = "node-hand";
2547
- hand.textContent = "\\u{1F44B}";
2548
- avatar.appendChild(hand);
2549
- const name = document.createElement("span");
2550
- name.className = "node-name";
2551
- name.textContent = entry.name;
2552
- const when = document.createElement("span");
2553
- when.className = "node-when";
2554
- when.textContent = relativeWhen(entry.lastActiveAt, nowMs);
2555
- const fact = document.createElement("span");
2556
- fact.className = "node-fact";
2557
- if (entry.lastFact) {
2558
- const line = entry.lastFact.subject + " " + String(entry.lastFact.predicate || "").replace(/^[a-z0-9_-]+:/i, "") + " " + entry.lastFact.object;
2559
- fact.textContent = line;
2560
- fact.title = "last shared: " + line;
2561
- } else {
2562
- fact.textContent = "nothing shared yet";
2563
- }
2564
- const waveOne = document.createElement("button");
2565
- waveOne.type = "button";
2566
- waveOne.className = "node-wave-btn";
2567
- waveOne.textContent = "\\u{1F44B}";
2568
- waveOne.title = entry.isSelf ? "wave at everyone" : "wave at " + entry.name + " \\u2014 they see it as a fact arriving";
2569
- waveOne.setAttribute("aria-label", waveOne.title);
2570
- waveOne.addEventListener("click", function () { waveAtNode(entry.isSelf ? null : entry.peerId, waveOne); });
2571
- item.appendChild(avatar);
2572
- item.appendChild(name);
2573
- item.appendChild(when);
2574
- item.appendChild(waveOne);
2575
- item.appendChild(fact);
2576
- listEl.appendChild(item);
2577
- }
2578
- el("nodeEmpty").hidden = rows.length > 1;
2579
- renderScene();
2580
- clearTimeout(nodeWaveTimer);
2581
- if (waving.size) nodeWaveTimer = setTimeout(renderNodes, 1000);
2582
- }
2583
-
2584
- // A node's own wave, distinct from an animal's: the fact's subject is the
2585
- // peer, and its object names the audience — everyone, or one node.
2586
- async function waveAtNode(targetPeerId, anchor) {
2587
- try {
2588
- const active = await ensureRoom();
2589
- await active.wave("peer:" + myPeerId, targetPeerId ? "peer:" + targetPeerId : null);
2590
- const audience = targetPeerId ? (active.displayNameFor(targetPeerId) || "one node") : "everyone";
2591
- renderNodes();
2592
- if (anchor) flashTip(anchor, "you waved at " + audience);
2593
- } catch (err) {
2594
- el("wireStateNote").textContent = "couldn't wave (" + (err && err.message ? err.message : err) + ").";
2595
- }
2596
- }
2597
-
2598
- const openNetPanel = function () {
2599
- const overlay = el("netPanel");
2600
- overlay.hidden = false;
2601
- el("statePill").setAttribute("aria-expanded", "true");
2602
- const card = overlay.querySelector(".so-card");
2603
- if (card) card.focus({ preventScroll: true });
2604
- };
2605
- const closeNetPanel = function () {
2606
- el("netPanel").hidden = true;
2607
- el("statePill").setAttribute("aria-expanded", "false");
2608
- };
2609
-
2610
- function renderInviteShare() {
2611
- const facts = room ? room.factRows().length : 0;
2612
- const factsPart = facts ? " \\u00b7 " + facts + (facts === 1 ? " fact travels" : " facts travel") + " when they join" : "";
2613
- el("inviteSummary").textContent = "invites one person into \\u201c" + worldName + "\\u201d" + factsPart;
2614
- const message = shareMessageFor({ worldName: worldName, link: el("shareLink").value, thing: "burrow" });
2615
- el("waShareBtn").href = whatsAppShareUrl(message);
2616
- el("webShareBtn").hidden = !navigator.share;
2617
- }
2618
-
2619
- function renderReplyShare() {
2620
- const message = replyMessageFor({ worldName: worldName, blob: el("replyOut").value });
2621
- el("replyWaBtn").href = whatsAppShareUrl(message);
2622
- el("joinWaBtn").href = whatsAppShareUrl(message);
2623
- el("replyShareBtn").hidden = !navigator.share;
2624
- el("joinShareBtn").hidden = !navigator.share;
2625
- }
2626
-
2627
- async function mintInvite(anchor) {
2628
- try {
2629
- const active = await ensureRoom();
2630
- const minted = await active.startSharing();
2631
- mintedBlob = minted.blob;
2632
- el("shareLink").value = inviteLinkFor(window.location.href, {
2633
- blob: minted.blob, world: worldId, worldName: worldName,
2634
- });
2635
- setOverlayRole("sponsor", "");
2636
- renderInviteShare();
2637
- el("replyProblem").hidden = true;
2638
- openNetPanel();
2639
- renderWire();
2640
- await copyText(el("shareLink").value);
2641
- flashTip(anchor, "link copied \\u2014 send it to one person");
2642
- } catch (err) {
2643
- el("replyProblem").textContent = "couldn't create an invite (" + (err && err.message ? err.message : err) + ").";
2644
- el("replyProblem").hidden = false;
2645
- openNetPanel();
2646
- }
2647
- }
2648
-
2649
- function wireNetPanel() {
2650
- el("netPanelClose").addEventListener("click", closeNetPanel);
2651
- el("netPanel").addEventListener("click", function (e) {
2652
- if (e.target === el("netPanel")) closeNetPanel();
2653
- });
2654
- document.addEventListener("keydown", function (e) {
2655
- if (e.key === "Escape" && !el("netPanel").hidden) closeNetPanel();
2656
- });
2657
- el("statePill").addEventListener("click", function () {
2658
- if (el("netPanel").hidden) openNetPanel(); else closeNetPanel();
2659
- });
2660
- if (isProbablyMobile()) el("netPanel").classList.add("so-mobile");
2661
- // The blobs are made to be copied whole — a click selects everything, so
2662
- // a manual copy can never take half a code.
2663
- for (const boxId of ["shareLink", "replyOut", "joinReply"]) {
2664
- el(boxId).addEventListener("focus", function (e) { e.target.select(); });
2665
- }
2666
-
2667
- el("shareBtn").addEventListener("click", async function () {
2668
- const shareBtn = el("shareBtn");
2669
- shareBtn.disabled = true;
2670
- try {
2671
- await mintInvite(shareBtn);
2672
- } finally {
2673
- shareBtn.disabled = false;
2674
- }
2675
- });
2676
- for (const mintId of ["mintInviteBtn", "remintBtn"]) {
2677
- el(mintId).addEventListener("click", async function () {
2678
- const btn = el(mintId);
2679
- btn.disabled = true;
2680
- try {
2681
- await mintInvite(btn);
2682
- } finally {
2683
- btn.disabled = false;
2684
- }
2685
- });
2686
- }
2687
-
2688
- el("copyLinkBtn").addEventListener("click", async function () {
2689
- await copyText(el("shareLink").value);
2690
- flashTip(el("copyLinkBtn"), "link copied \\u2014 send it to one person");
2691
- });
2692
- el("copyCodeBtn").addEventListener("click", async function () {
2693
- await copyText(mintedBlob);
2694
- flashTip(el("copyCodeBtn"), "code copied \\u2014 they paste it under step 3 on their page");
2695
- });
2696
- el("webShareBtn").addEventListener("click", function () {
2697
- navigator.share({
2698
- title: "join \\u201c" + worldName + "\\u201d",
2699
- text: shareMessageFor({ worldName: worldName, link: el("shareLink").value, thing: "burrow" }),
2700
- }).catch(function () { /* the sheet was closed — the copy buttons still stand */ });
2701
- });
2702
- const shareReplyViaSheet = function () {
2703
- navigator.share({
2704
- title: "my reply to \\u201c" + worldName + "\\u201d",
2705
- text: replyMessageFor({ worldName: worldName, blob: el("replyOut").value }),
2706
- }).catch(function () { /* the sheet was closed — the copy buttons still stand */ });
2707
- };
2708
- el("replyShareBtn").addEventListener("click", shareReplyViaSheet);
2709
- el("joinShareBtn").addEventListener("click", shareReplyViaSheet);
2710
- el("waveAllBtn").addEventListener("click", function () { waveAtNode(null, el("waveAllBtn")); });
2711
-
2712
- // The inviter's page has exactly one paste target, so there is no wrong box
2713
- // to choose. A rejected paste keeps its text so the copy can be fixed.
2714
- el("replyBtn").addEventListener("click", async function () {
2715
- let active = room;
2716
- if (!active) {
2717
- try { active = await ensureRoom(); } catch { return; }
2718
- }
2719
- const outcome = await active.completeInvite(el("replyBox").value);
2720
- if (outcome && outcome.error) {
2721
- el("replyProblem").textContent = outcome.message;
2722
- el("replyProblem").hidden = false;
2723
- return;
2724
- }
2725
- el("replyProblem").hidden = true;
2726
- el("replyBox").value = "";
2727
- renderWire();
2728
- });
2729
-
2730
- el("copyReplyBtn").addEventListener("click", async function () {
2731
- await copyText(el("replyOut").value);
2732
- flashTip(el("copyReplyBtn"), "reply copied \\u2014 send it back the same way");
2733
- });
2734
-
2735
- // The link flow lands somebody on the join card before they have dug
2736
- // anything. This is the other case: a burrow already open, an invite that
2737
- // arrived in a message, and no reason to throw the burrow away to take it.
2738
- el("joinOpenBtn").addEventListener("click", function () {
2739
- openNetPanel();
2740
- const box = el("inviteBox");
2741
- if (box.offsetParent !== null) box.focus();
2742
- });
2743
-
2744
- el("inviteBtn").addEventListener("click", async function () {
2745
- const problem = el("inviteProblem");
2746
- const pasted = el("inviteBox").value.trim();
2747
- if (!pasted) {
2748
- problem.textContent = "paste the invite you were sent, then try again";
2749
- problem.hidden = false;
2750
- return;
2751
- }
2752
- if (room) {
2753
- problem.textContent = "this burrow is already shared. Reset it first, then take the invite.";
2754
- problem.hidden = false;
2755
- return;
2756
- }
2757
- // A whole link and a bare blob are both what people actually paste, so
2758
- // both are read. The blob's own envelope decides either way.
2759
- const blob = offerBlobIn(pasted);
2760
- const mod = await loadP2p();
2761
- const decoded = mod.decodeInviteBlob(blob);
2762
- if (decoded.error || decoded.value.kind !== "offer") {
2763
- problem.textContent = decoded.error
2764
- ? "this invite looks cut short \\u2014 ask for it to be sent again"
2765
- : "that's a reply, not an invite \\u2014 it belongs on the page that sent the invite";
2766
- problem.hidden = false;
2767
- return;
2768
- }
2769
- // The world id has to be the invite's before the room is opened: a room
2770
- // that minted its own would refuse the invite as belonging elsewhere.
2771
- await ensureIdentity();
2772
- worldId = decoded.value.world || worldId;
2773
- if (decoded.value.worldName) {
2774
- worldName = decoded.value.worldName;
2775
- el("worldNameInput").value = worldName;
2776
- }
2777
- try {
2778
- const active = await ensureRoom();
2779
- const outcome = await active.acceptInvite(blob);
2780
- if (outcome && outcome.error) {
2781
- problem.textContent = outcome.message;
2782
- problem.hidden = false;
2783
- return;
2784
- }
2785
- problem.hidden = true;
2786
- el("inviteBox").value = "";
2787
- el("replyOut").value = outcome.blob;
2788
- setOverlayRole("joiner", "paste");
2789
- renderReplyShare();
2790
- renderWire();
2791
- await copyText(outcome.blob);
2792
- flashTip(el("copyReplyBtn"), "reply copied \\u2014 send it back the same way");
2793
- } catch (err) {
2794
- problem.textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
2795
- problem.hidden = false;
2796
- }
2797
- });
2798
-
2799
- el("nodeNameInput").addEventListener("change", function () {
2800
- const name = el("nodeNameInput").value.trim();
2801
- if (!name || name === myDisplayName) { el("nodeNameInput").value = myDisplayName; return; }
2802
- myDisplayName = name;
2803
- writeStoredNodeName(name);
2804
- if (!room) { renderNodes(); return; }
2805
- room.setMyDisplayName(name).then(renderNodes).catch(function () { /* the next broadcast carries it */ });
2806
- });
2807
- el("worldNameInput").addEventListener("change", function () {
2808
- const input = el("worldNameInput");
2809
- if (input.readOnly) { input.value = worldName; return; }
2810
- const name = input.value.trim();
2811
- if (name) worldName = name;
2812
- input.value = worldName;
2813
- });
2814
-
2815
- el("joinBtn").addEventListener("click", async function () {
2816
- const joinBtn = el("joinBtn");
2817
- joinBtn.disabled = true;
2818
- try {
2819
- const active = await ensureRoom();
2820
- const outcome = await active.acceptInvite(invite.offer);
2821
- if (outcome && outcome.error) {
2822
- el("joinProblem").textContent = outcome.message;
2823
- el("joinProblem").hidden = false;
2824
- joinBtn.disabled = false;
2825
- joinBtn.textContent = "try again";
2826
- return;
2827
- }
2828
- el("joinProblem").hidden = true;
2829
- el("joinReply").value = outcome.blob;
2830
- el("replyOut").value = outcome.blob;
2831
- el("joinReplyWrap").hidden = false;
2832
- joinBtn.textContent = "reply created";
2833
- el("joinDismiss").textContent = "close this and start digging";
2834
- renderReplyShare();
2835
- renderWire();
2836
- await copyText(outcome.blob);
2837
- flashTip(el("joinCopyBtn"), "reply copied \\u2014 send it back the same way");
2838
- } catch (err) {
2839
- el("joinProblem").textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
2840
- el("joinProblem").hidden = false;
2841
- joinBtn.disabled = false;
2842
- }
2843
- });
2844
- el("joinCopyBtn").addEventListener("click", async function () {
2845
- await copyText(el("joinReply").value);
2846
- flashTip(el("joinCopyBtn"), "reply copied \\u2014 send it back the same way");
2847
- });
2848
- el("joinDismiss").addEventListener("click", closeNetPanel);
2849
- }
2850
-
2851
- // A link that lost part of itself on the way reads as a mangled link, never
2852
- // as no invite at all.
2853
- async function prepareJoinCard() {
2854
- setOverlayRole("joiner", "link");
2855
- el("joinCard").hidden = false;
2856
- el("joinWorld").textContent = invite.worldName || "a shared burrow";
2857
- renderSteps();
2858
- openNetPanel();
2859
- el("joinBtn").focus();
2860
- const mod = await loadP2p();
2861
- const decoded = mod.decodeInviteBlob(invite.offer);
2862
- if (decoded.error || decoded.value.kind !== "offer") {
2863
- el("joinBtn").hidden = true;
2864
- el("joinEyebrow").textContent = "this link didn't arrive in one piece";
2865
- el("joinWorld").textContent = invite.worldName || "an invitation";
2866
- el("joinBody").textContent = decoded.error
2867
- ? "Part of the link was lost on the way here. Ask for it to be sent again, and check the whole thing travels."
2868
- : "That link carries a reply rather than an invite. It belongs in the box on the page that sent the invite.";
2869
- el("joinDismiss").textContent = "dig on your own instead";
2870
- return;
2871
- }
2872
- if (decoded.value.world) invite.world = decoded.value.world;
2873
- if (decoded.value.worldName) {
2874
- invite.worldName = decoded.value.worldName;
2875
- el("joinWorld").textContent = decoded.value.worldName;
2876
- }
2877
- }
2878
-
2879
1864
  // ---- the control deck ----------------------------------------------------
2880
1865
  // The players slider runs on detents (1, 2, 4), so its own value is the
2881
1866
  // index of the count, never the count.
@@ -2962,7 +1947,7 @@ function pageScript() {
2962
1947
  const wasEditing = editing;
2963
1948
  if (wasEditing) await exitEditMode();
2964
1949
  scenarioIndex = picked;
2965
- await boot("a different burrow opened \\u2014 still linked.");
1950
+ await boot();
2966
1951
  if (wasEditing) await enterEditMode();
2967
1952
  });
2968
1953
  }
@@ -3020,31 +2005,13 @@ function pageScript() {
3020
2005
  // session on the floor rather than binding it to the stage the newer boot
3021
2006
  // has already drawn.
3022
2007
  let bootSeq = 0;
3023
- let bootRun = null;
3024
- function boot(note) {
3025
- bootRun = openWorld(note);
3026
- return bootRun;
2008
+ function boot() {
2009
+ return openWorld();
3027
2010
  }
3028
2011
 
3029
- async function openWorld(note) {
2012
+ async function openWorld() {
3030
2013
  const seq = bootSeq += 1;
3031
2014
  autoOn = false;
3032
- // A live link survives a recast: once the new burrow's store is open the
3033
- // room re-binds to it (below), pushing the fresh world to every connected
3034
- // node instead of quietly abandoning them. The recast moves the world one
3035
- // epoch forward, read from the store the peers have all converged on, so
3036
- // nobody's leftover snapshots from the old run can outrank the new one.
3037
- const liveRoom = room;
3038
- let nextEpoch = 0;
3039
- if (liveRoom && session) {
3040
- try {
3041
- nextEpoch = (await session.snapshot()).state.epoch + 1;
3042
- } catch (err) {
3043
- nextEpoch = 1;
3044
- }
3045
- }
3046
- claimedElsewhere = {};
3047
- wavingNow = [];
3048
2015
  const playBtn = el("autoToggle");
3049
2016
  playBtn.setAttribute("aria-pressed", "false");
3050
2017
  playBtn.textContent = "\\u25B6 play";
@@ -3083,30 +2050,16 @@ function pageScript() {
3083
2050
  renderStage();
3084
2051
  // slotOf has to match cast from the same synchronous stretch that just
3085
2052
  // rebuilt the DOM for it \\u2014 a render triggered anywhere in the awaits
3086
- // below (rebind's own re-sync can trigger one) must never see this
3087
- // scenario's cast paired with the LAST scenario's pane ids, which is what
3088
- // a null pane-element lookup in renderAll means when it happens.
2053
+ // below must never see this scenario's cast paired with the LAST
2054
+ // scenario's pane ids, which is what a null pane-element lookup in
2055
+ // renderAll means when it happens.
3089
2056
  for (let i = 0; i < cast.length; i += 1) slotOf[cast[i]] = slots[i];
3090
2057
  const opened = await window.tmct.open(scenario().worldPayload, {
3091
- characters: everyone(), epoch: nextEpoch,
2058
+ characters: everyone(),
3092
2059
  getTeachEnabled: function () { return el("teachToggle").checked; },
3093
2060
  });
3094
2061
  if (seq !== bootSeq) return;
3095
2062
  session = opened;
3096
- if (liveRoom) {
3097
- try {
3098
- await liveRoom.rebind({ memoryDir: opened.memoryDir, worldName: worldName, myDisplayName: myDisplayName });
3099
- renderWire();
3100
- renderNodes();
3101
- // After renderWire, the same way dropRoom writes its own reason: the
3102
- // render restates the state's stock note, and WHY this recast kept
3103
- // the link is the thing worth saying over it.
3104
- el("wireStateNote").textContent = note || "the burrow recast \\u2014 still linked.";
3105
- await claimMyAnimals();
3106
- } catch (err) {
3107
- dropRoom("the link didn't survive the recast \\u2014 share again to link up.");
3108
- }
3109
- }
3110
2063
  bindPanes();
3111
2064
  for (const character of cast) {
3112
2065
  const w = paneIdFor(character);
@@ -3122,20 +2075,6 @@ function pageScript() {
3122
2075
  }
3123
2076
 
3124
2077
  wireDeck();
3125
- wireNetPanel();
3126
- renderWire();
3127
- renderNodes();
3128
2078
  boot();
3129
- // Off the boot path on purpose: this fetches the shared P2P asset so the
3130
- // panel can show a real node name and burrow name before anyone clicks
3131
- // anything. A failure here costs sharing, never the digging.
3132
- ensureIdentity().then(renderNodes).catch(function () { /* sharing stays unavailable; the burrow does not */ });
3133
- if (invite) {
3134
- prepareJoinCard().catch(function (err) {
3135
- el("joinProblem").textContent = "the networking asset didn't load (" + (err && err.message ? err.message : err) + ").";
3136
- el("joinProblem").hidden = false;
3137
- el("joinBtn").hidden = true;
3138
- });
3139
- }
3140
2079
  })();`;
3141
2080
  }