@polycode-projects/the-mechanical-code-talker 5.0.1 → 5.0.3

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.
@@ -11,7 +11,7 @@
11
11
  // actually seen. `exposedRoomIds` is the set of rooms this auto-play run has
12
12
  // itself moved into (the opening room included from turn 0) — it is the sole
13
13
  // caller making every move, so it always knows this, and threads the set
14
- // forward turn to turn exactly like spider-fly.mjs threads its own agents
14
+ // forward turn to turn exactly like predator-prey.mjs threads its own agents
15
15
  // shape. `exposedFacts` turns that set into the actual filtered view: a fact
16
16
  // is exposed when its subject's CURRENT placement resolves into an exposed
17
17
  // room, when the subject is the player, or when the fact IS the world's
@@ -130,7 +130,7 @@ async function stepTowardThenAct({
130
130
  * and execute exactly the one move that goal implies through `adventureTurn`
131
131
  * — the same public entry point a real chat turn calls. Returns
132
132
  * `{ turn, goal, plan, done, stalled, exposedRoomIds }`, mirroring
133
- * spider-fly.mjs's own tick shape: `plan` is the remaining multi-step route a
133
+ * predator-prey.mjs's own tick shape: `plan` is the remaining multi-step route a
134
134
  * `findActionPath` search found this tick (or null when the move was a
135
135
  * single, immediate step — an adjacent unexposed exit, or a take), `goal` is
136
136
  * a short line describing what this tick did, `done` means the objective is
@@ -944,6 +944,8 @@ ${THEME_TOKENS_CSS}
944
944
  onto further lines, staying right-anchored rather than dropping the
945
945
  whole pill row under the heading. */
946
946
  .command-head .pills { flex: 1 1 auto; min-width: 0; justify-content: flex-end; margin-bottom: 0; }
947
+ .teach-toggle { display: flex; align-items: center; gap: .35rem; font-family: ${MONO_STACK}; font-size: .72rem; color: var(--muted); margin: 0 0 .4rem; cursor: pointer; }
948
+ .teach-toggle input { accent-color: var(--gilt); }
947
949
  .chatask { display: flex; align-items: center; gap: .5rem; border-top: 1px solid var(--line); padding-top: .5rem; }
948
950
  .chatask .prompt { color: var(--taught); font-size: .78rem; font-family: ${MONO_STACK}; }
949
951
  .chatask input { flex: 1; font-family: ${MONO_STACK}; font-size: .78rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); padding: .32rem .55rem; min-width: 0; }
@@ -1042,6 +1044,10 @@ ${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " select
1042
1044
  <h2>What would you like to do</h2>
1043
1045
  <div class="pills" id="pills"></div>
1044
1046
  </div>
1047
+ <label class="teach-toggle" title="With this on, a sentence like &quot;Candle is in the study.&quot; writes a fact into the world instead of running as a command.">
1048
+ <input type="checkbox" id="teachToggle">
1049
+ teach mode
1050
+ </label>
1045
1051
  <form class="chatask" id="chatform">
1046
1052
  <span class="prompt mono">tmct&gt;</span>
1047
1053
  <input id="chatq" type="text" placeholder="go north" aria-label="Type a command, or ask a question" disabled>
@@ -1151,6 +1157,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1151
1157
  const pillsEl = el("pills");
1152
1158
  const chatformEl = el("chatform");
1153
1159
  const chatqEl = el("chatq");
1160
+ const teachToggleEl = el("teachToggle");
1154
1161
  const carryListEl = el("carryList");
1155
1162
  const mapWrapEl = el("mapWrap");
1156
1163
  const mapViewportEl = el("mapViewport");
@@ -1525,7 +1532,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1525
1532
  }
1526
1533
  function renderPills(rows, state, here) {
1527
1534
  const actions = pillsFor(rows, state, here).filter((a) => a.indexOf("go ") !== 0);
1528
- pillsEl.innerHTML = actions.map((a) => '<button type="button" class="pill">' + esc(a) + "</button>").join("");
1535
+ pillsEl.innerHTML = actions.map((a) => '<button type="button" class="pill" data-command="' + esc(a) + '">' + esc(a) + "</button>").join("");
1529
1536
  // The empty input teaches the grounded noun form off the same affordance
1530
1537
  // list the pills read — real props from THIS room, so a first-time player
1531
1538
  // types "examine lamp", not a pronoun with nothing to bind to yet.
@@ -1822,12 +1829,10 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1822
1829
 
1823
1830
  async function boot({ fresh = false } = {}) {
1824
1831
  const saved = !fresh && persist ? await persist.load() : null;
1825
- session = await tmct.open(
1826
- world(),
1827
- saved && saved.payload && saved.payload.memoryPayload
1828
- ? { restoredPayload: saved.payload.memoryPayload, restoredVisitedRoomIds: saved.payload.visitedRoomIds }
1829
- : {},
1830
- );
1832
+ const restoreOptions = saved && saved.payload && saved.payload.memoryPayload
1833
+ ? { restoredPayload: saved.payload.memoryPayload, restoredVisitedRoomIds: saved.payload.visitedRoomIds }
1834
+ : {};
1835
+ session = await tmct.open(world(), { ...restoreOptions, getTeachEnabled: () => teachToggleEl.checked });
1831
1836
  lastTicks = 0;
1832
1837
  const snap = await session.snapshot();
1833
1838
  redraw(snap);
@@ -40,7 +40,7 @@ import {
40
40
  stripTrailingScopeFiller, stripTrailingDiscourseTag, EDGE_NOUN_TO_METRIC, RELATIONS, LIST_TRIGGERS,
41
41
  locativePreposition,
42
42
  } from "../domain/ask-vocab.mjs";
43
- import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames, expandContractions, normalizeQuery, stripFillerWords, escapeRegex, kindNounAnaphoraHint, datedTeachSuffix } from "../domain/interpret/normalize.mjs";
43
+ import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames, expandContractions, normalizeQuery, stripFillerWords, escapeRegex, kindNounAnaphoraHint, datedTeachSuffix, QUESTION_LEAD_RE } from "../domain/interpret/normalize.mjs";
44
44
  import { setDefaultNlpAdapter } from "../domain/interpret/nlp-registry.mjs";
45
45
  import { setConstructionBanks } from "../domain/interpret/strategies/constructions.mjs";
46
46
  import { nlpAdapter } from "../adapters/ask-nlp.mjs";
@@ -2366,9 +2366,6 @@ const COMPARATIVE_ASK_RE = new RegExp(`^(?:is|are)\\s+(.+?)\\s+(${COMPARATIVE_SR
2366
2366
  * preposition into a minted predicate (the general-verb teach/query lanes
2367
2367
  * and the action-rule frames) — a single source so the set never forks. */
2368
2368
  const PREP_SRC = "on|in|at|onto|upon|under|over|beside|near|behind|above|below|inside|outside";
2369
- /** Interrogative / auxiliary leads that make an "X is a Y"-shaped line a QUESTION
2370
- * ("what is a cache", "is a module a component"), never a teach declarative. */
2371
- const QUESTION_LEAD_RE = /^(?:what|who|which|where|when|why|how|is|are|do|does|did|can|could|should|would|will|has|have)\b/i;
2372
2369
  /** A plain declarative "X is a kind of Y" / "X is a Y" shape (subject-first, no
2373
2370
  * question lead — paired with QUESTION_LEAD_RE at every call site), tolerating
2374
2371
  * an infix "kind of"/"type of" (teachLane's own stripKindOf handles this same
@@ -4794,23 +4791,54 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4794
4791
 
4795
4792
  // RETRACTION — "forget that X is a Y": wires the data-layer retraction
4796
4793
  // primitive (retractSubClassOf, src/domain/syllogise.mjs) up to chat-level
4797
- // phrasing.
4794
+ // phrasing. Scoped to THIS session's own assertion (sourceTags below) —
4795
+ // the same tags teachFact/assertSentence write under — so retracting
4796
+ // never erases a fact another source taught, only this session's own copy.
4798
4797
  //
4799
4798
  // TRIGGER, never itself the authority: RETRACT_FORGET_RE only recognizes
4800
4799
  // the SHAPE of a retraction sentence — it says nothing about whether
4801
- // subject⊑object was ever actually taught. retractSubClassOf is asked for
4802
- // real and is the only thing that decides:
4803
- // - found:truea real stored (or entailed) fact existed and was
4804
- // retracted (with its dependency-directed cascade) confirmed here.
4805
- // - found:false subject⊑object was never a stored fact, and this falls
4806
- // through to the rest of teachLane's ordinary cascade below rather than
4807
- // claiming a specific, possibly-wrong reason.
4800
+ // subject⊑object was ever actually taught, or taught by THIS session.
4801
+ // retractSubClassOf is asked for real and is the only thing that decides:
4802
+ // - found:falsesubject⊑object was never a stored fact, and this
4803
+ // falls through to the rest of teachLane's ordinary cascade below
4804
+ // rather than claiming a specific, possibly-wrong reason.
4805
+ // - found:true, ownRecord:false some OTHER source taught it; this
4806
+ // session has nothing of its own to withdraw.
4807
+ // - found:true, stillStands:true → this session's own record is gone,
4808
+ // but another source's record keeps the fact standing (no cascade —
4809
+ // its premise never actually broke).
4810
+ // - found:true, stillStands:false → this session held the only record;
4811
+ // the fact and its dependency-directed cascade are both gone.
4808
4812
  if (retractForgetMatch) {
4809
4813
  const { retractSubClassOf } = await import("../domain/syllogise.mjs");
4810
- const { loadMemory: loadMemForRetract, readFactRows: readRowsForRetract, removeFacts, appendFacts: appendFactsForRetract } = await import("../adapters/memory/core.mjs");
4814
+ const { provenanceTag: aceProvenanceTag } = await import("../domain/grammar/assert.mjs");
4815
+ const {
4816
+ loadMemory: loadMemForRetract, readFactRows: readRowsForRetract, removeFacts,
4817
+ appendFacts: appendFactsForRetract, factRecordIdForTag,
4818
+ } = await import("../adapters/memory/core.mjs");
4811
4819
  const result = await retractSubClassOf(memoryDir, retractSubject, retractObject, {
4812
- store: { loadMemory: loadMemForRetract, readFactRows: readRowsForRetract, removeFacts, appendFacts: appendFactsForRetract },
4820
+ // A session's own positive assertion of subject⊑object can have
4821
+ // landed under either lane: the free-form teach lane (teachFact) or
4822
+ // the ACE-parsed assert lane (assertSentence) — both tags name here.
4823
+ sourceTags: [teachProvenanceTag(sessionId), aceProvenanceTag({ sessionId })],
4824
+ store: {
4825
+ loadMemory: loadMemForRetract, readFactRows: readRowsForRetract, removeFacts,
4826
+ appendFacts: appendFactsForRetract, factRecordIdForTag,
4827
+ },
4813
4828
  });
4829
+ if (result.found && !result.ownRecord) {
4830
+ return {
4831
+ text: `"${retractSubject} is a kind of ${retractObject}" isn't something you taught me — it's on record from elsewhere, so there's nothing of yours to forget.`,
4832
+ via: "retract", miss: false,
4833
+ };
4834
+ }
4835
+ if (result.found && result.stillStands) {
4836
+ return {
4837
+ text: `noted — forgotten your own record of "${retractSubject} is a kind of ${retractObject}", `
4838
+ + "but it's still stored, told to me by someone else.",
4839
+ via: "retract", miss: false,
4840
+ };
4841
+ }
4814
4842
  if (result.found) {
4815
4843
  const extra = result.count - 1; // beyond the target fact itself
4816
4844
  return {
@@ -1,7 +1,7 @@
1
1
  // mud-turn.mjs — one acting character's whole turn in a mud world: investigate
2
2
  // the room it stands in, walk toward food it actually knows about, roll for
3
3
  // digging out of the room's frontier, and (when none of that came to anything)
4
- // set off for a room it has never stood in. The split mirrors spider-fly.mjs /
4
+ // set off for a room it has never stood in. The split mirrors predator-prey.mjs /
5
5
  // spider-fly-turn.mjs: adventure.mjs owns the read/fold/write primitives, this
6
6
  // file owns the per-tick decisions that drive them. Nothing here writes a fact
7
7
  // of its own — every change goes out through runWorldCommand, recordTold,
@@ -411,6 +411,10 @@ ${scenarioList.length > 1 ? ` <select id="scenarioSelect" class="deck-sel
411
411
  ${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " selected" : ""}>${escapeHtml(s.label || scenarioLabel(s.worldPayload?.name))}</option>`).join("\n")}
412
412
  </select>` : ""}
413
413
  <button type="button" id="editModeBtn" aria-pressed="false">edit</button>
414
+ <label class="deck-teach" title="With this on, a sentence like &quot;Candle is in the study.&quot; writes a fact into the world instead of running as a command.">
415
+ <input type="checkbox" id="teachToggle">
416
+ teach
417
+ </label>
414
418
  <button type="button" class="deck-info-btn" id="deckInfoBtn" aria-expanded="false" aria-controls="deckInfoPopup" aria-label="about this demo">?</button>
415
419
  <span class="mono deck-turns" id="globalTurnCount">turns: 0</span>
416
420
  </div>
@@ -634,6 +638,8 @@ const MUD_STYLE = `
634
638
  background: rgba(255,255,255,.5); color: var(--mud-ink);
635
639
  }
