@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.
- package/README.md +176 -8
- package/corpus/reference/index.json.gz +0 -0
- package/corpus/reference/manifest.json +8 -8
- package/corpus/reference/shards/ref-00.jsonl.gz +0 -0
- package/corpus/sprites/src/sprite-facts.jsonl +52 -0
- package/corpus/worlds/index.json.gz +0 -0
- package/corpus/worlds/manifest.json +53 -13
- package/corpus/worlds/shards/greyvale-museum.jsonl.gz +0 -0
- package/corpus/worlds/shards/lantern-cottage.jsonl.gz +0 -0
- package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
- package/corpus/worlds/shards/mud-hollow.jsonl.gz +0 -0
- package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
- package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
- package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
- package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
- package/corpus/worlds/src/mud-garden.jsonl +34 -1
- package/corpus/worlds/src/mud-hollow.jsonl +82 -0
- package/corpus/worlds/src/mud-warren.jsonl +124 -0
- package/corpus/worlds/src/spider-fly.jsonl +1 -1
- package/data/sprites/animal-icon.toml +11 -5
- package/data/sprites/book-icon.toml +9 -5
- package/data/sprites/cabinet-icon.toml +10 -5
- package/data/sprites/cellar-icon.toml +16 -6
- package/data/sprites/container-icon.toml +7 -3
- package/data/sprites/desk-icon.toml +8 -5
- package/data/sprites/dog-icon.toml +8 -5
- package/data/sprites/dog-with-colour-icon.toml +13 -12
- package/data/sprites/drawing-room-icon.toml +14 -5
- package/data/sprites/egg-icon.toml +6 -3
- package/data/sprites/fly-icon.toml +10 -5
- package/data/sprites/furniture-icon.toml +5 -3
- package/data/sprites/garden-icon.toml +10 -3
- package/data/sprites/key-icon.toml +3 -1
- package/data/sprites/kitchen-icon.toml +15 -6
- package/data/sprites/lamp-icon.toml +9 -4
- package/data/sprites/letter-icon.toml +7 -4
- package/data/sprites/library-icon.toml +14 -3
- package/data/sprites/pan-icon.toml +7 -3
- package/data/sprites/person-icon.toml +6 -2
- package/data/sprites/poodle-icon.toml +1 -1
- package/data/sprites/portable-icon.toml +6 -4
- package/data/sprites/portrait-icon.toml +7 -4
- package/data/sprites/room-icon.toml +11 -2
- package/data/sprites/spider-icon.toml +9 -3
- package/data/sprites/study-icon.toml +10 -2
- package/package.json +5 -4
- package/src/adapters/p2p/webrtc-transport.mjs +146 -0
- package/src/domain/game-config.mjs +26 -5
- package/src/domain/grammar/ace.mjs +40 -4
- package/src/domain/grammar/lexicon-core.json +2 -1
- package/src/domain/grammar/lexicon.mjs +31 -0
- package/src/domain/memory/trust.mjs +6 -4
- package/src/domain/p2p/facts.mjs +81 -0
- package/src/domain/p2p/peer-id.mjs +32 -0
- package/src/domain/p2p/provenance-relabel.mjs +26 -0
- package/src/domain/p2p/sync-filter.mjs +31 -0
- package/src/domain/p2p/wire.mjs +123 -0
- package/src/domain/reference-pack.mjs +31 -7
- package/src/domain/spider-fly-world.mjs +1 -1
- package/src/domain/sprite-map.mjs +10 -2
- package/src/domain/sprite-templates.mjs +8 -6
- package/src/services/adventure-editor.mjs +10 -2
- package/src/services/adventure-viz.mjs +309 -73
- package/src/services/adventure.mjs +1034 -330
- package/src/services/chat-page-viz.mjs +1097 -16
- package/src/services/chat.mjs +99 -18
- package/src/services/code-explorer-viz.mjs +52 -14
- package/src/services/extract-facts.mjs +4 -7
- package/src/services/ingest-viz.mjs +54 -26
- package/src/services/ledger-viz.mjs +105 -56
- package/src/services/memory-panel-viz.mjs +24 -0
- package/src/services/mud-editor.mjs +313 -0
- package/src/services/mud-turn.mjs +264 -87
- package/src/services/mud-viz.mjs +2606 -384
- package/src/services/p2p-room.mjs +559 -0
- package/src/services/plan-viz.mjs +119 -61
- package/src/services/research-viz.mjs +151 -57
- package/src/services/spider-fly-turn.mjs +1 -1
- package/src/services/spider-fly-viz.mjs +67 -43
- package/src/services/sprite-catalog-viz.mjs +175 -51
- package/src/services/viz-theme.mjs +15 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +109 -109
- package/src/surfaces/web/mud-browser-entry.mjs +226 -18
- 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
|
+
}
|