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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/corpus/sprites/src/sprite-facts.jsonl +28 -0
  2. package/corpus/tier2/generate.mjs +10 -1
  3. package/corpus/tier2/human.jsonl +23 -0
  4. package/corpus/tier2/manifest.json +3 -3
  5. package/corpus/worlds/index.json.gz +0 -0
  6. package/corpus/worlds/manifest.json +15 -5
  7. package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
  8. package/corpus/worlds/src/mud-garden.jsonl +101 -0
  9. package/package.json +2 -1
  10. package/src/adapters/p2p/webrtc-transport.mjs +146 -0
  11. package/src/domain/game-config.mjs +67 -0
  12. package/src/domain/grammar/ace.mjs +11 -3
  13. package/src/domain/grammar/lexicon-core.json +3 -0
  14. package/src/domain/grammar/lexicon.mjs +13 -0
  15. package/src/domain/memory/trust.mjs +15 -0
  16. package/src/domain/p2p/facts.mjs +81 -0
  17. package/src/domain/p2p/peer-id.mjs +32 -0
  18. package/src/domain/p2p/provenance-relabel.mjs +26 -0
  19. package/src/domain/p2p/sync-filter.mjs +31 -0
  20. package/src/domain/p2p/wire.mjs +123 -0
  21. package/src/domain/sprite-map.mjs +10 -2
  22. package/src/services/adventure-editor.mjs +10 -2
  23. package/src/services/adventure-viz.mjs +192 -39
  24. package/src/services/adventure.mjs +989 -69
  25. package/src/services/chat-page-viz.mjs +1060 -7
  26. package/src/services/chat-session.mjs +28 -3
  27. package/src/services/chat.mjs +2 -2
  28. package/src/services/mud-editor.mjs +313 -0
  29. package/src/services/mud-turn.mjs +572 -0
  30. package/src/services/mud-viz.mjs +2055 -0
  31. package/src/services/p2p-room.mjs +559 -0
  32. package/src/surfaces/web/memory-ask-browser.bundle.js +77 -77
  33. package/src/surfaces/web/mud-browser-entry.mjs +330 -0
  34. package/src/surfaces/web/p2p-browser-entry.mjs +39 -0
