@polycode-projects/the-mechanical-code-talker 6.0.18 → 6.0.20

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 (76) hide show
  1. package/README.md +20 -23
  2. package/bin/tmct.mjs +16 -33
  3. package/corpus/LICENSES.json +0 -21
  4. package/corpus/README.md +10 -13
  5. package/corpus/reference/manifest.json +19 -19
  6. package/corpus/reference/shards/ref-01.jsonl.gz +0 -0
  7. package/corpus/reference/shards/ref-04.jsonl.gz +0 -0
  8. package/corpus/reference/shards/ref-08.jsonl.gz +0 -0
  9. package/corpus/reference/shards/ref-10.jsonl.gz +0 -0
  10. package/corpus/reference/shards/ref-11.jsonl.gz +0 -0
  11. package/corpus/reference/shards/ref-17.jsonl.gz +0 -0
  12. package/corpus/reference/shards/ref-20.jsonl.gz +0 -0
  13. package/corpus/reference/shards/ref-25.jsonl.gz +0 -0
  14. package/corpus/reference/shards/ref-2c.jsonl.gz +0 -0
  15. package/corpus/tier2/generate.mjs +6 -142
  16. package/corpus/tier2/manifest.json +0 -42
  17. package/package.json +6 -4
  18. package/src/adapters/corpus/child-seed.mjs +74 -0
  19. package/src/adapters/corpus/conceptnet.mjs +45 -26
  20. package/src/adapters/corpus/research-source.mjs +6 -2
  21. package/src/adapters/corpus/wikidata-live.mjs +92 -51
  22. package/src/adapters/memory/blocks.mjs +7 -1
  23. package/src/adapters/memory/core.mjs +505 -107
  24. package/src/adapters/memory/corpus-bands.mjs +27 -10
  25. package/src/adapters/memory/inspect.mjs +24 -5
  26. package/src/adapters/memory/rows.mjs +359 -30
  27. package/src/adapters/memory/shacl.mjs +10 -3
  28. package/src/domain/ask.mjs +27 -10
  29. package/src/domain/cli-verbs.mjs +3 -4
  30. package/src/domain/completions/group.mjs +8 -3
  31. package/src/domain/completions/infer.mjs +7 -2
  32. package/src/domain/completions/prune.mjs +5 -1
  33. package/src/domain/completions/rank.mjs +7 -2
  34. package/src/domain/digest/compose.mjs +5 -1
  35. package/src/domain/digest/select.mjs +12 -6
  36. package/src/domain/domain.mjs +15 -8
  37. package/src/domain/el-classify.mjs +11 -2
  38. package/src/domain/fact-phrase.mjs +86 -4
  39. package/src/domain/hash.mjs +9 -0
  40. package/src/domain/memory/bias.mjs +8 -4
  41. package/src/domain/memory/capability.mjs +12 -6
  42. package/src/domain/memory/fact-order.mjs +29 -0
  43. package/src/domain/memory/resolution.mjs +3 -0
  44. package/src/domain/news-feed.mjs +862 -92
  45. package/src/domain/reference-pack.mjs +5 -0
  46. package/src/domain/sense-gate.mjs +220 -0
  47. package/src/domain/sense-scope.mjs +116 -0
  48. package/src/domain/sense-split.mjs +1 -1
  49. package/src/domain/syllogise.mjs +60 -21
  50. package/src/domain/tableau.mjs +23 -14
  51. package/src/domain/term-ledger.mjs +16 -1
  52. package/src/domain/worlds-pack.mjs +5 -1
  53. package/src/services/adventure-autoplay.mjs +6 -1
  54. package/src/services/adventure-editor.mjs +43 -21
  55. package/src/services/adventure-viz.mjs +26 -9
  56. package/src/services/adventure.mjs +40 -10
  57. package/src/services/chat.mjs +270 -125
  58. package/src/services/extensions.mjs +51 -58
  59. package/src/services/extract-facts.mjs +906 -66
  60. package/src/services/init.mjs +4 -4
  61. package/src/services/ledger-viz.mjs +9 -4
  62. package/src/services/memory-panel-viz.mjs +4 -5
  63. package/src/services/mud-editor.mjs +40 -16
  64. package/src/services/mud-viz.mjs +8 -2
  65. package/src/services/mudiii-turn.mjs +5 -3
  66. package/src/services/mudiii-viz.mjs +8 -2
  67. package/src/services/news.mjs +306 -21
  68. package/src/services/research-viz.mjs +1 -1
  69. package/src/services/sprite-catalog-viz.mjs +10 -5
  70. package/src/surfaces/web/adventure-browser-entry.mjs +6 -12
  71. package/src/surfaces/web/memory-ask-browser.bundle.js +152 -151
  72. package/src/surfaces/web/mud-browser-entry.mjs +7 -11
  73. package/src/surfaces/web/research-browser-entry.mjs +5 -2
  74. package/corpus/tier2/aws.jsonl +0 -39
  75. package/corpus/tier2/java.jsonl +0 -31
  76. package/corpus/tier2/python.jsonl +0 -30
