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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +176 -8
  2. package/corpus/reference/index.json.gz +0 -0
  3. package/corpus/reference/manifest.json +8 -8
  4. package/corpus/reference/shards/ref-00.jsonl.gz +0 -0
  5. package/corpus/sprites/src/sprite-facts.jsonl +52 -0
  6. package/corpus/worlds/index.json.gz +0 -0
  7. package/corpus/worlds/manifest.json +53 -13
  8. package/corpus/worlds/shards/greyvale-museum.jsonl.gz +0 -0
  9. package/corpus/worlds/shards/lantern-cottage.jsonl.gz +0 -0
  10. package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
  11. package/corpus/worlds/shards/mud-hollow.jsonl.gz +0 -0
  12. package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
  13. package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
  14. package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
  15. package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
  16. package/corpus/worlds/src/mud-garden.jsonl +34 -1
  17. package/corpus/worlds/src/mud-hollow.jsonl +82 -0
  18. package/corpus/worlds/src/mud-warren.jsonl +124 -0
  19. package/corpus/worlds/src/spider-fly.jsonl +1 -1
  20. package/data/sprites/animal-icon.toml +11 -5
  21. package/data/sprites/book-icon.toml +9 -5
  22. package/data/sprites/cabinet-icon.toml +10 -5
  23. package/data/sprites/cellar-icon.toml +16 -6
  24. package/data/sprites/container-icon.toml +7 -3
  25. package/data/sprites/desk-icon.toml +8 -5
  26. package/data/sprites/dog-icon.toml +8 -5
  27. package/data/sprites/dog-with-colour-icon.toml +13 -12
  28. package/data/sprites/drawing-room-icon.toml +14 -5
  29. package/data/sprites/egg-icon.toml +6 -3
  30. package/data/sprites/fly-icon.toml +10 -5
  31. package/data/sprites/furniture-icon.toml +5 -3
  32. package/data/sprites/garden-icon.toml +10 -3
  33. package/data/sprites/key-icon.toml +3 -1
  34. package/data/sprites/kitchen-icon.toml +15 -6
  35. package/data/sprites/lamp-icon.toml +9 -4
  36. package/data/sprites/letter-icon.toml +7 -4
  37. package/data/sprites/library-icon.toml +14 -3
  38. package/data/sprites/pan-icon.toml +7 -3
  39. package/data/sprites/person-icon.toml +6 -2
  40. package/data/sprites/poodle-icon.toml +1 -1
  41. package/data/sprites/portable-icon.toml +6 -4
  42. package/data/sprites/portrait-icon.toml +7 -4
  43. package/data/sprites/room-icon.toml +11 -2
  44. package/data/sprites/spider-icon.toml +9 -3
  45. package/data/sprites/study-icon.toml +10 -2
  46. package/package.json +5 -4
  47. package/src/adapters/p2p/webrtc-transport.mjs +146 -0
  48. package/src/domain/game-config.mjs +26 -5
  49. package/src/domain/grammar/ace.mjs +40 -4
  50. package/src/domain/grammar/lexicon-core.json +2 -1
  51. package/src/domain/grammar/lexicon.mjs +31 -0
  52. package/src/domain/memory/trust.mjs +6 -4
  53. package/src/domain/p2p/facts.mjs +81 -0
  54. package/src/domain/p2p/peer-id.mjs +32 -0
  55. package/src/domain/p2p/provenance-relabel.mjs +26 -0
  56. package/src/domain/p2p/sync-filter.mjs +31 -0
  57. package/src/domain/p2p/wire.mjs +123 -0
  58. package/src/domain/reference-pack.mjs +31 -7
  59. package/src/domain/spider-fly-world.mjs +1 -1
  60. package/src/domain/sprite-map.mjs +10 -2
  61. package/src/domain/sprite-templates.mjs +8 -6
  62. package/src/services/adventure-editor.mjs +10 -2
  63. package/src/services/adventure-viz.mjs +309 -73
  64. package/src/services/adventure.mjs +1034 -330
  65. package/src/services/chat-page-viz.mjs +1097 -16
  66. package/src/services/chat.mjs +99 -18
  67. package/src/services/code-explorer-viz.mjs +52 -14
  68. package/src/services/extract-facts.mjs +4 -7
  69. package/src/services/ingest-viz.mjs +54 -26
  70. package/src/services/ledger-viz.mjs +105 -56
  71. package/src/services/memory-panel-viz.mjs +24 -0
  72. package/src/services/mud-editor.mjs +313 -0
  73. package/src/services/mud-turn.mjs +264 -87
  74. package/src/services/mud-viz.mjs +2606 -384
  75. package/src/services/p2p-room.mjs +559 -0
  76. package/src/services/plan-viz.mjs +119 -61
  77. package/src/services/research-viz.mjs +151 -57
  78. package/src/services/spider-fly-turn.mjs +1 -1
  79. package/src/services/spider-fly-viz.mjs +67 -43
  80. package/src/services/sprite-catalog-viz.mjs +175 -51
  81. package/src/services/viz-theme.mjs +15 -0
  82. package/src/surfaces/web/memory-ask-browser.bundle.js +109 -109
  83. package/src/surfaces/web/mud-browser-entry.mjs +226 -18
  84. package/src/surfaces/web/p2p-browser-entry.mjs +39 -0
