@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,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
|
+
}
|
|
@@ -35,25 +35,49 @@ export function isReferenceIndexEntry(e) {
|
|
|
35
35
|
&& Number.isInteger(e.r) && e.r > 0;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/** A shard row: {term, title, text, summary, url, revid, isa
|
|
38
|
+
/** A shard row: {term, title, text, summary, url, revid, isa?, source?,
|
|
39
|
+
* licence?}. `source`/`licence` are optional per-entry overrides of the
|
|
40
|
+
* citation's source name and licence line (renderReferenceAnswer, below) —
|
|
41
|
+
* absent on every real Simple English Wikipedia row the build pipeline
|
|
42
|
+
* emits, present only on a hand-added synthetic entry (e.g. a demo/test
|
|
43
|
+
* term) whose citation must name its OWN real source, not Wikipedia's. */
|
|
39
44
|
export function isReferenceArticleRow(row) {
|
|
40
45
|
if (!row || typeof row !== "object") return false;
|
|
41
46
|
for (const field of ["term", "title", "text", "summary", "url"]) {
|
|
42
47
|
if (typeof row[field] !== "string" || !row[field]) return false;
|
|
43
48
|
}
|
|
44
49
|
if (!Number.isInteger(row.revid) || row.revid <= 0) return false;
|
|
45
|
-
|
|
50
|
+
for (const field of ["isa", "source", "licence"]) {
|
|
51
|
+
if (row[field] !== undefined && (typeof row[field] !== "string" || !row[field])) return false;
|
|
52
|
+
}
|
|
46
53
|
return true;
|
|
47
54
|
}
|
|
48
55
|
|
|
56
|
+
/** The default citation source/licence — every article the build pipeline
|
|
57
|
+
* (scripts/fetch-reference-pack.mjs) emits from the pinned Simple English
|
|
58
|
+
* Wikipedia dump carries neither field, so this is what renderReferenceAnswer
|
|
59
|
+
* falls back to for the overwhelming majority of rows. */
|
|
60
|
+
export const DEFAULT_REFERENCE_SOURCE = "Simple English Wikipedia";
|
|
61
|
+
export const DEFAULT_REFERENCE_LICENCE = "CC BY-SA 4.0";
|
|
62
|
+
|
|
49
63
|
/** The cited answer for a clean miss the pack could ground: the article's
|
|
50
|
-
* summary with its title, licence and
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
64
|
+
* summary with its title, licence and source always visible, then the grain
|
|
65
|
+
* cue. Without the cue a reader has to recognize the source name to tell
|
|
66
|
+
* this apart from a fact read out of their own graph — both answer the same
|
|
67
|
+
* "what is X" question in the same voice.
|
|
68
|
+
*
|
|
69
|
+
* The source/licence line reads the article's OWN `source`/`licence` fields
|
|
70
|
+
* when present, falling back to the Wikipedia defaults otherwise — every
|
|
71
|
+
* real pack row renders byte-identically to before this existed. Wikipedia's
|
|
72
|
+
* revision-pinned `?oldid=` query is a convention of that source alone, so it
|
|
73
|
+
* is only appended when the article is citing the default source; a
|
|
74
|
+
* non-Wikipedia entry's own `url` is shown bare. */
|
|
54
75
|
export function renderReferenceAnswer(term, article) {
|
|
76
|
+
const source = article.source ?? DEFAULT_REFERENCE_SOURCE;
|
|
77
|
+
const licence = article.licence ?? DEFAULT_REFERENCE_LICENCE;
|
|
78
|
+
const url = source === DEFAULT_REFERENCE_SOURCE ? `${article.url}?oldid=${article.revid}` : article.url;
|
|
55
79
|
return `${term} — ${article.summary} (source: reference article "${article.title}", `
|
|
56
|
-
+
|
|
80
|
+
+ `${source}, ${licence} — ${url})`
|
|
57
81
|
+ ` ${GENERAL_VOCABULARY_CUE}`;
|
|
58
82
|
}
|
|
59
83
|
|
|
@@ -122,7 +122,7 @@ export const SEED_TAXONOMY = Object.freeze([
|
|
|
122
122
|
]);
|
|
123
123
|
|
|
124
124
|
export const WORLD_OPENING =
|
|
125
|
-
"a spider waits in its web; a fly drifts in from the edge of the board. Neither is yours to move
|
|
125
|
+
"a spider waits in its web; a fly drifts in from the edge of the board. Neither is yours to move. Watch, or address one by name in chat.";
|
|
126
126
|
|
|
127
127
|
/** Every fact row the shipped world source carries: cell typing, grid
|
|
128
128
|
* adjacency (mgx:has-exit-<direction>), the web block (mgx:in-web) and the
|
|
@@ -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"/>'
|
|
@@ -21,12 +21,14 @@
|
|
|
21
21
|
// substitution (e.g. `black = "#22201d"`). A value with no entry in that
|
|
22
22
|
// map is not a match for this template at all — it falls through to a
|
|
23
23
|
// less specific one, never a guessed/invented substitution.
|
|
24
|
-
// - a fully-specific hand-authored VARIANT — `
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
24
|
+
// - a fully-specific hand-authored VARIANT — carries the same `classes`
|
|
25
|
+
// as the class it specializes, plus a `[match]` table (`property`,
|
|
26
|
+
// `value`) naming the exact fact it requires, so it outranks the
|
|
27
|
+
// parameterized template when both would otherwise apply. The filename
|
|
28
|
+
// is free-form and reads `{class}-{what it shows}` in practice
|
|
29
|
+
// (`portrait-round.toml` for mgx:hasProperty = round,
|
|
30
|
+
// `bear-facing-left.toml` for mgx:faces = left) — the `[match]` table,
|
|
31
|
+
// never the name, is what selects it.
|
|
30
32
|
//
|
|
31
33
|
// A parameterized template's `[parameters.<name>]` table comes in two
|
|
32
34
|
// shapes, picked by which of `placeholder`/`placeholders` it declares:
|
|
@@ -281,6 +281,14 @@ export function parseWorldEditorText(text, contextRows = []) {
|
|
|
281
281
|
|
|
282
282
|
const tripleKey = (t) => `${t.subject} ${t.predicate} ${t.object}`;
|
|
283
283
|
|
|
284
|
+
// The three flag predicates the renderer only ever writes a line for when they
|
|
285
|
+
// read "true" ("Basket is a container."). A row saying "false" has no sentence
|
|
286
|
+
// in this vocabulary, so it must not be diffable either — counted as editable
|
|
287
|
+
// while being unrenderable, it sits in the diff's current set, matches nothing
|
|
288
|
+
// the text can say, and gets retracted on the next clean parse: a fact the
|
|
289
|
+
// player never saw, silently deleted by an edit somewhere else in the document.
|
|
290
|
+
const TRUE_ONLY_FLAG_PREDICATES = ["mgx:is-container", "mgx:is-npc", "mgx:is-objective"];
|
|
291
|
+
|
|
284
292
|
/** Every raw fact row this editor's "other" family (type/exits/container/
|
|
285
293
|
* puzzle — never placement/openness) is allowed to touch — the closed set
|
|
286
294
|
* planWorldEditorSync diffs against and the only rows a removal can ever
|
|
@@ -290,8 +298,8 @@ export function editableOtherRows(rows) {
|
|
|
290
298
|
if (SNAPSHOT_RE.test(r.subject)) return false;
|
|
291
299
|
if (r.predicate === "rdf:type") return true;
|
|
292
300
|
if (EXIT_PREDICATE_RE.test(r.predicate)) return true;
|
|
293
|
-
|
|
294
|
-
|
|
301
|
+
if (TRUE_ONLY_FLAG_PREDICATES.includes(r.predicate)) return r.object === "true";
|
|
302
|
+
return ["mgx:unlocks-with", "mgx:acts-on-turn", "mgx:acts-toward"].includes(r.predicate);
|
|
295
303
|
});
|
|
296
304
|
}
|
|
297
305
|
|