@polycode-projects/the-mechanical-code-talker 5.0.2 → 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.
- package/data/mudiii-assets.json +26 -1
- package/package.json +1 -1
- package/src/adapters/memory/core.mjs +236 -18
- package/src/domain/ask-vocab.mjs +1 -1
- package/src/domain/ask.mjs +20 -10
- package/src/domain/game-config.mjs +7 -0
- package/src/domain/interpret/normalize.mjs +4 -0
- package/src/domain/memory/retraction.mjs +232 -0
- package/src/domain/p2p/sync-filter.mjs +9 -1
- package/src/domain/spider-fly-world.mjs +69 -22
- package/src/domain/syllogise.mjs +128 -75
- package/src/services/adventure-autoplay.mjs +2 -2
- package/src/services/adventure-viz.mjs +12 -7
- package/src/services/chat.mjs +42 -14
- package/src/services/mud-turn.mjs +1 -1
- package/src/services/mud-viz.mjs +11 -2
- package/src/services/mudiii-scene.mjs +69 -17
- package/src/services/mudiii-viz.mjs +2 -2
- package/src/services/p2p-room.mjs +130 -9
- package/src/services/predator-prey.mjs +524 -69
- package/src/services/spider-fly-turn.mjs +128 -56
- package/src/services/spider-fly-viz.mjs +9 -12
- package/src/services/world-teach.mjs +3 -3
- package/src/surfaces/web/adventure-browser-entry.mjs +12 -3
- package/src/surfaces/web/memory-ask-browser.bundle.js +126 -126
- package/src/surfaces/web/mud-browser-entry.mjs +10 -3
- package/src/surfaces/web/spider-fly-browser-entry.mjs +26 -22
- package/src/services/spider-fly.mjs +0 -943
|
@@ -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
|
|
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
|
-
*
|
|
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 "Candle is in the study." 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></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
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
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);
|
package/src/services/chat.mjs
CHANGED
|
@@ -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
|
|
4802
|
-
// real and is the only thing that decides:
|
|
4803
|
-
// - found:
|
|
4804
|
-
//
|
|
4805
|
-
//
|
|
4806
|
-
//
|
|
4807
|
-
//
|
|
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:false → subject⊑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 {
|
|
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
|
-
|
|
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
|
|
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,
|
package/src/services/mud-viz.mjs
CHANGED
|
@@ -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 "Candle is in the study." 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, {
|
|
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
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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:
|
|
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)
|
|
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
|
|
371
|
-
return
|
|
372
|
-
|
|
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
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
|
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
|
|
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());
|