@@ -40,6 +40,15 @@
40
40
 
41
41
  import { normFactTerm } from "./hash.mjs";
42
42
 
43
+ /** Codepoint order, never localeCompare. Every string sorted in this file is
44
+ * built from stored fact terms, and the sorts decide the ORDER RULES FIRE:
45
+ * which disjunct the or-rule branches on first, which axioms internalize
46
+ * first, which role assertions survive MAX_ROLE_ASSERTIONS. Under the step,
47
+ * branch and node ceilings a different firing order is a different verdict,
48
+ * so a locale-sensitive compare would let two machines read one KB and
49
+ * disagree about whether it is satisfiable. */
50
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
51
+
43
52
  // ---- concept-expression AST --------------------------------------------
44
53
 
45
54
  const atom = (name) => ({ t: "atom", name });
@@ -86,7 +95,7 @@ function pushNegation(expr, negate) {
86
95
  case "or": {
87
96
  const flip = expr.t === "and" ? "or" : "and";
88
97
  const cs = expr.cs.map((c) => pushNegation(c, negate));
89
- cs.sort((a, b) => canonicalKey(a).localeCompare(canonicalKey(b)));
98
+ cs.sort((a, b) => byCodepoint(canonicalKey(a), canonicalKey(b)));
90
99
  return { t: negate ? flip : expr.t, cs };
91
100
  }
92
101
  case "some":
@@ -428,7 +437,7 @@ function addLabel(node, expr, from) {
428
437
  }
429
438
 
430
439
  function sortedLabelEntries(node) {
431
- return [...node.labels.entries()].sort((a, b) => a[0].localeCompare(b[0]));
440
+ return [...node.labels.entries()].sort((a, b) => byCodepoint(a[0], b[0]));
432
441
  }
433
442
 
434
443
  /** A node clashes when its label set holds bottom, or holds both an
@@ -649,7 +658,7 @@ function applyOrRule(branch, kb) {
649
658
  for (const [key, { expr, from }] of sortedLabelEntries(node)) {
650
659
  if (expr.t !== "or") continue;
651
660
  if (node.branchedOn.has(key)) continue;
652
- const cs = [...expr.cs].sort((a, b) => canonicalKey(a).localeCompare(canonicalKey(b)));
661
+ const cs = [...expr.cs].sort((a, b) => byCodepoint(canonicalKey(a), canonicalKey(b)));
653
662
  if (cs.some((c) => node.labels.has(canonicalKey(c)))) {
654
663
  node.branchedOn.add(key);
655
664
  continue;
@@ -840,7 +849,7 @@ function serializeBranch(branch) {
840
849
  .map((node) => ({
841
850
  id: node.id,
842
851
  parent: node.parent,
843
- labels: [...node.labels.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([, { expr, from }]) => ({ expr, from })),
852
+ labels: [...node.labels.entries()].sort((a, b) => byCodepoint(a[0], b[0])).map(([, { expr, from }]) => ({ expr, from })),
844
853
  }));
845
854
  const edges = branch.edges.map(({ from, r, to, fromFacts }) => ({ from, r, to, fromFacts: fromFacts.slice() }));
846
855
  return { nodes, edges };
@@ -1147,7 +1156,7 @@ export function buildTableauKb(rows) {
1147
1156
  // truncation is deterministic rather than arrival-ordered.
1148
1157
  const roleAssertions = roleAssertionCandidates
1149
1158
  .filter((r) => isIndividualTerm(r.subject) && isIndividualTerm(r.object))
1150
- .sort((a, b) => a.subject.localeCompare(b.subject) || a.predicate.localeCompare(b.predicate) || a.object.localeCompare(b.object))
1159
+ .sort((a, b) => byCodepoint(a.subject, b.subject) || byCodepoint(a.predicate, b.predicate) || byCodepoint(a.object, b.object))
1151
1160
  .slice(0, MAX_ROLE_ASSERTIONS)
1152
1161
  .map((r) => ({ a: r.subject, r: r.predicate, b: r.object, from: [r.id] }));
1153
1162
 
@@ -1203,7 +1212,7 @@ export function buildTableauKb(rows) {
1203
1212
  for (const r of negTypeRows) assertions.push({ ind: r.subject, expr: toNNF(notE(atom(r.object))), from: [r.id] });
1204
1213
 
1205
1214
  for (const [unionId, memberRows] of unionMembersOf) {
1206
- const sorted = [...memberRows].sort((a, b) => String(a.object).localeCompare(String(b.object)));
1215
+ const sorted = [...memberRows].sort((a, b) => byCodepoint(String(a.object), String(b.object)));
1207
1216
  const cs = sorted.map((mr) => atom(mr.object));
1208
1217
  const ids = sorted.map((mr) => mr.id);
1209
1218
  const orExpr = toNNF(orE(cs));
@@ -1225,7 +1234,7 @@ export function buildTableauKb(rows) {
1225
1234
  // merge rule has a real carrier of the nominal to merge an outsider into.
1226
1235
  const nominalIndividuals = new Map(); // ind -> fact ids that declared it
1227
1236
  for (const [classId, memberRows] of oneOfMembersOf) {
1228
- const sorted = [...memberRows].sort((a, b) => String(a.object).localeCompare(String(b.object)));
1237
+ const sorted = [...memberRows].sort((a, b) => byCodepoint(String(a.object), String(b.object)));
1229
1238
  const nomExprs = sorted.map((mr) => ({ t: "nom", ind: mr.object }));
1230
1239
  const ids = sorted.map((mr) => mr.id);
1231
1240
  axioms.push(mkAxiom(atom(classId), toNNF(orE(nomExprs)), ids));
@@ -1238,7 +1247,7 @@ export function buildTableauKb(rows) {
1238
1247
 
1239
1248
  const differentFrom = differentFromRows
1240
1249
  .map((r) => ({ a: r.subject, b: r.object, from: [r.id] }))
1241
- .sort((a, b) => a.a.localeCompare(b.a) || a.b.localeCompare(b.b));
1250
+ .sort((a, b) => byCodepoint(a.a, b.a) || byCodepoint(a.b, b.b));
1242
1251
 
1243
1252
  // owl:inverseOf is stored symmetrically by the grammar (both directions
1244
1253
  // minted), but this reads either direction alone too, defensively.
@@ -1254,15 +1263,15 @@ export function buildTableauKb(rows) {
1254
1263
 
1255
1264
  const subPropertyEdges = subPropertyRows
1256
1265
  .map((r) => [r.subject, r.object])
1257
- .sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]));
1266
+ .sort((a, b) => byCodepoint(a[0], b[0]) || byCodepoint(a[1], b[1]));
1258
1267
  const roleNames = new Set([...roles, ...subPropertyEdges.flat(), ...inverseOf.keys(), ...inverseOf.values()]);
1259
1268
  const roleClosure = buildRoleClosure(subPropertyEdges, roleNames);
1260
1269
 
1261
1270
  axioms.sort((a, b) =>
1262
- canonicalKey(a.sub).localeCompare(canonicalKey(b.sub)) ||
1263
- canonicalKey(a.sup).localeCompare(canonicalKey(b.sup)) ||
1264
- a.from.join(",").localeCompare(b.from.join(",")));
1265
- assertions.sort((a, b) => a.ind.localeCompare(b.ind) || canonicalKey(a.expr).localeCompare(canonicalKey(b.expr)));
1271
+ byCodepoint(canonicalKey(a.sub), canonicalKey(b.sub)) ||
1272
+ byCodepoint(canonicalKey(a.sup), canonicalKey(b.sup)) ||
1273
+ byCodepoint(a.from.join(","), b.from.join(",")));
1274
+ assertions.sort((a, b) => byCodepoint(a.ind, b.ind) || byCodepoint(canonicalKey(a.expr), canonicalKey(b.expr)));
1266
1275
 
1267
1276
  const individuals = [...new Set([
1268
1277
  ...assertions.map((a) => a.ind),
@@ -1318,7 +1327,7 @@ export function findTableauViolations(kb, subjects = null, opts = {}) {
1318
1327
  const premises = sortedUnique(result.closedClashes.flatMap((c) => c?.premises || []));
1319
1328
  violations.push({ subject, premises, kind: describeClashKind(result.closedClashes[0]) });
1320
1329
  }
1321
- violations.sort((a, b) => a.subject.localeCompare(b.subject));
1330
+ violations.sort((a, b) => byCodepoint(a.subject, b.subject));
1322
1331
  return violations;
1323
1332
  }
1324
1333
 
@@ -65,8 +65,23 @@ const NOISE_TERM_SETS = [
65
65
  FOREIGN_PARTICLE_TERMS,
66
66
  ];
67
67
 
68
+ /** A version or model string shatters into pieces that are mostly digits and
69
+ * punctuation — "Qwen3.8-2.4T" leaves "8-2" and "4t" behind. A term with no
70
+ * letter at all, or one carrying a digit whose digits and punctuation match or
71
+ * outnumber its letters, states a quantity or a version; it never names a
72
+ * subject a reference lookup could define. A letter-only term with interior
73
+ * stops ("u.s.") carries no digit, so it stays. */
74
+ function isShapeNoiseTerm(term) {
75
+ const letters = (term.match(/[a-z]/g) || []).length;
76
+ if (!letters) return true;
77
+ const digits = (term.match(/\d/g) || []).length;
78
+ if (!digits) return false;
79
+ const punctuation = (term.match(/[^a-z0-9\s]/g) || []).length;
80
+ return digits + punctuation >= letters;
81
+ }
82
+
68
83
  function isNoiseTerm(term) {
69
- return NOISE_TERM_SETS.some((set) => set.has(term));
84
+ return NOISE_TERM_SETS.some((set) => set.has(term)) || isShapeNoiseTerm(term);
70
85
  }
71
86
 
72
87
  /** Ledger entry field order fixed once here so `ledgerPayload` serializes
@@ -72,6 +72,10 @@ export function worldProvenanceTag(worldName) {
72
72
 
73
73
  const DEFAULT_CONTAINS_PREDICATE = "mgx:default-contains";
74
74
 
75
+ // Codepoint order, never localeCompare — two readers loading the same world
76
+ // facts on different locales must mint the same instance ids.
77
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
78
+
75
79
  /** Materialize a world's class-default contents: for every
76
80
  * `<room> mgx:default-contains <class>` fact, mint one placed, portable
77
81
  * instance of that class in that room unless the room already holds one. A
@@ -87,7 +91,7 @@ export function expandWorldDefaultContents(facts) {
87
91
  const rows = facts || [];
88
92
  const defaults = rows
89
93
  .filter((r) => r.predicate === DEFAULT_CONTAINS_PREDICATE)
90
- .sort((a, b) => `${a.subject}\0${a.object}`.localeCompare(`${b.subject}\0${b.object}`));
94
+ .sort((a, b) => byCodepoint(`${a.subject}\0${a.object}`, `${b.subject}\0${b.object}`));
91
95
  if (!defaults.length) return rows;
92
96
 
93
97
  const instanceIds = new Set(rows.filter((r) => r.predicate === "rdf:type").map((r) => r.subject));
@@ -33,6 +33,11 @@ import { foldWorldState, adventureTurn, worldActionRows } from "./adventure.mjs"
33
33
  const isTypedRow = (rows, subject, type) =>
34
34
  (rows || []).some((r) => r.subject === subject && r.predicate === "rdf:type" && r.object === type);
35
35
 
36
+ // Codepoint order, never localeCompare — an exit direction traces back to a
37
+ // world fact, and two readers must land on the same explore order regardless
38
+ // of locale.
39
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
40
+
36
41
  /** The room a subject's CURRENT placement resolves into — a room resolves to
37
42
  * itself; an object placed directly in a room resolves to that room; an
38
43
  * object one containment hop inside an OPEN container resolves to the
@@ -294,7 +299,7 @@ export async function runAdventureAutoplayTick(memoryDir, opts = {}) {
294
299
  // isn't known yet. Prefer an immediate unexposed exit from here (the
295
300
  // lowest-sorted direction); otherwise path toward the nearest exposed room
296
301
  // that still has one.
297
- const unexposedHere = unexposedExitsOf(here, exposedState, exposed).sort(([a], [b]) => a.localeCompare(b));
302
+ const unexposedHere = unexposedExitsOf(here, exposedState, exposed).sort(([a], [b]) => byCodepoint(a, b));
298
303
  if (unexposedHere.length) {
299
304
  const [direction, target] = unexposedHere[0];
300
305
  await runCommand(`go ${direction}`);
@@ -14,24 +14,29 @@
14
14
  // mgx:acts-toward, mgx:is-objective) — an editor has to show and change
15
15
  // exactly the facts a player is never told.
16
16
  //
17
- // No imports of its own logic: every export here is .toString()-splice-safe,
18
- // the same discipline adventure-viz.mjs's own render-glue functions hold (see
19
- // that module's header) this module's functions get spliced directly into
20
- // the adventure page's inline script the same way. The one exception,
21
- // wordBeforeCursor, re-exports viz-theme.mjs's own shared copy (byte-identical
17
+ // The two exports the adventure page splices into its inline script through
18
+ // `.toString()` renderWorldEditorText and wordBeforeCursor are entirely
19
+ // self-contained, the same discipline adventure-viz.mjs's own render-glue
20
+ // functions hold (see that module's header): a splice captures a function's
21
+ // own source text and none of its closure over sibling module bindings.
22
+ // wordBeforeCursor re-exports viz-theme.mjs's own shared copy (byte-identical
22
23
  // to what used to live here, and to mud-editor.mjs's own copy) rather than
23
24
  // keep a third copy of the same regex.
24
25
  //
25
26
  // Two predicate families get different sync strategies, on purpose:
26
27
  // - PLACEMENT/OPENNESS (mgx:currently-in/located-in/fixed-in/stands-
27
28
  // locked-in/hidden-in, mgx:is-open) are fold-versioned: foldWorldState
28
- // already treats the newest write as the current truth (adventure.mjs's
29
- // own "turn >= prior.turn" rule the same mechanism every in-game
30
- // action's commit() already writes through). Editing one of these facts
31
- // is handled as a plain new write superseding the old one, never a
29
+ // ranks these rows by the (epoch, turn) pair stamped on the subject, and
30
+ // reads the highest-ranked one as the current truth. Editing one of these
31
+ // facts is handled as a plain new write superseding the old one, never a
32
32
  // retraction, so planWorldEditorSync can never touch memory/core.mjs's
33
33
  // own removeFacts for this family, and can never race the fold logic the
34
- // rest of the engine depends on. One consequence, stated plainly: this
34
+ // rest of the engine depends on. The superseding write is stamped one
35
+ // turn past the world's own turn count, exactly the way every in-game
36
+ // action's commit() writes, so it OUTRANKS what it replaces instead of
37
+ // merely arriving after it — at equal turn the fold takes whichever row
38
+ // comes later in the array, and array order is nobody's to promise once
39
+ // a peer's facts have merged in. One consequence, stated plainly: this
35
40
  // editor can move an object or reopen/close a container, but it cannot
36
41
  // make a placed object vanish outright — the append-only truth model has
37
42
  // no way to write "nowhere" — a scope choice, not a defect.
@@ -47,6 +52,8 @@
47
52
  // document parses cleanly again. Additions are never gated this way —
48
53
  // they are non-destructive by construction.
49
54
 
55
+ import { snapshotSubject } from "../domain/world-snapshot.mjs";
56
+
50
57
  const PLACEMENT_KIND = "placement";
51
58
  const OPENNESS_KIND = "openness";
52
59
  const OTHER_KIND = "other";
@@ -308,20 +315,29 @@ export function editableOtherRows(rows) {
308
315
 
309
316
  /** Plan the fact-store writes one parsed edit implies, from already-parsed
310
317
  * `triples` (parseWorldEditorText's own output) against the world's current
311
- * `rows`/`state`. Returns `{ toAppend, toRemoveIds }` — pure, no I/O.
318
+ * `rows`/`state`. Returns `{ toAppend, toRemoveIds, editTurn }` — pure, no
319
+ * I/O. `toAppend` rows are store-ready: a fold-versioned one already carries
320
+ * its `subject@turnN` stamp, and `editTurn` is the turn every one of them
321
+ * was stamped at, for the caller's own provenance tag.
312
322
  *
313
323
  * Placement/openness triples are NEVER retracted (see this module's own
314
324
  * header): a triple only joins `toAppend` when it actually differs from the
315
325
  * subject's current folded value (or the subject has none yet) — otherwise
316
326
  * re-asserting an unchanged line would append a no-op duplicate on every
317
- * keystroke.
327
+ * keystroke. Each one is stamped one turn past the world's own turn count,
328
+ * which is what makes it outrank the placement it supersedes rather than
329
+ * tie with it and win on array position.
318
330
  *
319
- * "Other"-family triples (type/exits/container/puzzle) get a real add/
320
- * remove diff against `editableOtherRows(rows)` but the CALLER decides
321
- * whether `toRemoveIds` is safe to apply (skip it whenever
322
- * parseWorldEditorText reported any unrecognized line see this module's
323
- * header for why). */
331
+ * "Other"-family triples (type/exits/container/puzzle) keep their bare
332
+ * subject every reader takes those raw, and a stamped one names a subject
333
+ * no verb resolves. They get a real add/remove diff against
334
+ * `editableOtherRows(rows)` but the CALLER decides whether `toRemoveIds`
335
+ * is safe to apply (skip it whenever parseWorldEditorText reported any
336
+ * unrecognized line — see this module's header for why). */
324
337
  export function planWorldEditorSync(rows, state, triples) {
338
+ const editTurn = (state?.turnCount ?? 0) + 1;
339
+ const editEpoch = state?.epoch ?? 0;
340
+ const stampedForFold = (t) => ({ ...t, subject: snapshotSubject(t.subject, editTurn, editEpoch) });
325
341
  const toAppend = [];
326
342
  const seenPlacementSubjects = new Set();
327
343
  const otherTriples = [];
@@ -334,11 +350,11 @@ export function planWorldEditorSync(rows, state, triples) {
334
350
  seenPlacementSubjects.add(t.subject);
335
351
  if (t.kind === PLACEMENT_KIND) {
336
352
  const current = state.placements?.get(t.subject);
337
- if (!current || current.predicate !== t.predicate || current.object !== t.object) toAppend.push(t);
353
+ if (!current || current.predicate !== t.predicate || current.object !== t.object) toAppend.push(stampedForFold(t));
338
354
  } else if (t.kind === OPENNESS_KIND) {
339
355
  const current = state.openness?.get(t.subject);
340
356
  const wantOpen = t.object === "true";
341
- if (!current || current.open !== wantOpen) toAppend.push(t);
357
+ if (!current || current.open !== wantOpen) toAppend.push(stampedForFold(t));
342
358
  }
343
359
  }
344
360
 
@@ -355,7 +371,7 @@ export function planWorldEditorSync(rows, state, triples) {
355
371
  for (const [key, id] of currentKeys) {
356
372
  if (!newOtherKeys.has(key)) toRemoveIds.push(id);
357
373
  }
358
- return { toAppend, toRemoveIds };
374
+ return { toAppend, toRemoveIds, editTurn };
359
375
  }
360
376
 
361
377
  /** The additive half of planWorldEditorSync, for ONE already-parsed triple:
@@ -364,7 +380,13 @@ export function planWorldEditorSync(rows, state, triples) {
364
380
  * one sentence only ever says what it says, so nothing it leaves out is
365
381
  * evidence of anything. Re-asserting a fact the world already holds appends
366
382
  * nothing — `reason` says which of the two happened, in the caller's own
367
- * words. Pure. */
383
+ * words. Pure.
384
+ *
385
+ * The triple comes back with its bare subject, unlike planWorldEditorSync's
386
+ * rows: a taught sentence can MINT the thing it talks about, and the caller
387
+ * picks the fresh id, so only the caller knows which subject the fold stamp
388
+ * belongs on. world-teach.mjs stamps a fold-versioned one at
389
+ * `turnCount + 1` there, for the same reason planWorldEditorSync does. */
368
390
  export function planTaughtTriple(rows, state, triple) {
369
391
  if (!triple?.subject || !triple?.object) return { toAppend: [], reason: "nothing parsed" };
370
392
  if (triple.kind === PLACEMENT_KIND) {
@@ -114,16 +114,26 @@ const TICK_WAIT_MS = 900;
114
114
  * (a distinct icon from plain furniture, since Ashcombe's own cabinet and
115
115
  * portrait are typed "furniture" but read more clearly as a container on
116
116
  * screen), else its own rdf:type object, else the generic "portable"
117
- * fallback for anything a world places with no type fact at all. Pure,
118
- * self-contained no reference to adventure.mjs's own private isContainer/
119
- * isTyped, since those aren't exported. */
117
+ * fallback for anything a world places with no type fact at all. A room
118
+ * always wins when it's one of several rdf:type facts on one subject: every
119
+ * shipped world's outdoor/underground rooms carry `rdf:type room` PLUS a
120
+ * second `rdf:type outdoor-space`/`underground-space` fact purely as a
121
+ * border-paint marker for roomKindForRoom below, and that marker has no
122
+ * sprite of its own, so picking it as the class would fall through to the
123
+ * generic "portable" icon for something that is actually a room. Preferring
124
+ * "room" keeps the answer the same regardless of which of the two facts a
125
+ * store happens to list first. Pure, self-contained — no reference to
126
+ * adventure.mjs's own private isContainer/isTyped, since those aren't
127
+ * exported. */
120
128
  export function spriteClassForObject(rows, subject) {
121
129
  const isContainer = (rows || []).some(
122
130
  (r) => r.subject === subject && r.predicate === "mgx:is-container" && r.object === "true",
123
131
  );
124
132
  if (isContainer) return "container";
125
- const typeRow = (rows || []).find((r) => r.subject === subject && r.predicate === "rdf:type");
126
- return typeRow ? typeRow.object : "portable";
133
+ const typeRows = (rows || []).filter((r) => r.subject === subject && r.predicate === "rdf:type");
134
+ if (!typeRows.length) return "portable";
135
+ const room = typeRows.find((r) => r.object === "room");
136
+ return (room || typeRows[0]).object;
127
137
  }
128
138
 
129
139
  /** `rows` plus synthetic rdfs:subClassOf edges covering `subject`'s whole
@@ -351,9 +361,13 @@ export function roomSceneLayout(rows, state, here) {
351
361
  bases.push(o.subject);
352
362
  }
353
363
 
354
- wall.sort((a, b) => a.subject.localeCompare(b.subject));
355
- bases.sort((a, b) => a.localeCompare(b));
356
- for (const list of stackedOnBy.values()) list.sort((a, b) => a.localeCompare(b));
364
+ // Codepoint order, never localeCompare — inlined, not a module-level
365
+ // helper, because this function is spliced verbatim into the page's own
366
+ // inline script and can carry no outer-scope reference with it.
367
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
368
+ wall.sort((a, b) => byCodepoint(a.subject, b.subject));
369
+ bases.sort(byCodepoint);
370
+ for (const list of stackedOnBy.values()) list.sort(byCodepoint);
357
371
 
358
372
  const visitedForStack = new Set();
359
373
  function stackFrom(base) {
@@ -390,7 +404,10 @@ export function carriedItems(rows, state, holder = "player") {
390
404
  return [...state.placements]
391
405
  .filter(([, p]) => p.predicate === "mgx:located-in" && p.object === holder)
392
406
  .map(([subject]) => ({ subject, spriteClass: spriteClassForObject(rows, subject) }))
393
- .sort((a, b) => a.subject.localeCompare(b.subject));
407
+ // Codepoint order, never localeCompare — inlined, not a module-level
408
+ // helper, because this function is spliced verbatim into the page's own
409
+ // inline script and can carry no outer-scope reference with it.
410
+ .sort((a, b) => (a.subject < b.subject ? -1 : a.subject > b.subject ? 1 : 0));
394
411
  }
395
412
 
396
413
  /** A visited-rooms-only map: one node per room `visitedRoomIds` actually
@@ -761,18 +761,26 @@ function rulingTestimonyClaim(provenance, knower, currentEpoch = 0) {
761
761
  }
762
762
 
763
763
  /** What `person` knows about NOW: the object of every knows-about edge whose
764
- * ruling claim still stands, in the order the edges were first written.
765
- * Nothing is deleted — an edge whose newest claim says the thing is gone just
766
- * stops reading back. `currentEpoch` is the run the reader is on, so a claim a
767
- * recast has left behind cannot decide what a character knows today. */
764
+ * ruling claim still stands, oldest learned first. Nothing is deleted an edge
765
+ * whose newest claim says the thing is gone just stops reading back.
766
+ * `currentEpoch` is the run the reader is on, so a claim a recast has left
767
+ * behind cannot decide what a character knows today.
768
+ *
769
+ * The order comes off each edge's own ruling claim rather than off the row
770
+ * array, so it says what it means: a character lists what it learned in the
771
+ * order it learned it, and two peers holding one fact set read the same list.
772
+ * Two topics learned on one turn fall back to the topic word. */
768
773
  function currentKnowsAboutTopics(rows, person, currentEpoch = 0) {
769
- const topics = [];
774
+ const known = [];
770
775
  for (const row of rows || []) {
771
776
  if (row.subject !== person || row.predicate !== KNOWS_ABOUT_PREDICATE) continue;
772
- if (rulingTestimonyClaim(row.provenance, person, currentEpoch)?.voided) continue;
773
- topics.push(row.object);
777
+ const claim = rulingTestimonyClaim(row.provenance, person, currentEpoch);
778
+ if (claim?.voided) continue;
779
+ known.push({ topic: row.object, epoch: claim?.epoch ?? 0, turn: claim?.turn ?? 0 });
774
780
  }
775
- return topics;
781
+ known.sort((a, b) => a.epoch - b.epoch || a.turn - b.turn
782
+ || (a.topic < b.topic ? -1 : a.topic > b.topic ? 1 : 0));
783
+ return known.map((entry) => entry.topic);
776
784
  }
777
785
 
778
786
  /**
@@ -1223,12 +1231,34 @@ export function freshObjectId(rows, kind, alsoTaken = new Set()) {
1223
1231
  }
1224
1232
 
1225
1233
  /** The kinds a room kind declares for one of the spawn pools, in the order the
1226
- * world wrote them, or `fallback` when it declares none. Pure. */
1234
+ * fold hands them over, or `fallback` when it declares none. A pool is a set of
1235
+ * rows, not a list, so the store keeps no rank between them and the order is
1236
+ * the fold's content order rather than the order the world file lists them in.
1237
+ * A caller that shows the whole pool can read it as it stands; one that takes
1238
+ * only part of it wants preferredDigKinds below. Pure. */
1227
1239
  function declaredKindsOr(rows, roomClass, predicate, fallback) {
1228
1240
  const declared = factObjects(rows, roomClass, predicate);
1229
1241
  return declared.length ? declared : fallback;
1230
1242
  }
1231
1243
 
1244
+ /** A dig pool ranked by what a dig should turn up first: the engine's own
1245
+ * DIG_SPAWN_KINDS order, then content order for a kind it does not name.
1246
+ *
1247
+ * A den shows everything it holds, so its pool needs no rank. A plain dig takes
1248
+ * the first few, so which few it takes is a real choice — and the fact store
1249
+ * holds the pool as a set, with no rank to read. Leaving it to the fold's own
1250
+ * order would decide it alphabetically, which puts a kind the world already
1251
+ * keeps as one hand-named prop ahead of one minted only by digging, and the
1252
+ * point of a minted id is that it names something a hand-authored prop does
1253
+ * not. Pure. */
1254
+ function preferredDigKinds(kinds) {
1255
+ const rank = (kind) => {
1256
+ const at = DIG_SPAWN_KINDS.indexOf(kind);
1257
+ return at < 0 ? DIG_SPAWN_KINDS.length : at;
1258
+ };
1259
+ return kinds.slice().sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0));
1260
+ }
1261
+
1232
1262
  /** The mass row a freshly minted instance needs, copied off its own class, or
1233
1263
  * nothing when the class declares no mass. eat reads the instance's mass, so a
1234
1264
  * dug carrot with none would be worth the flat default however the world
@@ -1499,7 +1529,7 @@ async function handleDigVerb(ctx) {
1499
1529
  const spawnCount = DIG_SPAWN_MIN + stableIndex(dug, spawnMax - DIG_SPAWN_MIN + 1);
1500
1530
  const spawnedKinds = isDen
1501
1531
  ? declaredKindsOr(rows, dugKind, DEN_SPAWN_PREDICATE, DIG_SPAWN_KINDS)
1502
- : declaredKindsOr(rows, dugKind, DIG_SPAWN_PREDICATE, DIG_SPAWN_KINDS).slice(0, spawnCount);
1532
+ : preferredDigKinds(declaredKindsOr(rows, dugKind, DIG_SPAWN_PREDICATE, DIG_SPAWN_KINDS)).slice(0, spawnCount);
1503
1533
  const minted = new Set();
1504
1534
  const spawned = spawnedKinds.map((kind) => {
1505
1535
  const id = freshObjectId(rows, kind, minted);