636
640
  .deck-select:hover { border-color: var(--burrow-glow); }
641
+ .deck-teach { display: flex; align-items: center; gap: .3rem; font-family: ${MONO_STACK}; font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--soil-mid); cursor: pointer; }
642
+ .deck-teach input[type="checkbox"] { accent-color: var(--burrow-glow); }
637
643
  .deck-play { background: var(--mud-ink) !important; color: var(--parchment); border-color: var(--mud-ink) !important; padding: .38rem 1.1rem !important; }
638
644
  .deck-play[aria-pressed="true"] { background: var(--burrow-glow) !important; border-color: var(--burrow-glow) !important; color: var(--mud-ink); }
639
645
  .deck-turns { margin-left: auto; font-size: .74rem; color: var(--soil-mid); }
@@ -1006,7 +1012,7 @@ const MUD_STYLE = `
1006
1012
  --lem-readout: var(--burrow-glow); --lem-readout-bg: var(--soil-deep);
1007
1013
  --lem-chalk: var(--soil-mid);
1008
1014
  }
1009
- .deck-slider, .deck h3 { color: var(--lem-chalk); }
1015
+ .deck-slider, .deck h3, .deck-teach { color: var(--lem-chalk); }
1010
1016
  .deck-slider input[type="range"] { accent-color: var(--burrow-glow); }
1011
1017
  .deck button, .pane-controls button {
1012
1018
  background: var(--lem-face); color: var(--mud-ink); border: 1px solid var(--soil-mid); border-radius: 3px;
@@ -3052,7 +3058,10 @@ function pageScript() {
3052
3058
  // scenario's cast paired with the LAST scenario's pane ids, which is what
3053
3059
  // a null pane-element lookup in renderAll means when it happens.
3054
3060
  for (let i = 0; i < cast.length; i += 1) slotOf[cast[i]] = slots[i];
3055
- const opened = await window.tmct.open(scenario().worldPayload, { characters: everyone(), epoch: nextEpoch });
3061
+ const opened = await window.tmct.open(scenario().worldPayload, {
3062
+ characters: everyone(), epoch: nextEpoch,
3063
+ getTeachEnabled: function () { return el("teachToggle").checked; },
3064
+ });
3056
3065
  if (seq !== bootSeq) return;
3057
3066
  session = opened;
3058
3067
  if (liveRoom) {
@@ -130,16 +130,14 @@ export function chebyshevDistanceBetweenCells(a, b) {
130
130
  return Math.max(Math.abs(Number(ma[1]) - Number(mb[1])), Math.abs(Number(ma[2]) - Number(mb[2])));
131
131
  }
132
132
 
133
- /** A facing word to a Y rotation in radians, `south` (the world's own
134
- * `defaultFacing`) at zero matches `cameraRigFor`'s own FACING_VECTOR
135
- * convention (`south: {x:0,z:1}`, `north: {x:0,z:-1}`, `east: {x:1,z:0}`,
136
- * `west: {x:-1,z:0}`) so an agent mesh turns to face the same way its own
137
- * follow/pov camera rig would sit behind it. A rig's own forward axis may
138
- * need a per-model offset once real assets are in place; this is the
139
- * world-facing convention, not a guarantee about any one GLB's neutral
140
- * pose. Pure. */
133
+ /** A facing word to a Y rotation in radians. Assumes a model's own local
134
+ * forward is +Z (true for fox and goblin, the two rigs in the current
135
+ * manifest, checked by walking each GLB's own bind-pose bone chain) so a
136
+ * rotated mesh matches `cameraRigFor`'s own FACING_VECTOR table exactly for
137
+ * all four cardinals a model with a -Z neutral pose would need its own
138
+ * offset. Pure. */
141
139
  export function yawForFacing(facing) {
142
- const YAW = { south: 0, north: Math.PI, east: -Math.PI / 2, west: Math.PI / 2 };
140
+ const YAW = { south: 0, north: Math.PI, east: Math.PI / 2, west: -Math.PI / 2 };
143
141
  return YAW[facing] ?? YAW.south;
144
142
  }
145
143
 
@@ -352,7 +350,7 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
352
350
 
353
351
  var loadThreeVendor = createThreeVendorLoader();
354
352
  var THREE = null, GLTFLoaderCtor = null, OrbitControlsCtor = null, MeshoptDecoderRef = null;
355
- var gltfLoader = null, loadQueue = createConcurrencyQueue(4), rawLoader = null;
353
+ var gltfLoader = null, loadQueue = createConcurrencyQueue(4);
356
354
  var scene = null, camera3 = null, renderer = null, orbitControls = null, groundMesh = null, raycaster = null;
357
355
  var agentGroups = {};
358
356
  var itemMeshes = {};
@@ -366,10 +364,24 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
366
364
  var lastFrameTs = null;
367
365
  var booted = false;
368
366
 
367
+ // Cache the fetched BYTES, not the parsed scene: one network request per
368
+ // model URL, however many agents share that kind, then a GLTFLoader.parse
369
+ // per caller off the shared ArrayBuffer. Each caller still ends up owning
370
+ // its own object graph, so normalizeToHeight's own guards still hold.
371
+ function fetchGlbBytes(url) {
372
+ return fetch(url).then(function (res) {
373
+ if (!res.ok) throw new Error("model fetch failed with status " + res.status + ": " + url);
374
+ return res.arrayBuffer();
375
+ });
376
+ }
377
+ var glbBytesCache = createCachedLoader(fetchGlbBytes);
378
+
369
379
  function loadGlbRaw(url) {
370
- return loadQueue.run(function () {
371
- return new Promise(function (resolve, reject) {
372
- gltfLoader.load(url, resolve, undefined, reject);
380
+ return glbBytesCache.load(url).then(function (buffer) {
381
+ return loadQueue.run(function () {
382
+ return new Promise(function (resolve, reject) {
383
+ gltfLoader.parse(buffer, THREE.LoaderUtils.extractUrlBase(url), resolve, reject);
384
+ });
373
385
  });
374
386
  });
375
387
  }
@@ -473,15 +485,32 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
473
485
  }
474
486
 
475
487
  // ---- props ---------------------------------------------------------------
488
+ // Box3.setFromObject reads through a parent's world matrix, so measuring a
489
+ // parented object reads whatever that parent's spawn flourish is doing in
490
+ // that frame and applies a scale that depends on the frame. The guards make
491
+ // both misuses fail loudly rather than render a wrong size silently. Keep
492
+ // no backtick in this block: the function's source sits inside the module's
493
+ // own template literal.
476
494
  function normalizeToHeight(object3D, targetHeight) {
495
+ if (object3D.parent) {
496
+ throw new Error("normalizeToHeight refused an object that already has a parent: " + (object3D.name || object3D.uuid));
497
+ }
498
+ if (object3D.userData.tmctNormalized) {
499
+ throw new Error("normalizeToHeight refused a second call on the same object: " + (object3D.name || object3D.uuid));
500
+ }
477
501
  var box = new THREE.Box3().setFromObject(object3D);
478
502
  var size = new THREE.Vector3();
479
503
  box.getSize(size);
480
- var height = size.y || 1;
481
- var scale = targetHeight && height ? targetHeight / height : 1;
504
+ if (!size.y) {
505
+ // Substituting 1 here invented a scale from an unmeasurable height, and
506
+ // that is what produced the house-height mesh.
507
+ throw new Error("normalizeToHeight found no measurable height on: " + (object3D.name || object3D.uuid));
508
+ }
509
+ var scale = targetHeight ? targetHeight / size.y : 1;
482
510
  object3D.scale.setScalar(scale);
483
511
  var seated = new THREE.Box3().setFromObject(object3D);
484
512
  object3D.position.y -= seated.min.y;
513
+ object3D.userData.tmctNormalized = true;
485
514
  }
486
515
 
487
516
  async function loadPropTemplate(asset) {
@@ -516,7 +545,9 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
516
545
  }
517
546
 
518
547
  // ---- items: crumbs and morsels, primitive geometry ------------------------
519
- var CRUMB_RADIUS = 0.08, MORSEL_RADIUS = 0.18;
548
+ // Doubled from 0.08/0.18: against the fox's own 1m targetHeight and a 1-unit
549
+ // cell, the old radii read as barely-visible dots on the board.
550
+ var CRUMB_RADIUS = 0.16, MORSEL_RADIUS = 0.36;
520
551
  function itemGeometryFor(kind) { return new THREE.SphereGeometry(kind === "morsel" ? MORSEL_RADIUS : CRUMB_RADIUS, 10, 8); }
521
552
  function itemMaterialFor(kind) { return new THREE.MeshStandardMaterial({ color: kind === "morsel" ? 0xd98a2b : 0x8c6a3f }); }
522
553
  function itemHeightFor(kind) { return kind === "morsel" ? MORSEL_RADIUS : CRUMB_RADIUS; }
@@ -579,7 +610,7 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
579
610
  agentGroups[id] = entry;
580
611
  var asset = manifestByKind[kind];
581
612
  if (asset) {
582
- loadGlb(modelUrlFor(asset.destPath)).then(function (gltf) {
613
+ loadGlbRaw(modelUrlFor(asset.destPath)).then(function (gltf) {
583
614
  normalizeToHeight(gltf.scene, asset.targetHeight);
584
615
  entry.group.add(gltf.scene);
585
616
  entry.group.visible = true;
@@ -760,6 +791,27 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
760
791
  if (itemMeshes[id]) return itemMeshes[id].cell;
761
792
  return null;
762
793
  },
794
+ // The live agent group's own applied Y rotation, in radians -- an e2e
795
+ // assertion's read, so it can compare the yaw actually applied against
796
+ // the direction an agent travelled between two cells rather than trusting
797
+ // yawForFacing's own output in isolation.
798
+ yawOf: function (id) {
799
+ var entry = agentGroups[id];
800
+ return entry ? entry.group.rotation.y : null;
801
+ },
802
+ // The live agent mesh's world-space height and lowest point, plus the
803
+ // manifest's own targetHeight to compare it against — an e2e assertion's
804
+ // read, so it goes through the group actually in the scene rather than a
805
+ // second, locally invented measurement.
806
+ meshHeightOf: function (id) {
807
+ var entry = agentGroups[id];
808
+ if (!entry || !entry.group || entry.group.children.length === 0) return null;
809
+ var box = new THREE.Box3().setFromObject(entry.group);
810
+ var size = new THREE.Vector3();
811
+ box.getSize(size);
812
+ var asset = manifestByKind[entry.kind];
813
+ return { height: size.y, minY: box.min.y, targetHeight: asset ? asset.targetHeight : null };
814
+ },
763
815
  ready: function () { return booted; },
764
816
  };
765
817
  })();`;