@@ -0,0 +1,559 @@
1
+ // services/p2p-room.mjs — room orchestration for the P2P layer: the two-paste
2
+ // signaling handoff, the mesh of direct connections it grows into, and the
3
+ // diff/broadcast/merge loop that keeps every peer's fact store converged.
4
+ // Everything WebRTC arrives through an injected `transportFactory`, so this
5
+ // module and every test of it run in plain Node with no browser and no network.
6
+ // src/adapters/p2p/webrtc-transport.mjs supplies the real factory; the pure
7
+ // message shapes live in src/domain/p2p/.
8
+ import {
9
+ encodeInviteBlob,
10
+ decodeInviteBlob,
11
+ helloMessage,
12
+ peerListMessage,
13
+ introOfferMessage,
14
+ introAnswerMessage,
15
+ syncRequestMessage,
16
+ syncResponseMessage,
17
+ opMessage,
18
+ isValidRoomMessage,
19
+ } from "../domain/p2p/wire.mjs";
20
+ import { relabelForBroadcast } from "../domain/p2p/provenance-relabel.mjs";
21
+ import {
22
+ worldNameFact,
23
+ nodeNameFact,
24
+ waveFact,
25
+ latestFact,
26
+ latestProvenanceTimestamp,
27
+ isRecentWave,
28
+ NODE_NAME_PREDICATE,
29
+ WAVED_PREDICATE,
30
+ } from "../domain/p2p/facts.mjs";
31
+ import { appendFacts, loadMemory, readFactRows, normFactTerm, FACT_CLASS } from "../adapters/memory/core.mjs";
32
+
33
+ export const ROOM_IDLE = "idle";
34
+ export const ROOM_SHARING = "sharing";
35
+ export const ROOM_ANSWERING = "answering";
36
+ export const ROOM_CONNECTING = "connecting";
37
+ export const ROOM_CONNECTED = "connected";
38
+ export const ROOM_FAILED = "failed";
39
+
40
+ /** The object term a wave carries when it isn't scoped to a mud room —
41
+ * chat.html has presence but no geography, and a wave still needs a real
42
+ * object term because a triple with an empty slot is skipped on write. */
43
+ export const PRESENCE_SCOPE = "presence";
44
+
45
+ const INVITE_PROBLEM_MESSAGES = {
46
+ invite: {
47
+ empty: "paste the invite you were sent, then try again",
48
+ truncated: "this invite looks cut short — ask for it to be sent again",
49
+ malformed: "this invite looks cut short — ask for it to be sent again",
50
+ "unsupported-version": "this invite came from a newer version of the page than this one",
51
+ "wrong-kind": "that's a reply, not an invite — paste it into the box on the page that sent the invite",
52
+ "wrong-world": "that invite is for a different world than this page is sharing",
53
+ },
54
+ reply: {
55
+ empty: "paste their reply here, then try again",
56
+ truncated: "that doesn't look like a complete reply — check the whole thing was copied, then paste it again",
57
+ malformed: "that doesn't look like a complete reply — check the whole thing was copied, then paste it again",
58
+ "unsupported-version": "that reply came from a newer version of the page than this one",
59
+ "wrong-kind": "that's an invite, not a reply — send it to the person you're inviting, or open it in a new tab to join their world",
60
+ "no-pending-invite": "that reply matched an invite that's already been used — create a fresh link and send that instead",
61
+ "connect-failed": "your two machines can't reach each other directly; this works on the same network, or between machines that can already see each other",
62
+ },
63
+ };
64
+
65
+ const problem = (context, code) => ({
66
+ error: code,
67
+ message: INVITE_PROBLEM_MESSAGES[context]?.[code] || INVITE_PROBLEM_MESSAGES[context]?.malformed || code,
68
+ });
69
+
70
+ // A tag this shape has already been rewritten for the wire by whichever peer
71
+ // authored it. Relabeling it again would overwrite their node name with ours
72
+ // and lose the attribution the receiving page renders as "taught by X".
73
+ const ALREADY_PEER_LABELED = /^teach:peer:/;
74
+
75
+ /** One outgoing tag per stored tag, each relabeled under the timestamp it
76
+ * already carries. Keeping a relabeled tag on the fact's own assertion time
77
+ * rather than the moment it went over the wire is what stops a months-old
78
+ * wave replayed inside a sync response reading as freshly waved, and it makes
79
+ * the rewrite idempotent: a peer that relays a tag it received produces the
80
+ * same tag again, so the union stops growing once every peer has seen it. */
81
+ function wireProvenanceTags(provenance, myDisplayName, fallbackTimestamp) {
82
+ const stored = String(provenance || "").split(" | ").filter(Boolean);
83
+ if (!stored.length) return [""];
84
+ const tags = new Set();
85
+ for (const segment of stored) {
86
+ if (ALREADY_PEER_LABELED.test(segment)) { tags.add(segment); continue; }
87
+ const assertedAt = latestProvenanceTimestamp(segment);
88
+ const timestamp = assertedAt === null ? fallbackTimestamp : new Date(assertedAt).toISOString();
89
+ tags.add(relabelForBroadcast(segment, myDisplayName, timestamp));
90
+ }
91
+ return [...tags];
92
+ }
93
+
94
+ const isFactShaped = (f) => !!f
95
+ && typeof f.subject === "string" && f.subject.length > 0
96
+ && typeof f.predicate === "string" && f.predicate.length > 0
97
+ && typeof f.object === "string" && f.object.length > 0;
98
+
99
+ /** One wire fact per provenance tag rather than one carrying the whole
100
+ * " | "-joined union. appendFacts dedupes an incoming tag against the tags it
101
+ * already stores, and a joined union arrives as one opaque string that matches
102
+ * none of them — so a fact whose provenance had grown would union again on
103
+ * every hop and never settle. Sending each tag on its own row keeps the merge
104
+ * idempotent, and a repeated triple inside one batch unions correctly. */
105
+ const toWireFacts = (row, myDisplayName, fallbackTimestamp) =>
106
+ wireProvenanceTags(row.provenance, myDisplayName, fallbackTimestamp).map((provenance) => ({
107
+ subject: row.subject,
108
+ predicate: row.predicate,
109
+ object: row.object,
110
+ provenance,
111
+ }));
112
+
113
+ /** A room: one shared world, one local fact store, and however many direct
114
+ * peer connections have been made into it.
115
+ *
116
+ * `syncableFacts(rows) -> rows` is injected because a chat room and a mud
117
+ * room disagree about which facts are worth replicating, and this module has
118
+ * no business knowing which one it is serving. It gates both directions: what
119
+ * a sync response carries out, and what an incoming batch is allowed to merge.
120
+ */
121
+ export function createP2pRoom({
122
+ memoryDir,
123
+ myPeerId,
124
+ myDisplayName,
125
+ worldId,
126
+ worldName,
127
+ transportFactory,
128
+ syncableFacts,
129
+ now: rawNow = () => new Date().toISOString(),
130
+ }) {
131
+ // Wall-clock resolution is 1ms; two facts minted in the same tick (a
132
+ // double-click wave, two rapid local writes) would otherwise share one
133
+ // timestamp — and since the diff key is (id, provenance), an identical
134
+ // provenance string for the same triple looks like no change at all, so
135
+ // the second write silently fails to broadcast. Monotonic nudging fixes
136
+ // that at the source rather than asking every caller to space calls out.
137
+ let lastTimestampMs = -Infinity;
138
+ const now = () => {
139
+ const raw = rawNow();
140
+ const ms = Date.parse(raw);
141
+ if (Number.isNaN(ms)) return raw; // an injected non-ISO now() is trusted as-is
142
+ const nudged = Math.max(ms, lastTimestampMs + 1);
143
+ lastTimestampMs = nudged;
144
+ return new Date(nudged).toISOString();
145
+ };
146
+ let displayName = myDisplayName;
147
+ let state = ROOM_IDLE;
148
+ let lastError = null;
149
+ let started = false;
150
+ let closed = false;
151
+ let droppedMessages = 0;
152
+
153
+ const peers = new Map(); // peerId -> { peerId, displayName, transport, connected }
154
+ const transportPeerId = new Map(); // transport -> peerId, for routing a reply back where it came from
155
+ const pendingIntros = new Map(); // peerId -> transport we minted an offer for and are awaiting an answer on
156
+ let pendingShare = null; // the transport behind the most recently minted invite link
157
+
158
+ // id -> the provenance string this room last saw on that fact. The diff key
159
+ // is (id, provenance) rather than id alone because a repeat wave re-asserts
160
+ // the SAME content-addressed triple and only unions a fresh tag onto it, so
161
+ // an id-only diff would never see the second wave. Recording a merged fact's
162
+ // post-merge provenance here immediately after appendFacts is what stops a
163
+ // fact that arrived from a peer being re-broadcast as if we had authored it.
164
+ const seenProvenanceById = new Map();
165
+ let cachedRows = [];
166
+
167
+ const factsListeners = new Set();
168
+ const stateListeners = new Set();
169
+ const peersListeners = new Set();
170
+
171
+ const emit = (listeners, payload) => { for (const fn of listeners) fn(payload); };
172
+
173
+ function setState(next) {
174
+ if (state === next) return;
175
+ state = next;
176
+ emit(stateListeners, state);
177
+ }
178
+
179
+ // A channel that died mid-send is a disconnect, reported by its own onClose
180
+ // handler; it should never fail the turn that happened to be broadcasting.
181
+ function send(transport, message) {
182
+ try { transport.send(message); } catch { /* handled by onClose */ }
183
+ }
184
+
185
+ const connectedPeers = () => [...peers.values()].filter((p) => p.connected);
186
+
187
+ function broadcast(message) {
188
+ for (const peer of connectedPeers()) send(peer.transport, message);
189
+ }
190
+
191
+ function relayTo(peerId, message) {
192
+ const peer = peers.get(peerId);
193
+ if (peer?.connected) send(peer.transport, message);
194
+ }
195
+
196
+ async function refreshRows() {
197
+ cachedRows = readFactRows(await loadMemory(memoryDir));
198
+ return cachedRows;
199
+ }
200
+
201
+ /** readFactRows walks `individuals` in array order and the fold reads them in
202
+ * that order, so two peers holding an identical fact set can still fold it
203
+ * differently while their arrival orders differ. Sorting the Fact
204
+ * individuals by content-addressed id after every merge makes that order the
205
+ * same on every peer. The sort is in place on the payload loadMemory
206
+ * returned, which for the in-memory backend a browser bundle uses is the
207
+ * live store itself. */
208
+ async function sortFactIndividualsById() {
209
+ const individuals = (await loadMemory(memoryDir))?.individuals;
210
+ if (!Array.isArray(individuals)) return;
211
+ const slots = [];
212
+ const facts = [];
213
+ for (let i = 0; i < individuals.length; i += 1) {
214
+ if (individuals[i]?.class !== FACT_CLASS) continue;
215
+ slots.push(i);
216
+ facts.push(individuals[i]);
217
+ }
218
+ // Codepoint order, never localeCompare: the whole point is that two peers
219
+ // in different locales land on the same order.
220
+ facts.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
221
+ for (let i = 0; i < slots.length; i += 1) individuals[slots[i]] = facts[i];
222
+ }
223
+
224
+ async function baselineSeen() {
225
+ await refreshRows();
226
+ for (const row of cachedRows) seenProvenanceById.set(row.id, row.provenance);
227
+ }
228
+
229
+ async function ensureStarted() {
230
+ if (started) return;
231
+ started = true;
232
+ // Every fact already on disk is history, not something this session
233
+ // authored, so it is baselined as seen. A joiner gets it through sync
234
+ // instead, which is what the sync filter exists to size down.
235
+ await baselineSeen();
236
+ const timestamp = now();
237
+ const identity = [];
238
+ if (worldId && worldName) identity.push(worldNameFact(worldId, worldName, timestamp));
239
+ if (myPeerId && displayName) identity.push(nodeNameFact(myPeerId, displayName, timestamp));
240
+ if (identity.length) {
241
+ await appendFacts(memoryDir, identity);
242
+ await sortFactIndividualsById();
243
+ await refreshRows();
244
+ }
245
+ }
246
+
247
+ /** Diff the store against what this room last saw, relabel each changed
248
+ * fact's provenance for the wire, and broadcast the batch. The page calls
249
+ * this once after every local turn or action. */
250
+ async function afterLocalChange() {
251
+ await ensureStarted();
252
+ await refreshRows();
253
+ const changed = [];
254
+ for (const row of cachedRows) {
255
+ if (seenProvenanceById.get(row.id) === row.provenance) continue;
256
+ seenProvenanceById.set(row.id, row.provenance);
257
+ changed.push(row);
258
+ }
259
+ if (!changed.length) return { broadcast: 0 };
260
+ const targets = connectedPeers();
261
+ if (!targets.length) return { broadcast: 0 };
262
+ const timestamp = now();
263
+ const facts = changed.flatMap((row) => toWireFacts(row, displayName, timestamp));
264
+ broadcast(opMessage({ from: myPeerId, facts }));
265
+ return { broadcast: changed.length };
266
+ }
267
+
268
+ async function mergeIncomingFacts(incoming) {
269
+ const accepted = syncableFacts((incoming || []).filter(isFactShaped));
270
+ if (!accepted.length) return { merged: 0 };
271
+ // Flush first: a local fact still waiting to be diffed would otherwise be
272
+ // recorded as merged below and never leave this browser.
273
+ await afterLocalChange();
274
+ const { ids } = await appendFacts(memoryDir, accepted.map((f) => ({
275
+ subject: f.subject,
276
+ predicate: f.predicate,
277
+ object: f.object,
278
+ provenance: typeof f.provenance === "string" ? f.provenance : "",
279
+ })));
280
+ await sortFactIndividualsById();
281
+ await refreshRows();
282
+ const mergedIds = new Set(ids);
283
+ for (const row of cachedRows) {
284
+ if (mergedIds.has(row.id)) seenProvenanceById.set(row.id, row.provenance);
285
+ }
286
+ emit(factsListeners, { merged: ids.length, rows: cachedRows });
287
+ return { merged: ids.length };
288
+ }
289
+
290
+ function registerPeer(peerId, peerDisplayName, transport) {
291
+ const existing = peers.get(peerId);
292
+ if (existing?.connected && existing.transport !== transport) {
293
+ // One direct channel per peer. A second one means both sides opened an
294
+ // introduction at once; drop the newcomer and keep the live channel.
295
+ transport.close();
296
+ return { accepted: false, freshlyConnected: false };
297
+ }
298
+ const freshlyConnected = !existing?.connected;
299
+ peers.set(peerId, { peerId, displayName: peerDisplayName, transport, connected: true });
300
+ transportPeerId.set(transport, peerId);
301
+ pendingIntros.delete(peerId);
302
+ setState(ROOM_CONNECTED);
303
+ emit(peersListeners, peerList());
304
+ return { accepted: true, freshlyConnected };
305
+ }
306
+
307
+ async function requestIntroduction(targetPeerId, viaTransport) {
308
+ const transport = transportFactory();
309
+ pendingIntros.set(targetPeerId, transport);
310
+ attachTransport(transport);
311
+ const sdp = await transport.createOffer();
312
+ send(viaTransport, introOfferMessage({ from: myPeerId, to: targetPeerId, sdp }));
313
+ }
314
+
315
+ async function handleMessage(transport, message) {
316
+ if (closed) return;
317
+ if (!isValidRoomMessage(message)) { droppedMessages += 1; return; }
318
+ switch (message.type) {
319
+ case "hello": {
320
+ if (message.peerId === myPeerId) return;
321
+ const { accepted, freshlyConnected } = registerPeer(message.peerId, message.displayName, transport);
322
+ if (!accepted || !freshlyConnected) return;
323
+ const others = connectedPeers()
324
+ .filter((p) => p.peerId !== message.peerId)
325
+ .map((p) => ({ peerId: p.peerId, displayName: p.displayName }));
326
+ send(transport, peerListMessage({ peers: others }));
327
+ return;
328
+ }
329
+ case "peer-list": {
330
+ for (const entry of message.peers) {
331
+ if (entry.peerId === myPeerId) continue;
332
+ if (peers.get(entry.peerId)?.connected) continue;
333
+ if (pendingIntros.has(entry.peerId)) continue;
334
+ await requestIntroduction(entry.peerId, transport);
335
+ }
336
+ return;
337
+ }
338
+ // intro-offer and intro-answer are the only messages this room ever
339
+ // forwards, and only while two peers who have never met are exchanging
340
+ // SDP through the one peer that already reaches both. Once their own
341
+ // channel opens they talk directly; no fact traffic is ever relayed.
342
+ case "intro-offer": {
343
+ if (message.to !== myPeerId) { relayTo(message.to, message); return; }
344
+ if (message.from === myPeerId) return;
345
+ if (peers.get(message.from)?.connected) return;
346
+ const answering = transportFactory();
347
+ attachTransport(answering);
348
+ const sdp = await answering.createAnswerFor(message.sdp);
349
+ send(transport, introAnswerMessage({ from: myPeerId, to: message.from, sdp }));
350
+ return;
351
+ }
352
+ case "intro-answer": {
353
+ if (message.to !== myPeerId) { relayTo(message.to, message); return; }
354
+ const pending = pendingIntros.get(message.from);
355
+ if (!pending) return;
356
+ await pending.completeWithAnswer(message.sdp);
357
+ return;
358
+ }
359
+ case "sync-request": {
360
+ await refreshRows();
361
+ const timestamp = now();
362
+ const facts = syncableFacts(cachedRows).flatMap((row) => toWireFacts(row, displayName, timestamp));
363
+ send(transport, syncResponseMessage({ facts }));
364
+ return;
365
+ }
366
+ // A historical sync response and a live op merge the same way, through
367
+ // the same filter, because merging is idempotent by id — the overlap
368
+ // between them needs no sequencing.
369
+ case "sync-response":
370
+ case "op":
371
+ await mergeIncomingFacts(message.facts);
372
+ }
373
+ }
374
+
375
+ function attachTransport(transport) {
376
+ // A peer can send anything, including an intro-offer carrying an SDP that
377
+ // makes the transport reject. That is a dropped message, not a crashed room.
378
+ transport.onMessage((data) => {
379
+ handleMessage(transport, data).catch(() => { droppedMessages += 1; });
380
+ });
381
+ transport.onOpen(() => {
382
+ if (closed) return;
383
+ setState(ROOM_CONNECTED);
384
+ send(transport, helloMessage({ peerId: myPeerId, displayName }));
385
+ // Sync starts the moment the channel opens, before any peer identity is
386
+ // known. Merging is idempotent by id, so overlap between the historical
387
+ // response and live ops needs no sequencing.
388
+ send(transport, syncRequestMessage());
389
+ });
390
+ transport.onClose(() => {
391
+ const peerId = transportPeerId.get(transport);
392
+ transportPeerId.delete(transport);
393
+ if (peerId) {
394
+ const peer = peers.get(peerId);
395
+ // A peer that has gone stays in the list marked away: its facts and its
396
+ // node name are still part of the graph, and rejoining is a fresh invite.
397
+ if (peer?.transport === transport) peers.set(peerId, { ...peer, connected: false });
398
+ emit(peersListeners, peerList());
399
+ }
400
+ if (pendingShare === transport) pendingShare = null;
401
+ for (const [target, pending] of pendingIntros) if (pending === transport) pendingIntros.delete(target);
402
+ if (!connectedPeers().length && state === ROOM_CONNECTING) setState(ROOM_FAILED);
403
+ });
404
+ return transport;
405
+ }
406
+
407
+ const peerList = () => [...peers.values()].map((p) => ({
408
+ peerId: p.peerId,
409
+ displayName: p.displayName,
410
+ connected: p.connected,
411
+ }));
412
+
413
+ /** Mint a fresh invite blob. One blob completes exactly one connection, so
414
+ * each call replaces whatever earlier invite was still waiting for a reply. */
415
+ async function startSharing() {
416
+ await ensureStarted();
417
+ const transport = attachTransport(transportFactory());
418
+ pendingShare = transport;
419
+ lastError = null;
420
+ const sdp = await transport.createOffer();
421
+ setState(ROOM_SHARING);
422
+ return { blob: encodeInviteBlob({ kind: "offer", sdp, world: worldId, worldName }) };
423
+ }
424
+
425
+ /** Decode someone's invite and answer it. Returns the reply blob to send
426
+ * back, or a named problem the page can show beside the box it came from. */
427
+ async function acceptInvite(blobString) {
428
+ await ensureStarted();
429
+ const decoded = decodeInviteBlob(blobString);
430
+ if (decoded.error) {
431
+ lastError = problem("invite", decoded.error);
432
+ return lastError;
433
+ }
434
+ if (decoded.value.kind !== "offer") {
435
+ lastError = problem("invite", "wrong-kind");
436
+ return lastError;
437
+ }
438
+ if (worldId && decoded.value.world !== worldId) {
439
+ lastError = problem("invite", "wrong-world");
440
+ return { ...lastError, world: decoded.value.world, worldName: decoded.value.worldName };
441
+ }
442
+ const transport = attachTransport(transportFactory());
443
+ lastError = null;
444
+ const sdp = await transport.createAnswerFor(decoded.value.sdp);
445
+ setState(ROOM_ANSWERING);
446
+ return {
447
+ blob: encodeInviteBlob({ kind: "reply", sdp }),
448
+ world: decoded.value.world,
449
+ worldName: decoded.value.worldName,
450
+ };
451
+ }
452
+
453
+ /** Feed the joiner's reply back into the invite it answers, completing the
454
+ * connection. */
455
+ async function completeInvite(replyBlob) {
456
+ await ensureStarted();
457
+ if (!pendingShare) {
458
+ lastError = problem("reply", "no-pending-invite");
459
+ return lastError;
460
+ }
461
+ const decoded = decodeInviteBlob(replyBlob);
462
+ if (decoded.error) {
463
+ lastError = problem("reply", decoded.error);
464
+ return lastError;
465
+ }
466
+ if (decoded.value.kind !== "reply") {
467
+ lastError = problem("reply", "wrong-kind");
468
+ return lastError;
469
+ }
470
+ const transport = pendingShare;
471
+ pendingShare = null;
472
+ lastError = null;
473
+ setState(ROOM_CONNECTING);
474
+ try {
475
+ await transport.completeWithAnswer(decoded.value.sdp);
476
+ } catch {
477
+ setState(ROOM_FAILED);
478
+ lastError = problem("reply", "connect-failed");
479
+ return lastError;
480
+ }
481
+ return { ok: true };
482
+ }
483
+
484
+ async function setMyDisplayName(name) {
485
+ await ensureStarted();
486
+ displayName = name;
487
+ await appendFacts(memoryDir, [nodeNameFact(myPeerId, name, now())]);
488
+ await sortFactIndividualsById();
489
+ return afterLocalChange();
490
+ }
491
+
492
+ /** Wave as `subjectId`, in `roomId` when there is one. chat.html's presence
493
+ * wave passes null and lands on PRESENCE_SCOPE instead. */
494
+ async function wave(subjectId, roomId = null) {
495
+ await ensureStarted();
496
+ await appendFacts(memoryDir, [waveFact(subjectId, roomId || PRESENCE_SCOPE, now())]);
497
+ await sortFactIndividualsById();
498
+ return afterLocalChange();
499
+ }
500
+
501
+ /** Whether `subjectId` is waving right now, read from the cached rows so a
502
+ * renderer can call it every frame. A stored row holds the normalized term,
503
+ * so a caller's raw id is normalized the same way before it's compared. */
504
+ function isWaving(subjectId, nowMs = Date.now(), windowMs) {
505
+ const subject = normFactTerm(subjectId);
506
+ return cachedRows.some((row) => row.subject === subject
507
+ && row.predicate === WAVED_PREDICATE
508
+ && isRecentWave(row, nowMs, windowMs));
509
+ }
510
+
511
+ /** A peer's own chosen node name, from its latest nodeName fact, falling
512
+ * back to the name it introduced itself with and then to a short peer id.
513
+ * A label never waits for a name fact to arrive. */
514
+ function displayNameFor(peerId) {
515
+ const fact = latestFact(cachedRows, normFactTerm(`peer:${peerId}`), NODE_NAME_PREDICATE);
516
+ if (fact?.object) return fact.object;
517
+ return peers.get(peerId)?.displayName || String(peerId).slice(0, 8);
518
+ }
519
+
520
+ function close() {
521
+ closed = true;
522
+ for (const peer of peers.values()) peer.transport.close();
523
+ if (pendingShare) pendingShare.close();
524
+ for (const pending of pendingIntros.values()) pending.close();
525
+ pendingIntros.clear();
526
+ pendingShare = null;
527
+ }
528
+
529
+ const subscribe = (listeners) => (handler) => {
530
+ listeners.add(handler);
531
+ return () => listeners.delete(handler);
532
+ };
533
+
534
+ return {
535
+ peerId: myPeerId,
536
+ worldId,
537
+ worldName,
538
+ get displayName() { return displayName; },
539
+ get state() { return state; },
540
+ get lastError() { return lastError; },
541
+ get droppedMessages() { return droppedMessages; },
542
+ peers: peerList,
543
+ displayNameFor,
544
+ start: ensureStarted,
545
+ startSharing,
546
+ acceptInvite,
547
+ completeInvite,
548
+ afterLocalChange,
549
+ setMyDisplayName,
550
+ wave,
551
+ isWaving,
552
+ factRows: () => cachedRows,
553
+ refresh: refreshRows,
554
+ onFactsChanged: subscribe(factsListeners),
555
+ onStateChanged: subscribe(stateListeners),
556
+ onPeersChanged: subscribe(peersListeners),
557
+ close,
558
+ };
559
+ }