@@ -0,0 +1,146 @@
1
+ // One WebRTC DataChannel between two browsers, and nothing else. The caller
2
+ // carries the offer and answer SDP strings between the two machines however it
3
+ // likes — a link, a paste, an already-open channel to a third peer — and this
4
+ // module never learns what travels over the channel once it is open: it takes
5
+ // plain JS values in and hands plain JS values out.
6
+ //
7
+ // `connectionState` is a live GETTER PROPERTY, not a method: read
8
+ // `transport.connectionState`, never `transport.connectionState()`. It reports
9
+ // RTCPeerConnection's own state ("new" | "connecting" | "connected" | "failed"
10
+ // | "closed"), so a caller can poll it without registering a handler.
11
+ //
12
+ // `iceServers` defaults to [] and this design keeps it empty: no STUN, no
13
+ // TURN, no third party in the loop at all. Peers that cannot already reach
14
+ // each other directly (same machine, same LAN, or a NAT that allows it) never
15
+ // finish the handshake, which is the stated boundary of staying serverless.
16
+ //
17
+ // Both `createOffer` and `createAnswerFor` resolve only once ICE gathering has
18
+ // completed, so the SDP string they return already carries every candidate.
19
+ // Nothing here trickles, because a pasted blob is a one-shot message rather
20
+ // than a live connection back to the other side.
21
+ //
22
+ // Late handler registration still fires: `onOpen` on an already-open channel
23
+ // and `onClose` on an already-closed one call back immediately, so a caller
24
+ // that registers after the transition never silently misses it. Each fires at
25
+ // most once.
26
+
27
+ const CHANNEL_LABEL = "tmct";
28
+
29
+ export function createTransport({ iceServers = [] } = {}) {
30
+ const PeerConnection = globalThis.RTCPeerConnection;
31
+ if (typeof PeerConnection !== "function") {
32
+ throw new Error("no RTCPeerConnection here: this transport runs in a browser, not in bare node");
33
+ }
34
+
35
+ const connection = new PeerConnection({ iceServers });
36
+ const messageHandlers = [];
37
+ const openHandlers = [];
38
+ const closeHandlers = [];
39
+
40
+ let channel = null;
41
+ let openAnnounced = false;
42
+ let closeAnnounced = false;
43
+ let tornDown = false;
44
+
45
+ function announceOpen() {
46
+ if (openAnnounced) return;
47
+ openAnnounced = true;
48
+ for (const handler of openHandlers) handler();
49
+ }
50
+
51
+ function announceClose() {
52
+ if (closeAnnounced) return;
53
+ closeAnnounced = true;
54
+ for (const handler of closeHandlers) handler();
55
+ }
56
+
57
+ function receive(event) {
58
+ let value;
59
+ try {
60
+ value = JSON.parse(event.data);
61
+ } catch {
62
+ // A peer sending something that isn't JSON must not be able to throw
63
+ // inside our receive path, so the frame is dropped rather than raised.
64
+ return;
65
+ }
66
+ for (const handler of messageHandlers) handler(value);
67
+ }
68
+
69
+ function attachChannel(dataChannel) {
70
+ channel = dataChannel;
71
+ dataChannel.addEventListener("open", announceOpen);
72
+ dataChannel.addEventListener("close", announceClose);
73
+ dataChannel.addEventListener("message", receive);
74
+ if (dataChannel.readyState === "open") announceOpen();
75
+ }
76
+
77
+ connection.addEventListener("datachannel", (event) => attachChannel(event.channel));
78
+ connection.addEventListener("connectionstatechange", () => {
79
+ const state = connection.connectionState;
80
+ if (state === "failed" || state === "closed") announceClose();
81
+ });
82
+
83
+ async function whenIceGatheringCompletes() {
84
+ if (connection.iceGatheringState === "complete") return;
85
+ await new Promise((resolve) => {
86
+ const settle = () => {
87
+ if (connection.iceGatheringState !== "complete") return;
88
+ connection.removeEventListener("icegatheringstatechange", settle);
89
+ resolve();
90
+ };
91
+ connection.addEventListener("icegatheringstatechange", settle);
92
+ });
93
+ }
94
+
95
+ return {
96
+ async createOffer() {
97
+ attachChannel(connection.createDataChannel(CHANNEL_LABEL));
98
+ await connection.setLocalDescription(await connection.createOffer());
99
+ await whenIceGatheringCompletes();
100
+ return connection.localDescription.sdp;
101
+ },
102
+
103
+ async createAnswerFor(offerSdp) {
104
+ await connection.setRemoteDescription({ type: "offer", sdp: offerSdp });
105
+ await connection.setLocalDescription(await connection.createAnswer());
106
+ await whenIceGatheringCompletes();
107
+ return connection.localDescription.sdp;
108
+ },
109
+
110
+ async completeWithAnswer(answerSdp) {
111
+ await connection.setRemoteDescription({ type: "answer", sdp: answerSdp });
112
+ },
113
+
114
+ send(data) {
115
+ const state = channel ? channel.readyState : "missing";
116
+ if (state !== "open") throw new Error(`cannot send over a data channel that is ${state}`);
117
+ channel.send(JSON.stringify(data));
118
+ },
119
+
120
+ onMessage(handler) {
121
+ messageHandlers.push(handler);
122
+ },
123
+
124
+ onOpen(handler) {
125
+ openHandlers.push(handler);
126
+ if (openAnnounced) handler();
127
+ },
128
+
129
+ onClose(handler) {
130
+ closeHandlers.push(handler);
131
+ if (closeAnnounced) handler();
132
+ },
133
+
134
+ close() {
135
+ if (tornDown) return;
136
+ tornDown = true;
137
+ channel?.close();
138
+ connection.close();
139
+ announceClose();
140
+ },
141
+
142
+ get connectionState() {
143
+ return connection.connectionState;
144
+ },
145
+ };
146
+ }
@@ -27,6 +27,35 @@ export const DEFAULT_GAME_CONFIG = Object.freeze({
27
27
  minHatchlingMass: 3,
28
28
  webDurationTurns: 10,
29
29
  }),