@@ -82,7 +82,7 @@ const NPC_COUNT_MIN = 1;
82
82
  const NPC_COUNT_MAX = 10;
83
83
  const NPC_COUNT_LABELLED = [1, 5, 10];
84
84
  const DEFAULT_NPC_COUNT = 2;
85
- const DEFAULT_DELAY_MS = 650;
85
+ const DEFAULT_DELAY_MS = 220;
86
86
  const DEFAULT_MAX_TURNS = 400;
87
87
  // test/fixtures/mudiii-ticks.json's own board size — the fallback for a
88
88
  // scenario that names no gridSize of its own.
@@ -1216,7 +1216,7 @@ function pageScript() {
1216
1216
  agentsById = {};
1217
1217
  itemsById = {};
1218
1218
  el("chatInput").disabled = false;
1219
- callScene("boot", { propPlacements: props, assetManifest: DATA.assetManifest, gridSize: DATA.gridSize, cellSize: 1 });
1219
+ await callScene("boot", { propPlacements: props, assetManifest: DATA.assetManifest, gridSize: DATA.gridSize, cellSize: 1 });
1220
1220
 
1221
1221
  // The opening board, drawn through the very path a tick takes. Without
1222
1222
  // this the page's first sight of where anything stands is the first tick,
@@ -30,7 +30,13 @@ import {
30
30
  WAVED_PREDICATE,
31
31
  } from "../domain/p2p/facts.mjs";
32
32
  import { generateNodeId } from "../domain/p2p/peer-id.mjs";
33
- import { appendFacts, loadMemory, loadNodeId, saveNodeId, readFactRows, normFactTerm, FACT_CLASS } from "../adapters/memory/core.mjs";
33
+ import {
34
+ RETRACTION_PREDICATE, encodeRetractionValue, decodeRetractionValue,
35
+ } from "../domain/memory/retraction.mjs";
36
+ import {
37
+ appendFacts, appendRetractions, loadMemory, loadNodeId, saveNodeId,
38
+ readFactRows, readRetractions, normFactTerm, factGroupId, factRecordIdForTag, FACT_CLASS,
39
+ } from "../adapters/memory/core.mjs";
34
40
 
35
41
  export const ROOM_IDLE = "idle";
36
42
  export const ROOM_SHARING = "sharing";
@@ -156,6 +162,40 @@ const toWireFacts = (row, identity, fallbackTimestamp) => {
156
162
  }));
