@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.
- package/corpus/sprites/src/sprite-facts.jsonl +28 -0
- package/corpus/tier2/generate.mjs +10 -1
- package/corpus/tier2/human.jsonl +23 -0
- package/corpus/tier2/manifest.json +3 -3
- package/corpus/worlds/index.json.gz +0 -0
- package/corpus/worlds/manifest.json +15 -5
- package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
- package/corpus/worlds/src/mud-garden.jsonl +101 -0
- package/package.json +2 -1
- package/src/adapters/p2p/webrtc-transport.mjs +146 -0
- package/src/domain/game-config.mjs +67 -0
- package/src/domain/grammar/ace.mjs +11 -3
- package/src/domain/grammar/lexicon-core.json +3 -0
- package/src/domain/grammar/lexicon.mjs +13 -0
- package/src/domain/memory/trust.mjs +15 -0
- 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/sprite-map.mjs +10 -2
- package/src/services/adventure-editor.mjs +10 -2
- package/src/services/adventure-viz.mjs +192 -39
- package/src/services/adventure.mjs +989 -69
- package/src/services/chat-page-viz.mjs +1060 -7
- package/src/services/chat-session.mjs +28 -3
- package/src/services/chat.mjs +2 -2
- package/src/services/mud-editor.mjs +313 -0
- package/src/services/mud-turn.mjs +572 -0
- package/src/services/mud-viz.mjs +2055 -0
- package/src/services/p2p-room.mjs +559 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +77 -77
- package/src/surfaces/web/mud-browser-entry.mjs +330 -0
- 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) {
|
|
@@ -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"><
|
|
101
|
-
+ '
|
|
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"/>'
|