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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +176 -8
  2. package/corpus/reference/index.json.gz +0 -0
  3. package/corpus/reference/manifest.json +8 -8
  4. package/corpus/reference/shards/ref-00.jsonl.gz +0 -0
  5. package/corpus/sprites/src/sprite-facts.jsonl +52 -0
  6. package/corpus/worlds/index.json.gz +0 -0
  7. package/corpus/worlds/manifest.json +53 -13
  8. package/corpus/worlds/shards/greyvale-museum.jsonl.gz +0 -0
  9. package/corpus/worlds/shards/lantern-cottage.jsonl.gz +0 -0
  10. package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
  11. package/corpus/worlds/shards/mud-hollow.jsonl.gz +0 -0
  12. package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
  13. package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
  14. package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
  15. package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
  16. package/corpus/worlds/src/mud-garden.jsonl +34 -1
  17. package/corpus/worlds/src/mud-hollow.jsonl +82 -0
  18. package/corpus/worlds/src/mud-warren.jsonl +124 -0
  19. package/corpus/worlds/src/spider-fly.jsonl +1 -1
  20. package/data/sprites/animal-icon.toml +11 -5
  21. package/data/sprites/book-icon.toml +9 -5
  22. package/data/sprites/cabinet-icon.toml +10 -5
  23. package/data/sprites/cellar-icon.toml +16 -6
  24. package/data/sprites/container-icon.toml +7 -3
  25. package/data/sprites/desk-icon.toml +8 -5
  26. package/data/sprites/dog-icon.toml +8 -5
  27. package/data/sprites/dog-with-colour-icon.toml +13 -12
  28. package/data/sprites/drawing-room-icon.toml +14 -5
  29. package/data/sprites/egg-icon.toml +6 -3
  30. package/data/sprites/fly-icon.toml +10 -5
  31. package/data/sprites/furniture-icon.toml +5 -3
  32. package/data/sprites/garden-icon.toml +10 -3
  33. package/data/sprites/key-icon.toml +3 -1
  34. package/data/sprites/kitchen-icon.toml +15 -6
  35. package/data/sprites/lamp-icon.toml +9 -4
  36. package/data/sprites/letter-icon.toml +7 -4
  37. package/data/sprites/library-icon.toml +14 -3
  38. package/data/sprites/pan-icon.toml +7 -3
  39. package/data/sprites/person-icon.toml +6 -2
  40. package/data/sprites/poodle-icon.toml +1 -1
  41. package/data/sprites/portable-icon.toml +6 -4
  42. package/data/sprites/portrait-icon.toml +7 -4
  43. package/data/sprites/room-icon.toml +11 -2
  44. package/data/sprites/spider-icon.toml +9 -3
  45. package/data/sprites/study-icon.toml +10 -2
  46. package/package.json +5 -4
  47. package/src/adapters/p2p/webrtc-transport.mjs +146 -0
  48. package/src/domain/game-config.mjs +26 -5
  49. package/src/domain/grammar/ace.mjs +40 -4
  50. package/src/domain/grammar/lexicon-core.json +2 -1
  51. package/src/domain/grammar/lexicon.mjs +31 -0
  52. package/src/domain/memory/trust.mjs +6 -4
  53. package/src/domain/p2p/facts.mjs +81 -0
  54. package/src/domain/p2p/peer-id.mjs +32 -0
  55. package/src/domain/p2p/provenance-relabel.mjs +26 -0
  56. package/src/domain/p2p/sync-filter.mjs +31 -0
  57. package/src/domain/p2p/wire.mjs +123 -0
  58. package/src/domain/reference-pack.mjs +31 -7
  59. package/src/domain/spider-fly-world.mjs +1 -1
  60. package/src/domain/sprite-map.mjs +10 -2
  61. package/src/domain/sprite-templates.mjs +8 -6
  62. package/src/services/adventure-editor.mjs +10 -2
  63. package/src/services/adventure-viz.mjs +309 -73
  64. package/src/services/adventure.mjs +1034 -330
  65. package/src/services/chat-page-viz.mjs +1097 -16
  66. package/src/services/chat.mjs +99 -18
  67. package/src/services/code-explorer-viz.mjs +52 -14
  68. package/src/services/extract-facts.mjs +4 -7
  69. package/src/services/ingest-viz.mjs +54 -26
  70. package/src/services/ledger-viz.mjs +105 -56
  71. package/src/services/memory-panel-viz.mjs +24 -0
  72. package/src/services/mud-editor.mjs +313 -0
  73. package/src/services/mud-turn.mjs +264 -87
  74. package/src/services/mud-viz.mjs +2606 -384
  75. package/src/services/p2p-room.mjs +559 -0
  76. package/src/services/plan-viz.mjs +119 -61
  77. package/src/services/research-viz.mjs +151 -57
  78. package/src/services/spider-fly-turn.mjs +1 -1
  79. package/src/services/spider-fly-viz.mjs +67 -43
  80. package/src/services/sprite-catalog-viz.mjs +175 -51
  81. package/src/services/viz-theme.mjs +15 -0
  82. package/src/surfaces/web/memory-ask-browser.bundle.js +109 -109
  83. package/src/surfaces/web/mud-browser-entry.mjs +226 -18
  84. package/src/surfaces/web/p2p-browser-entry.mjs +39 -0
@@ -0,0 +1,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
- if (row.isa !== undefined && (typeof row.isa !== "string" || !row.isa)) return false;
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 revision-pinned URL always visible,
51
- * then the grain cue. Without the cue a reader has to recognize the source
52
- * name to tell this apart from a fact read out of their own graph — both
53
- * answer the same "what is X" question in the same voice. */
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
- + `Simple English Wikipedia, CC BY-SA 4.0 — ${article.url}?oldid=${article.revid})`
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 — watch, or address one by name in chat.";
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"><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"/>'
@@ -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 — `{class}-with-{property}-
25
- // {value}.toml` (e.g. a hypothetical `dog-with-colour-black.toml`, not
26
- // authored this pass) carries the same `classes` as the class it
27
- // specializes, plus a `[match]` table (`property`, `value`) naming the
28
- // exact fact it requires, so it outranks the parameterized template
29
- // when both would otherwise apply.
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
- return ["mgx:is-container", "mgx:is-npc", "mgx:is-objective", "mgx:unlocks-with", "mgx:acts-on-turn", "mgx:acts-toward"]
294
- .includes(r.predicate);
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