157
163
  };
158
164
 
165
+ /** The record ids one provenance string keys once its tags have been relabelled
166
+ * for the wire. Two stores file one assertion under two ids — here under the
167
+ * chat session that taught it, at the peer under the node that sent it — so a
168
+ * retraction that named only the ids it sees locally would bite on one store
169
+ * and miss the other. Naming both is what makes it land either way. */
170
+ function broadcastRecordIds(groupId, provenance, identity, fallbackTimestamp) {
171
+ const ids = [];
172
+ for (const { tag } of wireProvenanceTags(provenance, identity, fallbackTimestamp)) {
173
+ if (tag) ids.push(factRecordIdForTag(groupId, tag));
174
+ }
175
+ return ids;
176
+ }
177
+
178
+ /** A retraction goes out as ONE fact carrying every tag, not one per tag. The
179
+ * per-tag split exists because appendFacts unions an incoming tag against the
180
+ * ones it stores; a retraction merges through its own union instead, and the
181
+ * tags it carries are already relabel fixpoints. */
182
+ const toWireRetraction = (record, identity, fallbackTimestamp) => {
183
+ const groupId = factGroupId(record.subject);
184
+ const { retractedAt, ids } = decodeRetractionValue(record.object);
185
+ const tags = wireProvenanceTags(record.provenance, identity, fallbackTimestamp)
186
+ .map(({ tag }) => tag)
187
+ .filter(Boolean);
188
+ return {
189
+ subject: record.subject,
190
+ predicate: RETRACTION_PREDICATE,
191
+ object: encodeRetractionValue(retractedAt, [
192
+ ...ids,
193
+ ...broadcastRecordIds(groupId, record.provenance, identity, fallbackTimestamp),
194
+ ]),
195
+ provenance: tags.join(" | "),
196
+ };
197
+ };
198
+
159
199
  /** A room: one shared world, one local fact store, and however many direct
160
200
  * peer connections have been made into it.
161
201
  *
@@ -219,6 +259,14 @@ export function createP2pRoom({
219
259
  // fact that arrived from a peer being re-broadcast as if we had authored it.
220
260
  const seenProvenanceById = new Map();
221
261
  let cachedRows = [];
262
+ // A retraction is not a fact row — readFactRows can never return one, because
263
+ // the whole point of the record is that the rows it names are gone. So it
264
+ // gets its own cache and its own diff, keyed on what it actually carries: the
265
+ // ids it suppressed. Those grow by union as peers merge, and a grown record
266
+ // is a change worth broadcasting even though its provenance never moved.
267
+ let cachedRetractions = [];
268
+ const seenRetractionValueById = new Map();
269
+ const retractionDiffValue = (fact) => `${fact.provenance}${fact.object}`;
222
270
 
223
271
  // Store-touching work runs one job at a time, in arrival order. Every path
224
272
  // that reads or writes memoryDir/seenProvenanceById/cachedRows crosses at
@@ -263,10 +311,53 @@ export function createP2pRoom({
263
311
  }
264
312
 
265
313
  async function refreshRows() {
266
- cachedRows = readFactRows(await loadMemory(memoryDir));
314
+ const memory = await loadMemory(memoryDir);
315
+ cachedRows = readFactRows(memory);
316
+ cachedRetractions = readRetractions(memory);
267
317
  return cachedRows;
268
318
  }
269
319
 
320
+ const toWire = (row, identity, timestamp) => (row.predicate === RETRACTION_PREDICATE
321
+ ? [toWireRetraction(row, identity, timestamp)]
322
+ : toWireFacts(row, identity, timestamp));
323
+
324
+ /** Fold the broadcast form of this node's own retractions back into the store.
325
+ * A retraction made here names the ids this store files the assertion under;
326
+ * a peer relaying that same assertion back sends it under the id the RELABEL
327
+ * produces. Merging the broadcast form in is what makes the local refusal
328
+ * cover it, and it is a union, so it settles after one pass. */
329
+ async function alignOwnRetractions() {
330
+ if (!cachedRetractions.length) return;
331
+ const identity = wireIdentity();
332
+ const timestamp = now();
333
+ const expanded = cachedRetractions.map((record) => toWireRetraction(record, identity, timestamp));
334
+ if (expanded.every((wire, i) => wire.object === cachedRetractions[i].object)) return;
335
+ await appendRetractions(memoryDir, expanded);
336
+ await refreshRows();
337
+ }
338
+
339
+ /** The other side of the same re-keying: a retraction that names this node's
340
+ * broadcast id for an assertion also names whatever id this store files that
341
+ * same assertion under. Without it a peer could retract a fact it learned
342
+ * from here and the origin would keep reading its own copy. */
343
+ function localiseRetraction(fact) {
344
+ const groupId = factGroupId(fact.subject);
345
+ const { retractedAt, ids } = decodeRetractionValue(fact.object);
346
+ const named = new Set(ids);
347
+ const row = cachedRows.find((r) => r.id === groupId);
348
+ if (!row) return fact;
349
+ const identity = wireIdentity();
350
+ const timestamp = now();
351
+ const alsoNamed = [];
352
+ for (const assertion of row.assertions || []) {
353
+ if (named.has(assertion.id)) continue;
354
+ const asBroadcast = broadcastRecordIds(groupId, assertion.provenance, identity, timestamp);
355
+ if (asBroadcast.some((id) => named.has(id))) alsoNamed.push(assertion.id);
356
+ }
357
+ if (!alsoNamed.length) return fact;
358
+ return { ...fact, object: encodeRetractionValue(retractedAt, [...ids, ...alsoNamed]) };
359
+ }
360
+
270
361
  /** readFactRows walks `individuals` in array order and the fold reads them in
271
362
  * that order, so two peers holding an identical fact set can still fold it
272
363
  * differently while their arrival orders differ. Sorting the Fact
@@ -293,6 +384,7 @@ export function createP2pRoom({
293
384
  async function baselineSeen() {
294
385
  await refreshRows();
295
386
  for (const row of cachedRows) seenProvenanceById.set(row.id, row.provenance);
387
+ for (const fact of cachedRetractions) seenRetractionValueById.set(fact.id, retractionDiffValue(fact));
296
388
  }
297
389
 
298
390
  async function ensureStarted() {
@@ -322,17 +414,24 @@ export function createP2pRoom({
322
414
  async function flushLocalChange() {
323
415
  await ensureStarted();
324
416
  await refreshRows();
417
+ await alignOwnRetractions();
325
418
  const changed = [];
326
419
  for (const row of cachedRows) {
327
420
  if (seenProvenanceById.get(row.id) === row.provenance) continue;
328
421
  seenProvenanceById.set(row.id, row.provenance);
329
422
  changed.push(row);
330
423
  }
424
+ for (const fact of cachedRetractions) {
425
+ const value = retractionDiffValue(fact);
426
+ if (seenRetractionValueById.get(fact.id) === value) continue;
427
+ seenRetractionValueById.set(fact.id, value);
428
+ changed.push(fact);
429
+ }
331
430
  if (!changed.length) return { broadcast: 0 };
332
431
  const targets = connectedPeers();
333
432
  if (!targets.length) return { broadcast: 0 };
334
433
  const timestamp = now();
335
- const facts = changed.flatMap((row) => toWireFacts(row, wireIdentity(), timestamp));
434
+ const facts = changed.flatMap((row) => toWire(row, wireIdentity(), timestamp));
336
435
  broadcast(opMessage({ from: myPeerId, facts }));
337
436
  return { broadcast: changed.length };
338
437
  }
@@ -343,7 +442,17 @@ export function createP2pRoom({
343
442
  // Flush first: a local fact still waiting to be diffed would otherwise be
344
443
  // recorded as merged below and never leave this browser.
345
444
  await flushLocalChange();
346
- const { ids } = await appendFacts(memoryDir, accepted.map((f) => ({
445
+ // Retractions land BEFORE the assertions in the same batch, so a peer
446
+ // sending both the fact and the record that suppresses it lands them in the
447
+ // order that leaves the fact out. Either order converges — the read fold
448
+ // strips a record a retraction covers however late the retraction arrives —
449
+ // but doing it this way keeps a suppressed record from being written at all.
450
+ const retractions = accepted.filter((f) => f.predicate === RETRACTION_PREDICATE);
451
+ if (retractions.length) await appendRetractions(memoryDir, retractions.map(localiseRetraction));
452
+ const assertions = retractions.length
453
+ ? accepted.filter((f) => f.predicate !== RETRACTION_PREDICATE)
454
+ : accepted;
455
+ const { ids } = assertions.length ? await appendFacts(memoryDir, assertions.map((f) => ({
347
456
  subject: f.subject,
348
457
  predicate: f.predicate,
349
458
  object: f.object,
@@ -353,15 +462,21 @@ export function createP2pRoom({
353
462
  // this is the passthrough that lets a dated taught fact's wire fact
354
463
  // reach it at all.
355
464
  ...(typeof f.observedAt === "string" && f.observedAt ? { observedAt: f.observedAt } : {}),
356
- })));
465
+ }))) : { ids: [] };
357
466
  await sortFactIndividualsById();
358
467
  await refreshRows();
359
468
  const mergedIds = new Set(ids);
360
469
  for (const row of cachedRows) {
361
470
  if (mergedIds.has(row.id)) seenProvenanceById.set(row.id, row.provenance);
362
471
  }
363
- emit(factsListeners, { merged: ids.length, rows: cachedRows });
364
- return { merged: ids.length };
472
+ // Same reason the fact baseline above exists: a retraction that arrived
473
+ // from a peer must not go back out as though this node had authored it.
474
+ // Every peer broadcasts its OWN record when it makes one, so the union each
475
+ // peer ends up with is reached from the originals rather than from relays.
476
+ for (const fact of cachedRetractions) seenRetractionValueById.set(fact.id, retractionDiffValue(fact));
477
+ const merged = ids.length + retractions.length;
478
+ emit(factsListeners, { merged, rows: cachedRows });
479
+ return { merged };
365
480
  }
366
481
 
367
482
  function registerPeer(peerId, peerDisplayName, transport) {
@@ -436,8 +551,10 @@ export function createP2pRoom({
436
551
  case "sync-request": {
437
552
  const facts = await withStore(async () => {
438
553
  await refreshRows();
554
+ await alignOwnRetractions();
439
555
  const timestamp = now();
440
- return syncableFacts(cachedRows).flatMap((row) => toWireFacts(row, wireIdentity(), timestamp));
556
+ return syncableFacts([...cachedRows, ...cachedRetractions])
557
+ .flatMap((row) => toWire(row, wireIdentity(), timestamp));
441
558
  });
442
559
  send(transport, syncResponseMessage({ facts }));
443
560
  return;
@@ -644,7 +761,9 @@ export function createP2pRoom({
644
761
  // from reading to every peer as a brand-new node.
645
762
  nodeId = await resolveStoreNodeId(memoryDir, nodeId || myNodeId);
646
763
  seenProvenanceById.clear();
764
+ seenRetractionValueById.clear();
647
765
  cachedRows = [];
766
+ cachedRetractions = [];
648
767
  const timestamp = now();
649
768
  const identity = [];
650
769
  if (worldId && worldName) identity.push(worldNameFact(worldId, worldName, timestamp));
@@ -655,11 +774,13 @@ export function createP2pRoom({
655
774
  }
656
775
  await refreshRows();
657
776
  for (const row of cachedRows) seenProvenanceById.set(row.id, row.provenance);
777
+ for (const fact of cachedRetractions) seenRetractionValueById.set(fact.id, retractionDiffValue(fact));
658
778
  const targets = connectedPeers();
659
779
  let pushed = 0;
660
780
  if (targets.length) {
661
781
  const wireTimestamp = now();
662
- const facts = syncableFacts(cachedRows).flatMap((row) => toWireFacts(row, wireIdentity(), wireTimestamp));
782
+ const facts = syncableFacts([...cachedRows, ...cachedRetractions])
783
+ .flatMap((row) => toWire(row, wireIdentity(), wireTimestamp));
663
784
  pushed = facts.length;
664
785
  if (facts.length) broadcast(opMessage({ from: myPeerId, facts }));
665
786
  broadcast(syncRequestMessage());