@polycode-projects/the-mechanical-code-talker 6.0.19 → 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.
- package/README.md +20 -23
- package/bin/tmct.mjs +16 -33
- package/corpus/LICENSES.json +0 -21
- package/corpus/README.md +10 -13
- package/corpus/reference/manifest.json +19 -19
- package/corpus/reference/shards/ref-01.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-04.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-08.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-10.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-11.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-17.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-20.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-25.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-2c.jsonl.gz +0 -0
- package/corpus/tier2/generate.mjs +6 -142
- package/corpus/tier2/manifest.json +0 -42
- package/package.json +4 -4
- package/src/adapters/corpus/child-seed.mjs +74 -0
- package/src/adapters/corpus/conceptnet.mjs +45 -26
- package/src/adapters/memory/blocks.mjs +7 -1
- package/src/adapters/memory/core.mjs +453 -103
- package/src/adapters/memory/corpus-bands.mjs +27 -10
- package/src/adapters/memory/inspect.mjs +24 -5
- package/src/adapters/memory/rows.mjs +106 -9
- package/src/adapters/memory/shacl.mjs +10 -3
- package/src/domain/ask.mjs +27 -10
- package/src/domain/cli-verbs.mjs +3 -4
- package/src/domain/completions/group.mjs +8 -3
- package/src/domain/completions/infer.mjs +7 -2
- package/src/domain/completions/prune.mjs +5 -1
- package/src/domain/completions/rank.mjs +7 -2
- package/src/domain/digest/compose.mjs +5 -1
- package/src/domain/digest/select.mjs +12 -6
- package/src/domain/domain.mjs +15 -8
- package/src/domain/el-classify.mjs +11 -2
- package/src/domain/fact-phrase.mjs +86 -4
- package/src/domain/hash.mjs +9 -0
- package/src/domain/memory/bias.mjs +8 -4
- package/src/domain/memory/capability.mjs +12 -6
- package/src/domain/memory/fact-order.mjs +29 -0
- package/src/domain/memory/resolution.mjs +3 -0
- package/src/domain/news-feed.mjs +422 -56
- package/src/domain/reference-pack.mjs +5 -0
- package/src/domain/sense-scope.mjs +116 -0
- package/src/domain/sense-split.mjs +1 -1
- package/src/domain/syllogise.mjs +21 -13
- package/src/domain/tableau.mjs +23 -14
- package/src/domain/worlds-pack.mjs +5 -1
- package/src/services/adventure-autoplay.mjs +6 -1
- package/src/services/adventure-editor.mjs +43 -21
- package/src/services/adventure-viz.mjs +26 -9
- package/src/services/adventure.mjs +40 -10
- package/src/services/chat.mjs +253 -113
- package/src/services/extensions.mjs +51 -58
- package/src/services/extract-facts.mjs +670 -95
- package/src/services/init.mjs +4 -4
- package/src/services/ledger-viz.mjs +9 -4
- package/src/services/memory-panel-viz.mjs +4 -5
- package/src/services/mud-editor.mjs +40 -16
- package/src/services/mud-viz.mjs +8 -2
- package/src/services/mudiii-turn.mjs +5 -3
- package/src/services/mudiii-viz.mjs +8 -2
- package/src/services/news.mjs +257 -11
- package/src/services/research-viz.mjs +1 -1
- package/src/services/sprite-catalog-viz.mjs +10 -5
- package/src/surfaces/web/adventure-browser-entry.mjs +6 -12
- package/src/surfaces/web/memory-ask-browser.bundle.js +152 -151
- package/src/surfaces/web/mud-browser-entry.mjs +7 -11
- package/src/surfaces/web/research-browser-entry.mjs +5 -2
- package/corpus/tier2/aws.jsonl +0 -39
- package/corpus/tier2/java.jsonl +0 -31
- package/corpus/tier2/python.jsonl +0 -30
|
@@ -226,9 +226,14 @@ function withoutParentheticals(text) {
|
|
|
226
226
|
|
|
227
227
|
// Words that end the noun phrase: a relative clause or a trailing modifier
|
|
228
228
|
// carries detail, not the category ("a place where ships shelter" → place).
|
|
229
|
+
// "about"/"around"/"roughly"/"approximately" open a comparison or a topic
|
|
230
|
+
// that reads like more of the head phrase but is not ("a small marsupial
|
|
231
|
+
// about the size of a large cat" is a marsupial, not a size; "a book about
|
|
232
|
+
// dogs" is a book).
|
|
229
233
|
const ISA_CLAUSE_CUT = new Set([
|
|
230
234
|
"that", "which", "where", "who", "whose", "whom", "when", "used", "found",
|
|
231
235
|
"made", "with", "in", "on", "for", "from", "by", "to", "and", "or",
|
|
236
|
+
"about", "around", "roughly", "approximately",
|
|
232
237
|
]);
|
|
233
238
|
// Classifier heads carry no category of their own ("a member of the cat
|
|
234
239
|
// family" names family membership, not what the thing is) — no isa beats a
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// sense-scope.mjs — the same-sense discipline a READ-TIME walk applies when it
|
|
2
|
+
// picks which neighbours of a term to show.
|
|
3
|
+
//
|
|
4
|
+
// sense-gate.mjs already places a term under its nearest top classes and says
|
|
5
|
+
// when two terms are provably in different senses. It uses that to refuse a
|
|
6
|
+
// DERIVED isa edge, and it refuses only on proof: both ends placed, every cross
|
|
7
|
+
// pair declared disjoint. That burden is right for a derivation, because
|
|
8
|
+
// refusing one deletes an entailment the graph would otherwise hold.
|
|
9
|
+
//
|
|
10
|
+
// Selection carries the opposite burden. Nothing is deleted when a background
|
|
11
|
+
// row goes unshown, so a walk may ask for evidence that a neighbour belongs
|
|
12
|
+
// before it spends a line on it. This module asks for exactly that: the
|
|
13
|
+
// neighbour's top classes must MEET the anchor's, not merely fail to be
|
|
14
|
+
// provably disjoint.
|
|
15
|
+
//
|
|
16
|
+
// The difference is the whole bug. "russia" places under `place`; "passage"
|
|
17
|
+
// places under `artifact`. Those two tops overlap by declaration (a cathedral
|
|
18
|
+
// is both), so the derivation gate lets them stand and the store holds an
|
|
19
|
+
// entailed "russia is a kind of passage". A hop-bounded walk out of russia then
|
|
20
|
+
// treats `passage` as a one-hop neighbour and comes back down the anatomy side
|
|
21
|
+
// of it: "orifice is a kind of passage", "duct is a kind of passage", on a card
|
|
22
|
+
// about a prisoner release. Requiring a shared top stops the walk at `passage`
|
|
23
|
+
// while `country`, `district` and `geographical area` — all of which meet
|
|
24
|
+
// `place` — carry straight on.
|
|
25
|
+
//
|
|
26
|
+
// Unplaced is not out of sense. A term the bands never classify (a person's
|
|
27
|
+
// name, a coined product name, most of what a headline is actually about) has
|
|
28
|
+
// no tops to meet, so it is admitted.
|
|
29
|
+
//
|
|
30
|
+
// An unplaced ANCHOR is the harder case: it pools no tops, so by default the
|
|
31
|
+
// scope keeps to nothing and every neighbour walks in as filler. Two ways out,
|
|
32
|
+
// and a caller picks between them. `hasPlacedSense` says whether an anchor set
|
|
33
|
+
// is placed at all, so a caller holding a second, better placed set can anchor
|
|
34
|
+
// there instead. `admitAllWhenUnplaced: false` says these anchors ARE the
|
|
35
|
+
// sense, placed or not: an unplaced anchor set then keeps to the unplaced, so a
|
|
36
|
+
// walk under it goes sparse instead of pulling in the whole placed graph. A
|
|
37
|
+
// graph with no bands at all places nothing, refuses nothing, and reads exactly
|
|
38
|
+
// as it did.
|
|
39
|
+
//
|
|
40
|
+
// Pure over the fact set. The gate underneath sorts every frontier and memoizes
|
|
41
|
+
// per term, and an anchor set is a membership test over the tops it pools, so
|
|
42
|
+
// two ingestion orders of the same facts admit the same terms.
|
|
43
|
+
|
|
44
|
+
import { normFactTerm } from "./hash.mjs";
|
|
45
|
+
import { buildSenseGate } from "./sense-gate.mjs";
|
|
46
|
+
|
|
47
|
+
const SUBCLASS_PREDICATE = "rdfs:subClassOf";
|
|
48
|
+
const TYPE_PREDICATE = "rdf:type";
|
|
49
|
+
|
|
50
|
+
/** Whether a row states its isa edge rather than concluding it. An entailment
|
|
51
|
+
* head, a hypothetical's environment, or a justification chain all mark the
|
|
52
|
+
* graph's own output; feeding that back to the gate that constrains it is how
|
|
53
|
+
* a bad shortcut ends up vouching for itself. Read here rather than imported,
|
|
54
|
+
* so this module stands alone for any reader that wants the same discipline. */
|
|
55
|
+
function statesItsIsaEdge(row) {
|
|
56
|
+
const head = String(row?.provenance || "").trim().split(/\s+/)[0] || "";
|
|
57
|
+
if (head.startsWith("entailed:")) return false;
|
|
58
|
+
if (Array.isArray(row?.environments) && row.environments.length) return false;
|
|
59
|
+
if (Array.isArray(row?.justification) && row.justification.length) return false;
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Builds the scope over one fact set, reading only the asserted isa rows.
|
|
64
|
+
*
|
|
65
|
+
* Returns `{ topsOf, hasPlacedSense, sameSenseAs }`. `topsOf(term)` is the
|
|
66
|
+
* gate's own placement, exposed so a caller can explain a refusal.
|
|
67
|
+
* `sameSenseAs(anchors)` takes one term or an iterable of them and answers a
|
|
68
|
+
* `(term) => boolean` predicate: true when the term may stay in the anchors'
|
|
69
|
+
* neighbourhood. Several anchors pool their tops, so a walk seeded from more
|
|
70
|
+
* than one term keeps to the senses of all of them. `hasPlacedSense(anchors)`
|
|
71
|
+
* says whether that pool holds anything, which is whether the scope those
|
|
72
|
+
* anchors build refuses any term at all. Pass `admitAllWhenUnplaced: false`
|
|
73
|
+
* and an unplaced anchor set refuses every placed term instead of admitting
|
|
74
|
+
* everything. */
|
|
75
|
+
export function buildSenseScope(rows) {
|
|
76
|
+
const subClassEdges = [];
|
|
77
|
+
const typeEdges = [];
|
|
78
|
+
for (const row of rows || []) {
|
|
79
|
+
if (!row || !statesItsIsaEdge(row)) continue;
|
|
80
|
+
const child = normFactTerm(row.subject);
|
|
81
|
+
const parent = normFactTerm(row.object);
|
|
82
|
+
if (!child || !parent) continue;
|
|
83
|
+
if (row.predicate === SUBCLASS_PREDICATE) subClassEdges.push([child, parent]);
|
|
84
|
+
else if (row.predicate === TYPE_PREDICATE) typeEdges.push([child, parent]);
|
|
85
|
+
}
|
|
86
|
+
const gate = buildSenseGate({ subClassEdges, typeEdges });
|
|
87
|
+
|
|
88
|
+
const topsOf = (term) => gate.topsOf(normFactTerm(term));
|
|
89
|
+
|
|
90
|
+
const anchorList = (anchors) => (typeof anchors === "string" ? [anchors] : [...(anchors || [])]);
|
|
91
|
+
|
|
92
|
+
function topsAcross(list) {
|
|
93
|
+
const tops = new Set();
|
|
94
|
+
for (const anchor of list) for (const top of topsOf(anchor)) tops.add(top);
|
|
95
|
+
return tops;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const hasPlacedSense = (anchors) => topsAcross(anchorList(anchors)).size > 0;
|
|
99
|
+
|
|
100
|
+
function sameSenseAs(anchors, { admitAllWhenUnplaced = true } = {}) {
|
|
101
|
+
const list = anchorList(anchors);
|
|
102
|
+
const anchorTops = topsAcross(list);
|
|
103
|
+
if (!anchorTops.size && admitAllWhenUnplaced) return () => true;
|
|
104
|
+
const anchorTerms = new Set(list.map((a) => normFactTerm(a)).filter(Boolean));
|
|
105
|
+
return (term) => {
|
|
106
|
+
const norm = normFactTerm(term);
|
|
107
|
+
if (!norm || anchorTerms.has(norm)) return true;
|
|
108
|
+
const tops = topsOf(norm);
|
|
109
|
+
if (!tops.length) return true;
|
|
110
|
+
for (const top of tops) if (anchorTops.has(top)) return true;
|
|
111
|
+
return false;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { topsOf, hasPlacedSense, sameSenseAs };
|
|
116
|
+
}
|
|
@@ -260,7 +260,7 @@ export function clusterSenses(objects, {
|
|
|
260
260
|
let best = depth(label);
|
|
261
261
|
for (const m of sorted) { const d = depth(m); if (d > best) { best = d; label = m; } }
|
|
262
262
|
return { objects: sorted, label };
|
|
263
|
-
}).sort((x, y) => x.label
|
|
263
|
+
}).sort((x, y) => compareStrings(x.label, y.label));
|
|
264
264
|
|
|
265
265
|
return { split: clusters.length > 1, clusters, pairs };
|
|
266
266
|
}
|
package/src/domain/syllogise.mjs
CHANGED
|
@@ -32,6 +32,14 @@
|
|
|
32
32
|
import { normFactTerm, factIdForTriple } from "./hash.mjs";
|
|
33
33
|
import { buildSenseGate } from "./sense-gate.mjs";
|
|
34
34
|
|
|
35
|
+
/** Codepoint order, never localeCompare. Every candidate list below is sorted
|
|
36
|
+
* and then cut at the budget, so the comparator decides WHICH entailments a
|
|
37
|
+
* pass derives. It also decides which premise each one cites: dedup keeps the
|
|
38
|
+
* first candidate per key, so a derivation's `via` slot holds whichever middle
|
|
39
|
+
* term sorted first. Sorting either by the reader's OS locale would let two
|
|
40
|
+
* machines forward-chain one graph into two different fact sets. */
|
|
41
|
+
const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
42
|
+
|
|
35
43
|
/** The two persisting entry points (syllogise, retractSubClassOf) take the
|
|
36
44
|
* memory store's read/write functions through a required `store` option —
|
|
37
45
|
* this module never imports the store. A missing function is a loud
|
|
@@ -168,7 +176,7 @@ export function deriveSubClassClosure(edges, { depth = 32, budget = 50, focus =
|
|
|
168
176
|
}
|
|
169
177
|
}
|
|
170
178
|
if (!additions.length) break; // fixpoint reached
|
|
171
|
-
additions.sort((x, y) => x[0]
|
|
179
|
+
additions.sort((x, y) => byCodepoint(x[0], y[0]) || byCodepoint(x[2], y[2]) || byCodepoint(x[1], y[1]));
|
|
172
180
|
let progressed = false;
|
|
173
181
|
for (const [a, b, c, key] of additions) {
|
|
174
182
|
if (derivedKeys.has(key)) continue; // an earlier addition this round covered it
|
|
@@ -237,7 +245,7 @@ export function deriveSubClassClosureDelta(allEdges, deltaEdges, { depth = 32, b
|
|
|
237
245
|
for (const z of pred.get(a) || []) consider(z, a, b); // R∘Δ
|
|
238
246
|
}
|
|
239
247
|
if (!additions.length) break; // fixpoint reached
|
|
240
|
-
additions.sort((x, y) => x[0]
|
|
248
|
+
additions.sort((x, y) => byCodepoint(x[0], y[0]) || byCodepoint(x[2], y[2]) || byCodepoint(x[1], y[1]));
|
|
241
249
|
let progressed = false;
|
|
242
250
|
const nextDelta = [];
|
|
243
251
|
for (const [a, b, c, key] of additions) {
|
|
@@ -380,7 +388,7 @@ export function deriveTypePropagation(typeEdges, subClassEdges, { budget = 50, f
|
|
|
380
388
|
candidates.push([x, c, d, key]);
|
|
381
389
|
}
|
|
382
390
|
}
|
|
383
|
-
candidates.sort((p, q) => p[0]
|
|
391
|
+
candidates.sort((p, q) => byCodepoint(p[0], q[0]) || byCodepoint(p[2], q[2]) || byCodepoint(p[1], q[1]));
|
|
384
392
|
const derived = [];
|
|
385
393
|
const derivedKeys = new Set();
|
|
386
394
|
for (const [x, c, d, key] of candidates) {
|
|
@@ -446,7 +454,7 @@ export function deriveDisjointViolations(typeEdges, subClassEdges, disjointEdges
|
|
|
446
454
|
}
|
|
447
455
|
}
|
|
448
456
|
}
|
|
449
|
-
candidates.sort((p, q) => p[0]
|
|
457
|
+
candidates.sort((p, q) => byCodepoint(p[0], q[0]) || byCodepoint(p[3], q[3]) || byCodepoint(p[2], q[2]) || byCodepoint(p[1], q[1]));
|
|
450
458
|
const derived = [];
|
|
451
459
|
const derivedKeys = new Set();
|
|
452
460
|
for (const [x, c, d, e, key] of candidates) {
|
|
@@ -526,7 +534,7 @@ export function deriveSomeValuesFromApplication(propertyEdges, typeEdges, subCla
|
|
|
526
534
|
}
|
|
527
535
|
}
|
|
528
536
|
}
|
|
529
|
-
candidates.sort((a, b) => a[0]
|
|
537
|
+
candidates.sort((a, b) => byCodepoint(a[0], b[0]) || byCodepoint(a[6], b[6]) || byCodepoint(a[1], b[1]) || byCodepoint(a[3], b[3]));
|
|
530
538
|
const derived = [];
|
|
531
539
|
const derivedKeys = new Set();
|
|
532
540
|
for (const [x, p, pKey, y, c, target, r, key] of candidates) {
|
|
@@ -575,7 +583,7 @@ export function buildCardinalityRestrictions(rows) {
|
|
|
575
583
|
if (!onClass) continue;
|
|
576
584
|
restrictions.push({ restriction, kind, n, onClass });
|
|
577
585
|
}
|
|
578
|
-
restrictions.sort((a, b) => a.restriction
|
|
586
|
+
restrictions.sort((a, b) => byCodepoint(a.restriction, b.restriction));
|
|
579
587
|
return restrictions;
|
|
580
588
|
}
|
|
581
589
|
|
|
@@ -627,7 +635,7 @@ export function deriveSomeValuesFromSubsumption(restrictionEdges, subClassEdges,
|
|
|
627
635
|
}
|
|
628
636
|
}
|
|
629
637
|
}
|
|
630
|
-
candidates.sort((a, b) => a[0]
|
|
638
|
+
candidates.sort((a, b) => byCodepoint(a[0], b[0]) || byCodepoint(a[1], b[1]));
|
|
631
639
|
const derived = [];
|
|
632
640
|
const derivedKeys = new Set();
|
|
633
641
|
for (const [c1, c2, y1, y2, key] of candidates) {
|
|
@@ -795,7 +803,7 @@ export function findConsistencyViolations(typeEdges, subClassEdges, disjointEdge
|
|
|
795
803
|
}
|
|
796
804
|
}
|
|
797
805
|
}
|
|
798
|
-
candidates.sort((p, q) => p[0]
|
|
806
|
+
candidates.sort((p, q) => byCodepoint(p[0], q[0]) || byCodepoint(p[1], q[1]) || byCodepoint(p[2], q[2]));
|
|
799
807
|
const derived = [];
|
|
800
808
|
for (const [x, ta, tb, viaA, viaB] of candidates) {
|
|
801
809
|
if (derived.length >= budget) break;
|
|
@@ -1146,7 +1154,7 @@ export async function syllogise(repoDir, {
|
|
|
1146
1154
|
environments: environmentsOf(r), provenance: r.provenance,
|
|
1147
1155
|
}));
|
|
1148
1156
|
const alternateCandidates = [...conclusionCandidates, ...storedCandidates]
|
|
1149
|
-
.sort((a, b) => a.subject
|
|
1157
|
+
.sort((a, b) => byCodepoint(a.subject, b.subject) || byCodepoint(a.predicate, b.predicate) || byCodepoint(a.object, b.object));
|
|
1150
1158
|
let environmentsAdded = 0;
|
|
1151
1159
|
let alternatesTruncated = false;
|
|
1152
1160
|
let examined = 0;
|
|
@@ -1430,7 +1438,7 @@ function buildSupportEnumerator(rows) {
|
|
|
1430
1438
|
if (rec) {
|
|
1431
1439
|
const edges = [...(propertyEdgesOf.get(row.subject) || [])]
|
|
1432
1440
|
.filter(([p]) => normFactTerm(p) === rec.propertyKey)
|
|
1433
|
-
.sort((a, b) => a[0]
|
|
1441
|
+
.sort((a, b) => byCodepoint(a[0], b[0]) || byCodepoint(a[1], b[1]));
|
|
1434
1442
|
for (const [p, y] of edges) {
|
|
1435
1443
|
if (out.length >= maxEnvironments) break;
|
|
1436
1444
|
for (const c of [...(typesOf.get(y) || [])].sort()) {
|
|
@@ -1457,7 +1465,7 @@ function buildSupportEnumerator(rows) {
|
|
|
1457
1465
|
if ((disjointOf.get(d) || new Set()).has(row.object)) pairs.push([c, d]);
|
|
1458
1466
|
}
|
|
1459
1467
|
}
|
|
1460
|
-
pairs.sort((a, b) => a[0]
|
|
1468
|
+
pairs.sort((a, b) => byCodepoint(a[0], b[0]) || byCodepoint(a[1], b[1]));
|
|
1461
1469
|
for (const [c, d] of pairs) {
|
|
1462
1470
|
if (out.length >= maxEnvironments) break;
|
|
1463
1471
|
const dwStoredForward = disjointForward.has(`${d}${SEP}${row.object}`);
|
|
@@ -1653,7 +1661,7 @@ export async function retractSubClassOf(repoDir, subject, object, {
|
|
|
1653
1661
|
}
|
|
1654
1662
|
}
|
|
1655
1663
|
const candidates = [...candidateIds].map((id) => byId.get(id))
|
|
1656
|
-
.sort((a, b) => a.subject
|
|
1664
|
+
.sort((a, b) => byCodepoint(a.subject, b.subject) || byCodepoint(a.predicate, b.predicate) || byCodepoint(a.object, b.object));
|
|
1657
1665
|
if (!candidates.length) break; // fixpoint — nothing cites what just fell
|
|
1658
1666
|
|
|
1659
1667
|
// The surviving fact set for THIS round excludes every candidate's own
|
|
@@ -1719,7 +1727,7 @@ export async function retractSubClassOf(repoDir, subject, object, {
|
|
|
1719
1727
|
if (appendFactsFn) {
|
|
1720
1728
|
const regroundWrites = [...reground.entries()]
|
|
1721
1729
|
.filter(([id]) => !removed.has(id))
|
|
1722
|
-
.sort((a, b) => a[0]
|
|
1730
|
+
.sort((a, b) => byCodepoint(a[0], b[0]))
|
|
1723
1731
|
.map(([id, environments]) => {
|
|
1724
1732
|
const row = byId.get(id);
|
|
1725
1733
|
return {
|
package/src/domain/tableau.mjs
CHANGED
|
@@ -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)
|
|
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]
|
|
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)
|
|
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]
|
|
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
|
|
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)
|
|
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)
|
|
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
|
|
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]
|
|
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)
|
|
1263
|
-
canonicalKey(a.sup)
|
|
1264
|
-
a.from.join(",")
|
|
1265
|
-
assertions.sort((a, b) => a.ind
|
|
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
|
|
1330
|
+
violations.sort((a, b) => byCodepoint(a.subject, b.subject));
|
|
1322
1331
|
return violations;
|
|
1323
1332
|
}
|
|
1324
1333
|
|
|
@@ -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}
|
|
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
|
|
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
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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.
|
|
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
|
|
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)
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
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.
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
|
126
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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
|