30
+ // The drains are what make a mud character mortal: every scripted turn
31
+ // charges one, and a character whose mass reaches zero starves and takes no
32
+ // more turns. They are sized against the demo page's own default run (400
33
+ // shared turns, so roughly 200 each for two animals) — an animal that never
34
+ // eats dies about two thirds of the way through, and one that forages does
35
+ // not. A drain that emptied a starting mass in twenty turns would end every
36
+ // run before it had shown anything.
37
+ mud: Object.freeze({
38
+ moleInitialMass: 8,
39
+ moleMassDecrementPerTurn: 0.06,
40
+ moleSpeed: 1,
41
+ moleDigReach: 1,
42
+ voleInitialMass: 6,
43
+ voleMassDecrementPerTurn: 0.05,
44
+ voleSpeed: 1,
45
+ voleDigReach: 1,
46
+ badgerInitialMass: 20,
47
+ badgerMassDecrementPerTurn: 0.08,
48
+ badgerSpeed: 2,
49
+ badgerDigReach: 2,
50
+ groundhogInitialMass: 12,
51
+ groundhogMassDecrementPerTurn: 0.06,
52
+ groundhogSpeed: 2,
53
+ groundhogDigReach: 2,
54
+ meerkatInitialMass: 9,
55
+ meerkatMassDecrementPerTurn: 0.05,
56
+ meerkatSpeed: 1,
57
+ meerkatDigReach: 1,
58
+ }),
30
59
  guessNumber: Object.freeze({
31
60
  defaultLo: 1,
32
61
  defaultHi: 100,
@@ -55,6 +84,29 @@ const SPIDER_FLY_KEY_MAP = Object.freeze({
55
84
  web_duration_turns: "webDurationTurns",
56
85
  });
57
86
 
87
+ const MUD_KEY_MAP = Object.freeze({
88
+ mole_initial_mass: "moleInitialMass",
89
+ mole_mass_decrement_per_turn: "moleMassDecrementPerTurn",
90
+ mole_speed: "moleSpeed",
91
+ mole_dig_reach: "moleDigReach",
92
+ vole_initial_mass: "voleInitialMass",
93
+ vole_mass_decrement_per_turn: "voleMassDecrementPerTurn",
94
+ vole_speed: "voleSpeed",
95
+ vole_dig_reach: "voleDigReach",
96
+ badger_initial_mass: "badgerInitialMass",
97
+ badger_mass_decrement_per_turn: "badgerMassDecrementPerTurn",
98
+ badger_speed: "badgerSpeed",
99
+ badger_dig_reach: "badgerDigReach",
100
+ groundhog_initial_mass: "groundhogInitialMass",
101
+ groundhog_mass_decrement_per_turn: "groundhogMassDecrementPerTurn",
102
+ groundhog_speed: "groundhogSpeed",
103
+ groundhog_dig_reach: "groundhogDigReach",
104
+ meerkat_initial_mass: "meerkatInitialMass",
105
+ meerkat_mass_decrement_per_turn: "meerkatMassDecrementPerTurn",
106
+ meerkat_speed: "meerkatSpeed",
107
+ meerkat_dig_reach: "meerkatDigReach",
108
+ });
109
+
58
110
  const GUESS_NUMBER_KEY_MAP = Object.freeze({
59
111
  default_lo: "defaultLo",
60
112
  default_hi: "defaultHi",
@@ -77,6 +129,20 @@ function mergeSection(defaults, raw, keyMap) {
77
129
  return out;
78
130
  }
79
131
 
132
+ /** The species a mud character id names — "mole-1" -> "mole" — which is how
133
+ * every per-species knob above is keyed. Pure. */
134
+ export function mudSpeciesOf(characterId) {
135
+ return String(characterId).replace(/-\d+$/, "");
136
+ }
137
+
138
+ /** What one turn costs `characterId` in mass, from the resolved `mud` section.
139
+ * Zero for a species the config carries no drain for: a knob nobody set is not
140
+ * a reason to invent a number and starve something with it. Pure. */
141
+ export function mudMassDrainPerTurn(mudConfig, characterId) {
142
+ const drain = mudConfig?.[`${mudSpeciesOf(characterId)}MassDecrementPerTurn`];
143
+ return Number.isFinite(drain) ? drain : 0;
144
+ }
145
+
80
146
  /**
81
147
  * Fold a normalized tmct.toml's `games`/`planning` tables (the raw sparse
82
148
  * pass-through src/adapters/toml-config.mjs's normalizeConfig produces —
@@ -90,6 +156,7 @@ export function resolveGameConfig(toml) {
90
156
  const games = toml?.games ?? {};
91
157
  return {
92
158
  spiderFly: mergeSection(DEFAULT_GAME_CONFIG.spiderFly, games["spider-fly"], SPIDER_FLY_KEY_MAP),
159
+ mud: mergeSection(DEFAULT_GAME_CONFIG.mud, games.mud, MUD_KEY_MAP),
93
160
  guessNumber: mergeSection(DEFAULT_GAME_CONFIG.guessNumber, games["guess-number"], GUESS_NUMBER_KEY_MAP),
94
161
  planning: mergeSection(DEFAULT_GAME_CONFIG.planning, toml?.planning, PLANNING_KEY_MAP),
95
162
  };
@@ -525,7 +525,7 @@ export function parseAce(sentence, lexicon = loadLexicon()) {
525
525
  // it is reported back on the command (`corrected`) for the caller to name in
526
526
  // its response — a synonym executes silently, a typo fix does not.
527
527
 
528
- const IMPERATIVE_VERBS = new Set(["go", "take", "drop", "open", "unlock", "close", "give", "look", "talk", "examine"]);
528
+ const IMPERATIVE_VERBS = new Set(["go", "take", "drop", "open", "unlock", "close", "give", "look", "talk", "examine", "dig", "eat", "put"]);
529
529
  const IMPERATIVE_DIRECTIONS = new Set(["north", "south", "east", "west", "up", "down"]);
530
530
 
531
531
  // The object pronouns an imperative object slot may carry ("examine it", "take
@@ -665,13 +665,13 @@ export function parseImperative(sentence, lexicon = loadLexicon()) {
665
665
  if (object.term == null) return miss(object.unknown);
666
666
  return command({ object: object.term });
667
667
  }
668
- if (verb === "examine" || verb === "talk") {
668
+ if (verb === "examine" || verb === "talk" || verb === "eat") {
669
669
  if (!rest.length) return null;
670
670
  const object = imperativeNP(lexicon, rest);
671
671
  if (object.term == null) return miss(object.unknown);
672
672
  return command({ object: object.term });
673
673
  }
674
- if (verb === "go") {
674
+ if (verb === "go" || verb === "dig") {
675
675
  if (rest.length === 1) {
676
676
  if (IMPERATIVE_DIRECTIONS.has(lower[0])) return command({ direction: lower[0] });
677
677
  const fuzzyDir = fuzzyMatchInSet(lower[0], [...IMPERATIVE_DIRECTIONS], IMPERATIVE_FUZZY_BOUND);
@@ -690,6 +690,14 @@ export function parseImperative(sentence, lexicon = loadLexicon()) {
690
690
  if (object.term == null || indirect.term == null) return miss([...object.unknown, ...indirect.unknown]);
691
691
  return command({ object: object.term, indirectObject: indirect.term });
692
692
  }
693
+ if (verb === "put") {
694
+ const inIdx = lower.indexOf("in");
695
+ if (inIdx < 1 || inIdx === rest.length - 1) return null;
696
+ const object = imperativeNP(lexicon, rest.slice(0, inIdx));
697
+ const container = imperativeNP(lexicon, rest.slice(inIdx + 1));
698
+ if (object.term == null || container.term == null) return miss([...object.unknown, ...container.unknown]);
699
+ return command({ object: object.term, indirectObject: container.term });
700
+ }
693
701
  if (verb === "unlock") {
694
702
  const withIdx = lower.indexOf("with");
695
703
  if (withIdx !== -1) {
@@ -394,6 +394,9 @@
394
394
  "tiger": {},
395
395
  "elephant": {},
396
396
  "snake": {},
397
+ "mole": {},
398
+ "groundhog": {},
399
+ "meerkat": {},
397
400
  "tree": {},
398
401
  "flower": {},
399
402
  "grass": {},
@@ -149,6 +149,19 @@ export function loadLexicon(extra, ns = DEFAULT_NS) {
149
149
  return lex;
150
150
  }
151
151
 
152
+ /** `lexicon` with `names` additionally declared as proper names. The noun,
153
+ * verb and adjective maps are SHARED with the base lexicon rather than
154
+ * re-ingested — a caller that re-declares on every turn (a live game world
155
+ * minting ids as it is played) would otherwise rebuild nine thousand core
156
+ * entries to add half a dozen. Proper names outrank every other category, so
157
+ * a name with no dictionary reading of its own ("groundhog-1", "carrot-2")
158
+ * resolves as itself instead of dying as an undeclared word. */
159
+ export function withProperNames(lexicon, names) {
160
+ const properNames = new Map(lexicon.properNames);
161
+ for (const name of names) properNames.set(String(name).toLowerCase(), String(name));
162
+ return { ...lexicon, properNames };
163
+ }
164
+
152
165
  /** Noun lookup with plural folding; returns the entry ({lemma, property?}) or
153
166
  * null. `opts.singularOnly` (an "a"/"an" determiner) prunes the irregular-
154
167
  * plural fold in favor of a standalone-singular entry when both exist for
@@ -40,6 +40,13 @@ function parseChatTagRest(rest) {
40
40
  * same tier the hand-written tier2 corpus already scores at; the :turnN
41
41
  * segment a snapshot write carries records when, not who, and is not
42
42
  * part of the Source identity)
43
+ * mud:<character>[:turnN][:gone] -> { kind:"corpus", name:"mud:<character>" }
44
+ * (one character's own testimony in a multi-character world — what it told
45
+ * someone, what it examined for itself, and with `:gone` what it saw leave
46
+ * the world. Same tier as the world, but one Source per character; the
47
+ * `mud:` prefix in the name keeps that Source apart from a world of the
48
+ * same literal name. Both trailing segments say when and what, not who, so
49
+ * neither is part of the Source identity)
43
50
  * ace:chat:<session>@<ts> -> { kind:"operator", createdAt:<ts>, sessionId:<session> }
44
51
  * teach:chat:<session>@<ts> -> { kind:"teach", createdAt:<ts>, sessionId:<session> }
45
52
  * web:<url> | url:<url> -> { kind:"web", url:<url> }
@@ -98,6 +105,14 @@ export function provenanceTagToSource(tag) {
98
105
  // authored shipped content scored at the corpus tier; the per-turn tail is
99
106
  // dropped from the id so every write of one world corroborates one Source.
100
107
  if (head.startsWith("world:")) return { kind: "corpus", name: head.slice("world:".length).split(":")[0] || "unknown" };
108
+ // mud:<character>[:turnN] — one character's own testimony inside a multi-
109
+ // character world: what it told another character, and what it saw for
110
+ // itself. Same corpus tier as the world it stands in, but a Source per
111
+ // CHARACTER, so every claim an animal makes over a whole game accumulates on
112
+ // that animal's own track record rather than the world's. The name keeps the
113
+ // `mud:` prefix so `src:corpus:mud:<character>` can never collide with a
114
+ // `world:<name>` Source that happens to share the literal name.
115
+ if (head.startsWith("mud:")) return { kind: "corpus", name: `mud:${head.slice("mud:".length).split(":")[0] || "unknown"}` };
101
116
  if (head.startsWith("ace:")) return { kind: "operator", ...parseChatTagRest(head.slice("ace:".length)) };
102
117
  if (head.startsWith("teach:")) {
103
118
  // the chat teach lane's natural frames — chat.mjs's teachProvenanceTag
@@ -0,0 +1,81 @@
1
+ // domain/p2p/facts.mjs — the small set of new predicates the P2P layer
2
+ // introduces on top of the existing memory store's triple shape, plus pure
3
+ // constructors for each. Every one is a plain add-only fact, replicated the
4
+ // same way any other fact is (appendFacts' own union-by-id behavior) — no
5
+ // new CRDT primitive. Provenance uses the existing `ace:` tag shape
6
+ // (`ace:p2p:<id>@<ts>`) so trust.mjs's own parser reads it with no changes
7
+ // there: stripped of its `ace:` prefix it reads as `p2p:<id>@<ts>`, which
8
+ // parses to { kind: "operator", sessionId: "p2p:<id>", createdAt: <ts> }.
9
+ import { provenanceTagToSource } from "../memory/trust.mjs";
10
+
11
+ export const WORLD_NAME_PREDICATE = "mgx:worldName";
12
+ export const NODE_NAME_PREDICATE = "mgx:nodeName";
13
+ export const PLAYED_BY_PREDICATE = "mgx:playedBy";
14
+ export const WAVED_PREDICATE = "mgx:waved";
15
+
16
+ export const P2P_PREDICATES = Object.freeze([
17
+ WORLD_NAME_PREDICATE,
18
+ NODE_NAME_PREDICATE,
19
+ PLAYED_BY_PREDICATE,
20
+ WAVED_PREDICATE,
21
+ ]);
22
+
23
+ const provenanceFor = (id, timestamp) => `ace:p2p:${id}@${timestamp}`;
24
+
25
+ export function worldNameFact(worldId, name, timestamp) {
26
+ return { subject: worldId, predicate: WORLD_NAME_PREDICATE, object: name, provenance: provenanceFor(worldId, timestamp) };
27
+ }
28
+
29
+ export function nodeNameFact(peerId, name, timestamp) {
30
+ return { subject: `peer:${peerId}`, predicate: NODE_NAME_PREDICATE, object: name, provenance: provenanceFor(peerId, timestamp) };
31
+ }
32
+
33
+ export function playedByFact(characterId, peerId, timestamp) {
34
+ return { subject: characterId, predicate: PLAYED_BY_PREDICATE, object: `peer:${peerId}`, provenance: provenanceFor(characterId, timestamp) };
35
+ }
36
+
37
+ export function waveFact(characterId, roomId, timestamp) {
38
+ return { subject: characterId, predicate: WAVED_PREDICATE, object: roomId, provenance: provenanceFor(`${characterId}-${roomId}`, timestamp) };
39
+ }
40
+
41
+ /** The newest asserted-at timestamp across every " | "-joined segment of a
42
+ * fact's provenance — the correct read for "when was this most recently
43
+ * true," since a repeat wave unions a fresh tag onto the SAME fact id
44
+ * (same subject/predicate/object) rather than minting a new row. Returns
45
+ * null if no segment parses to a timestamp at all. */
46
+ export function latestProvenanceTimestamp(provenance) {
47
+ const tag = String(provenance || "");
48
+ if (!tag) return null;
49
+ let latest = null;
50
+ for (const segment of tag.split(" | ")) {
51
+ const source = provenanceTagToSource(segment);
52
+ const at = source?.createdAt ? Date.parse(source.createdAt) : NaN;
53
+ if (!Number.isNaN(at) && (latest === null || at > latest)) latest = at;
54
+ }
55
+ return latest;
56
+ }
57
+
58
+ /** "Currently waving" is a read-time recency question, never a retraction —
59
+ * a wave fact older than the window just stops being read as current, with
60
+ * nothing ever deleted from the graph. */
61
+ export function isRecentWave(waveFactRow, nowMs, windowMs = 8000) {
62
+ const at = latestProvenanceTimestamp(waveFactRow?.provenance);
63
+ if (at === null) return false;
64
+ const age = nowMs - at;
65
+ return age >= 0 && age <= windowMs;
66
+ }
67
+
68
+ /** The latest-by-timestamp fact for a subject+predicate pair — the "current
69
+ * value" read for an add-only, no-retraction fact like a node's own name
70
+ * or which peer plays a character (first-claim-wins uses the OLDEST of
71
+ * these instead; see playedByFact's own caller for that distinction). */
72
+ export function latestFact(rows, subject, predicate) {
73
+ let best = null;
74
+ let bestAt = -Infinity;
75
+ for (const row of rows) {
76
+ if (row.subject !== subject || row.predicate !== predicate) continue;
77
+ const at = latestProvenanceTimestamp(row.provenance) ?? -Infinity;
78
+ if (at > bestAt) { best = row; bestAt = at; }
79
+ }
80
+ return best;
81
+ }
@@ -0,0 +1,32 @@
1
+ // domain/p2p/peer-id.mjs — pure id/name generation for the P2P layer. No
2
+ // network, no DOM; safe to import from both a Node test and a browser
3
+ // bundle. World/peer ids are UUIDs, generated client-side and never seen by
4
+ // any server. Display names are two words drawn from the same closed-world
5
+ // lexicon that grounds every taught fact, so a name a player sees on screen
6
+ // is always a real word this build's vocabulary already recognizes.
7
+ import { loadLexicon } from "../grammar/lexicon.mjs";
8
+
9
+ const fallbackId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
10
+
11
+ export function generatePeerId() {
12
+ return globalThis.crypto?.randomUUID?.() ?? fallbackId("peer");
13
+ }
14
+
15
+ export function generateWorldId() {
16
+ return globalThis.crypto?.randomUUID?.() ?? fallbackId("world");
17
+ }
18
+
19
+ /** Two distinct words drawn from the lexicon's own noun list — a mnemonic
20
+ * label, not a credential. `random` is injectable for deterministic tests;
21
+ * defaults to Math.random. Callers add a numeric suffix themselves if a
22
+ * live collision turns up among currently-connected peers — this function
23
+ * never guesses at uniqueness on its own. */
24
+ export function generateDisplayName(random = Math.random) {
25
+ const words = [...loadLexicon().nouns.keys()];
26
+ if (words.length < 2) return "guest-guest";
27
+ const pick = () => words[Math.floor(random() * words.length)];
28
+ const first = pick();
29
+ let second = pick();
30
+ while (second === first) second = pick();
31
+ return `${first}-${second}`;
32
+ }
@@ -0,0 +1,26 @@
1
+ // domain/p2p/provenance-relabel.mjs — rewrites a fact's outgoing provenance
2
+ // tag before it's broadcast to peers, so "who taught this" reads as a node
3
+ // name rather than a local session id. Only teach/operator-kind tags are
4
+ // touched; a mud world/testimony tag is already attributed to the world or
5
+ // the character that made it, not the person at the keyboard, and rewriting
6
+ // it would lose information rather than add it.
7
+ import { provenanceTagToSource } from "../memory/trust.mjs";
8
+
9
+ const RELABELED_KINDS = new Set(["teach", "operator"]);
10
+
11
+ /** `provenance` may already be a " | "-joined union of several tags (the
12
+ * same fact taught more than once, from different sources) — relabel each
13
+ * segment independently and rejoin, so a segment this peer didn't author
14
+ * passes through untouched. */
15
+ export function relabelForBroadcast(provenance, myDisplayName, timestamp) {
16
+ const tag = String(provenance || "");
17
+ if (!tag) return tag;
18
+ return tag
19
+ .split(" | ")
20
+ .map((segment) => {
21
+ const source = provenanceTagToSource(segment);
22
+ if (!source || !RELABELED_KINDS.has(source.kind)) return segment;
23
+ return `teach:peer:${myDisplayName}@${timestamp}`;
24
+ })
25
+ .join(" | ");
26
+ }
@@ -0,0 +1,31 @@
1
+ // domain/p2p/sync-filter.mjs — which rows of an already-loaded fact store
2
+ // are worth syncing to a new joiner. Every peer's page ships the identical
3
+ // build-time seed, so those rows already share the same content-addressed
4
+ // ids before any network traffic happens; sending them again is pure waste.
5
+ // What actually needs syncing is the delta: whatever a person or a peer
6
+ // actually added since boot.
7
+ import { provenanceTagToSource } from "../memory/trust.mjs";
8
+
9
+ const CHAT_SYNCABLE_KINDS = new Set(["teach", "operator"]);
10
+
11
+ /** chat.html: every fact a human (locally or via a peer) actually taught or
12
+ * asserted — never a row from the shipped corpus. */
13
+ export function chatSyncableFacts(rows) {
14
+ return rows.filter((row) => {
15
+ const source = provenanceTagToSource(row.provenance);
16
+ return source ? CHAT_SYNCABLE_KINDS.has(source.kind) : false;
17
+ });
18
+ }
19
+
20
+ /** mud.html: every fact that isn't part of the bare, unsuffixed world seed —
21
+ * a move, a dig, a piece of testimony, or one of the P2P layer's own new
22
+ * predicates (world/node names, character claims, waves). `isMudStatePredicate`
23
+ * is injected rather than imported from adventure.mjs directly, so this
24
+ * module never needs to know that file's internal predicate names — the
25
+ * caller (whoever wires the mud room) supplies it, typically
26
+ * `adventure.mjs`'s own exported predicate check, extended to also accept
27
+ * this module's own P2P predicates via `extraPredicates`. */
28
+ export function mudSyncableFacts(rows, isMudStatePredicate, extraPredicates = []) {
29
+ const extra = new Set(extraPredicates);
30
+ return rows.filter((row) => extra.has(row.predicate) || isMudStatePredicate(row.predicate));
31
+ }
@@ -0,0 +1,123 @@
1
+ // domain/p2p/wire.mjs — pure message shapes and blob encoding for the P2P
2
+ // layer. Two kinds of payload: an "invite blob" carried in a URL or pasted
3
+ // by hand (a base64url-encoded JSON envelope holding one SDP string), and
4
+ // plain JSON messages sent over an already-open DataChannel. Nothing here
5
+ // touches the network or WebRTC itself — src/adapters/p2p/webrtc-transport.mjs
6
+ // owns the connection, src/services/p2p-room.mjs owns what these messages mean.
7
+
8
+ const INVITE_KINDS = new Set(["offer", "reply"]);
9
+
10
+ function toBase64Url(str) {
11
+ const b64 = typeof btoa === "function" ? btoa(str) : Buffer.from(str, "utf8").toString("base64");
12
+ return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
13
+ }
14
+
15
+ function fromBase64Url(b64url) {
16
+ let b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
17
+ while (b64.length % 4) b64 += "=";
18
+ return typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("utf8");
19
+ }
20
+
21
+ /** Encode one invite envelope — an offer (from the sharer) or a reply (from
22
+ * the joiner) — into a URL-safe string. An offer carries the world id and
23
+ * name so a fresh joiner can be shown them before connecting; a reply only
24
+ * needs to carry the answer SDP back to the inviter. */
25
+ export function encodeInviteBlob({ kind, sdp, world, worldName }) {
26
+ if (!INVITE_KINDS.has(kind)) throw new Error(`encodeInviteBlob: unknown kind "${kind}"`);
27
+ if (typeof sdp !== "string" || !sdp) throw new Error("encodeInviteBlob: sdp is required");
28
+ const envelope = kind === "offer" ? { v: 1, kind, sdp, world, worldName } : { v: 1, kind, sdp };
29
+ return toBase64Url(JSON.stringify(envelope));
30
+ }
31
+
32
+ /** Decode a blob produced by encodeInviteBlob. Never throws — a malformed,
33
+ * truncated, or foreign-shaped blob returns { error } instead, so the UI can
34
+ * show a specific message ("this invite looks cut short") rather than an
35
+ * uncaught exception or a silent no-op. */
36
+ export function decodeInviteBlob(blobString) {
37
+ if (typeof blobString !== "string" || !blobString.trim()) return { error: "empty" };
38
+ let json;
39
+ try {
40
+ json = fromBase64Url(blobString.trim());
41
+ } catch {
42
+ return { error: "truncated" };
43
+ }
44
+ let envelope;
45
+ try {
46
+ envelope = JSON.parse(json);
47
+ } catch {
48
+ return { error: "truncated" };
49
+ }
50
+ if (!envelope || typeof envelope !== "object") return { error: "malformed" };
51
+ if (envelope.v !== 1) return { error: "unsupported-version" };
52
+ if (!INVITE_KINDS.has(envelope.kind)) return { error: "malformed" };
53
+ if (typeof envelope.sdp !== "string" || !envelope.sdp) return { error: "malformed" };
54
+ if (envelope.kind === "offer" && (typeof envelope.world !== "string" || !envelope.world)) {
55
+ return { error: "malformed" };
56
+ }
57
+ return { value: envelope };
58
+ }
59
+
60
+ const ROOM_MESSAGE_TYPES = new Set([
61
+ "hello",
62
+ "peer-list",
63
+ "intro-offer",
64
+ "intro-answer",
65
+ "sync-request",
66
+ "sync-response",
67
+ "op",
68
+ ]);
69
+
70
+ export function helloMessage({ peerId, displayName }) {
71
+ return { type: "hello", peerId, displayName };
72
+ }
73
+
74
+ export function peerListMessage({ peers }) {
75
+ return { type: "peer-list", peers };
76
+ }
77
+
78
+ export function introOfferMessage({ from, to, sdp }) {
79
+ return { type: "intro-offer", from, to, sdp };
80
+ }
81
+
82
+ export function introAnswerMessage({ from, to, sdp }) {
83
+ return { type: "intro-answer", from, to, sdp };
84
+ }
85
+
86
+ export function syncRequestMessage() {
87
+ return { type: "sync-request" };
88
+ }
89
+
90
+ export function syncResponseMessage({ facts }) {
91
+ return { type: "sync-response", facts };
92
+ }
93
+
94
+ export function opMessage({ from, facts }) {
95
+ return { type: "op", from, facts };
96
+ }
97
+
98
+ /** Structural validation only — the front door for anything a peer sends
99
+ * over an open channel. Returns true/false rather than throwing, so a room
100
+ * can drop a malformed message from a misbehaving or out-of-date peer
101
+ * instead of crashing on it. */
102
+ export function isValidRoomMessage(msg) {
103
+ if (!msg || typeof msg !== "object" || !ROOM_MESSAGE_TYPES.has(msg.type)) return false;
104
+ switch (msg.type) {
105
+ case "hello":
106
+ return typeof msg.peerId === "string" && msg.peerId.length > 0 && typeof msg.displayName === "string";
107
+ case "peer-list":
108
+ return Array.isArray(msg.peers)
109
+ && msg.peers.every((p) => p && typeof p.peerId === "string" && typeof p.displayName === "string");
110
+ case "intro-offer":
111
+ case "intro-answer":
112
+ return typeof msg.from === "string" && typeof msg.to === "string"
113
+ && typeof msg.sdp === "string" && msg.sdp.length > 0;
114
+ case "sync-request":
115
+ return true;
116
+ case "sync-response":
117
+ return Array.isArray(msg.facts);
118
+ case "op":
119
+ return typeof msg.from === "string" && Array.isArray(msg.facts);
120
+ default:
121
+ return false;
122
+ }
123
+ }
@@ -96,9 +96,17 @@ const FURNITURE_SVG =
96
96
  + '<rect x="4" y="8" width="3" height="12" fill="currentColor"/>'
97
97
  + '<rect x="17" y="8" width="3" height="12" fill="currentColor"/></svg>';
98
98
 
99
+ // A drawstring parcel: the plainest "some small thing you could pick up"
100
+ // shape in the set. The body-plus-shackle outline this replaced drew a
101
+ // padlock, which reads as "restricted" rather than "unidentified" — the
102
+ // wrong thing to say about an object whose class simply has no sprite yet.
103
+ // Matches the silhouette data/sprites/portable-icon.toml already draws, so
104
+ // the same class looks the same whichever tier answers.
99
105
  const PORTABLE_SVG =
100
- '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="9" width="14" height="11" rx="1.5" fill="currentColor"/>'
101
- + '<path d="M9 9 V6.5 A3 3 0 0 1 15 6.5 V9" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>';
106
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 6 C7.5 6 5 9.4 5 13.4 C5 17.7 8.1 20 12 20 '
107
+ + 'C15.9 20 19 17.7 19 13.4 C19 9.4 16.5 6 12 6 Z" fill="currentColor"/>'
108
+ + '<path d="M9.4 6.4 C9.9 4.6 10.8 3.4 12 3.4 C13.2 3.4 14.1 4.6 14.6 6.4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>'
109
+ + '<ellipse cx="9.8" cy="11.6" rx="1.6" ry="2.1" fill="currentColor" opacity="0.28"/></svg>';
102
110
 
103
111
  const PERSON_SVG =
104
112
  '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="6.5" r="3.2" fill="currentColor"/>'