@polycode-projects/the-mechanical-code-talker 4.1.8 → 5.0.0

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 CHANGED
@@ -1408,7 +1408,7 @@ writes graded rows you can diff between runs:
1408
1408
 
1409
1409
  - `npm run chatbench:run` measures chat quality against CEFR-graded English
1410
1410
  cases with deterministic tier-1 checks (the full tuning loop is in
1411
- `SKILL_BENCHMARK_CEFR_ENGLISH.md`);
1411
+ `.claude/skills/benchmark-cefr-english/SKILL.md`);
1412
1412
  - `npm run infbench` generates inference cases, then runs each through both
1413
1413
  drive points, the reasoning kernel and the chat surface;
1414
1414
  - `npm run agentbench:run` measures the tool-loop behaviour, and every
@@ -0,0 +1,201 @@
1
+ {
2
+ "_readme": [
3
+ "Closed allowlist. A later copy agent may copy ONLY the exact sourcePath -> destPath pairs listed in `assets` below, and must not read, list, or copy anything else out of ../world-of-claudecraft. If a model isn't a row here, it doesn't get copied, no matter how well it fits.",
4
+ "The eastbrook_* trap: those files sit alphabetically right next to the correct ones and look better, but CREDITS.md licenses the whole eastbrook_* kit 'With the project only' (ships with that project, not extractable into a standalone demo like mudiii.html). Take the unprefixed sibling every time: market_stand_1.glb not eastbrook_market_stall.glb, inn.glb not eastbrook_inn.glb, blacksmith.glb not eastbrook_smithy.glb, well.glb not eastbrook_civic_well_beacon.glb.",
5
+ "Every GLB listed here uses EXT_meshopt_compression and KHR_mesh_quantization (verified by parsing each file's JSON chunk directly, not by trusting docs). Three additionally use EXT_texture_webp: creatures/goblin.glb, foliage/oak_1.glb, foliage/bush.glb. three.js's GLTFLoader decodes KHR_mesh_quantization and EXT_texture_webp natively, so nobody needs to hunt for a decoder for either of those. EXT_meshopt_compression is the one exception and needs exactly one line: loader.setMeshoptDecoder(MeshoptDecoder). See commands.vendorMeshoptDecoderLine below, and commands.fallbackTranscodeToPlainGlb if vendoring the decoder turns out to be undesirable.",
6
+ "Food markers (cheese, apples, apple crate) are primitive geometry in v1, not models. The KayKit food props (food_cheese, food_apple_*, food_crate_large_apples) are almost certainly CC0 at source, but no CREDITS.md row names a KayKit resource pack, so they're unrecorded rather than free to take and are excluded here. Fetching the genuine KayKit Resource Bits pack from source (https://kaykit.itch.io) and adding a CREDITS.md row for it is a clean later upgrade, not a wall.",
7
+ "The predator is creatures/fox.glb, not creatures/wolf_basic.glb. wolf_basic.glb looks like the obvious predator model and is close in name to the CREDITS 'Animated creatures' row, but wolf_basic.glb itself is an unrecorded custom Tripo-generated project asset, not the CC0 Quaternius wolf. fox.glb is covered by the CC0 Quaternius creatures row by name, shares the same quadruped rig (Idle/Walk/Gallop/Attack/hit-react/Death via manifest.ts's animal() clip map), and additionally ships an 'Eating' clip verified in the GLB itself -- useful for an eaten-flourish. goblin.glb is the prey/scavenger, matching world-of-claudecraft's own mob_kobold casting (Idle/Walk/Run/Attack/HitRecieve/Death) -- note HitRecieve is misspelled in the asset itself and is reproduced verbatim below, not corrected.",
8
+ "targetHeight is the model's own measured bounding-box height in metres (computed from each GLB's POSITION accessor bounds through its node transform chain, dequantizing normalized SHORT/BYTE components first), not an arbitrary guess -- these packs already ship in a consistent real-world scale, so no further per-item normalization judgment call was needed for the static props. The two character rigs (fox, goblin) use world-of-claudecraft's own manifest.ts height values instead, since their POSITION accessors are in skinned bind-pose space and a plain node-transform bbox walk (as used for the static props) does not account for joint transforms."
9
+ ],
10
+ "commands": {
11
+ "verifyBytes": "stat -f '%z' <file> # BSD/macOS; use `stat -c '%s' <file>` on GNU/Linux",
12
+ "verifySha256": "shasum -a 256 <file>",
13
+ "vendorMeshoptDecoderLine": "loader.setMeshoptDecoder(MeshoptDecoder) // three.js GLTFLoader; the only line meshopt compression needs -- quantization and webp need nothing",
14
+ "fallbackTranscodeToPlainGlb": "npx gltf-transform copy <in>.glb <out>.glb # decodes EXT_meshopt_compression on read; since no compress step is re-applied, the written GLB carries plain (uncompressed) buffers -- use only if vendoring the decoder is undesirable"
15
+ },
16
+ "assets": [
17
+ {
18
+ "key": "fox",
19
+ "sourcePath": "public/models/creatures/fox.glb",
20
+ "destPath": "public/models/creatures/fox.glb",
21
+ "licence": "CC0-1.0",
22
+ "author": "Quaternius",
23
+ "source": "https://poly.pizza/u/Quaternius · https://quaternius.com",
24
+ "bytes": 332748,
25
+ "sha256": "2694028b47e84bad3b9d985aa7743dae02a3bcfddd6becd73b46c747a1e067b3",
26
+ "targetHeight": 1.0,
27
+ "clips": {
28
+ "idle": "Idle",
29
+ "walk": "Walk",
30
+ "run": "Gallop",
31
+ "attack": "Attack",
32
+ "hit": ["Idle_HitReact_Left", "Idle_HitReact_Right"],
33
+ "death": "Death",
34
+ "eat": "Eating"
35
+ }
36
+ },
37
+ {
38
+ "key": "goblin",
39
+ "sourcePath": "public/models/creatures/goblin.glb",
40
+ "destPath": "public/models/creatures/goblin.glb",
41
+ "licence": "CC0-1.0",
42
+ "author": "Quaternius",
43
+ "source": "https://poly.pizza/u/Quaternius · https://quaternius.com",
44
+ "bytes": 47924,
45
+ "sha256": "2f02ce927a8871554f3931655d75f1abb8237d36be8a19a35b09920d0812bc3c",
46
+ "targetHeight": 2.1,
47
+ "clips": {
48
+ "idle": "Idle",
49
+ "walk": "Walk",
50
+ "run": "Run",
51
+ "attack": "Attack",
52
+ "hit": ["HitRecieve"],
53
+ "death": "Death"
54
+ }
55
+ },
56
+ {
57
+ "key": "well",
58
+ "sourcePath": "public/models/props/well.glb",
59
+ "destPath": "public/models/props/well.glb",
60
+ "licence": "CC0-1.0",
61
+ "author": "Quaternius",
62
+ "source": "https://quaternius.com/packs/medievalvillage.html",
63
+ "bytes": 27788,
64
+ "sha256": "9308ba09ea3bed048984eeb14307b0557c5ea11404556d33afa9ed904b6ee123",
65
+ "targetHeight": 1.25,
66
+ "clips": null
67
+ },
68
+ {
69
+ "key": "house-1",
70
+ "sourcePath": "public/models/props/house_1.glb",
71
+ "destPath": "public/models/props/house_1.glb",
72
+ "licence": "CC0-1.0",
73
+ "author": "Quaternius",
74
+ "source": "https://quaternius.com/packs/medievalvillage.html",
75
+ "bytes": 84484,
76
+ "sha256": "87c8306f95e335ce01150ef3fb90b52cabc15e047077f98d1f25df8d76a95b9e",
77
+ "targetHeight": 3.39,
78
+ "clips": null
79
+ },
80
+ {
81
+ "key": "house-2",
82
+ "sourcePath": "public/models/props/house_2.glb",
83
+ "destPath": "public/models/props/house_2.glb",
84
+ "licence": "CC0-1.0",
85
+ "author": "Quaternius",
86
+ "source": "https://quaternius.com/packs/medievalvillage.html",
87
+ "bytes": 103180,
88
+ "sha256": "ede3299a2b09bdf66fc56cb56187ae1cdd673ee23eb44b75a40aa38cd8855e93",
89
+ "targetHeight": 3.25,
90
+ "clips": null
91
+ },
92
+ {
93
+ "key": "house-3",
94
+ "sourcePath": "public/models/props/house_3.glb",
95
+ "destPath": "public/models/props/house_3.glb",
96
+ "licence": "CC0-1.0",
97
+ "author": "Quaternius",
98
+ "source": "https://quaternius.com/packs/medievalvillage.html",
99
+ "bytes": 43696,
100
+ "sha256": "3b426a1d8894d3423c0a32c61527dfcbf79e9c0727fa9b83889cf6fa93d9b969",
101
+ "targetHeight": 1.07,
102
+ "clips": null
103
+ },
104
+ {
105
+ "key": "inn",
106
+ "sourcePath": "public/models/props/inn.glb",
107
+ "destPath": "public/models/props/inn.glb",
108
+ "licence": "CC0-1.0",
109
+ "author": "Quaternius",
110
+ "source": "https://quaternius.com/packs/medievalvillage.html",
111
+ "bytes": 92748,
112
+ "sha256": "a1386fe4db504f5110df670b91f4e5f6fdc3baf545cc023fb21ebcc457867a04",
113
+ "targetHeight": 3.49,
114
+ "clips": null
115
+ },
116
+ {
117
+ "key": "blacksmith",
118
+ "sourcePath": "public/models/props/blacksmith.glb",
119
+ "destPath": "public/models/props/blacksmith.glb",
120
+ "licence": "CC0-1.0",
121
+ "author": "Quaternius",
122
+ "source": "https://quaternius.com/packs/medievalvillage.html",
123
+ "bytes": 97444,
124
+ "sha256": "893c49e06a5c7e5967caad0a666666fb898f3d6823e81dec66b8cf96b3aca3ac",
125
+ "targetHeight": 3.0,
126
+ "clips": null
127
+ },
128
+ {
129
+ "key": "market-stall-1",
130
+ "sourcePath": "public/models/props/market_stand_1.glb",
131
+ "destPath": "public/models/props/market_stand_1.glb",
132
+ "licence": "CC0-1.0",
133
+ "author": "Quaternius",
134
+ "source": "https://quaternius.com/packs/medievalvillage.html",
135
+ "bytes": 25184,
136
+ "sha256": "d59645bb16549882280e091e41804cca09dac4a693772960bde0418e5d0229b5",
137
+ "targetHeight": 1.05,
138
+ "clips": null
139
+ },
140
+ {
141
+ "key": "market-stall-2",
142
+ "sourcePath": "public/models/props/market_stand_2.glb",
143
+ "destPath": "public/models/props/market_stand_2.glb",
144
+ "licence": "CC0-1.0",
145
+ "author": "Quaternius",
146
+ "source": "https://quaternius.com/packs/medievalvillage.html",
147
+ "bytes": 18868,
148
+ "sha256": "b81f32ab47d17e5b76386814554fe5c1419189049c727d5b84a46a2278ce3597",
149
+ "targetHeight": 1.04,
150
+ "clips": null
151
+ },
152
+ {
153
+ "key": "cart",
154
+ "sourcePath": "public/models/props/cart.glb",
155
+ "destPath": "public/models/props/cart.glb",
156
+ "licence": "CC0-1.0",
157
+ "author": "Quaternius",
158
+ "source": "https://quaternius.com/packs/medievalvillage.html",
159
+ "bytes": 30972,
160
+ "sha256": "8bb07d235482439d0e4f1d9152d932b6487cdb09b3331c668f882461401f43d9",
161
+ "targetHeight": 0.8,
162
+ "clips": null
163
+ },
164
+ {
165
+ "key": "fence",
166
+ "sourcePath": "public/models/props/fence.glb",
167
+ "destPath": "public/models/props/fence.glb",
168
+ "licence": "CC0-1.0",
169
+ "author": "Quaternius",
170
+ "source": "https://quaternius.com/packs/medievalvillage.html",
171
+ "bytes": 4836,
172
+ "sha256": "ff22bf2593398774f20e7e6f82f3a9184e326cc4c45cd780a0981590a54172c0",
173
+ "targetHeight": 0.33,
174
+ "clips": null
175
+ },
176
+ {
177
+ "key": "oak-tree",
178
+ "sourcePath": "public/models/foliage/oak_1.glb",
179
+ "destPath": "public/models/foliage/oak_1.glb",
180
+ "licence": "CC0-1.0",
181
+ "author": "Quaternius",
182
+ "source": "https://quaternius.itch.io/stylized-nature-megakit",
183
+ "bytes": 184216,
184
+ "sha256": "e8c8b7e02173490bfe5e802fad47c1b7824b787bb6c9274163bde7fda065c102",
185
+ "targetHeight": 7.26,
186
+ "clips": null
187
+ },
188
+ {
189
+ "key": "bush",
190
+ "sourcePath": "public/models/foliage/bush.glb",
191
+ "destPath": "public/models/foliage/bush.glb",
192
+ "licence": "CC0-1.0",
193
+ "author": "Quaternius",
194
+ "source": "https://quaternius.itch.io/stylized-nature-megakit",
195
+ "bytes": 49064,
196
+ "sha256": "614172ada35fc6048487a6af6d77059a5c031e0a93eff9d58036649ea0d16c12",
197
+ "targetHeight": 1.57,
198
+ "clips": null
199
+ }
200
+ ]
201
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "4.1.8",
3
+ "version": "5.0.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; indexes a repo on request (tmct index) or reads any producer's graph.",
@@ -162,11 +162,13 @@
162
162
  "build:research-bundle": "node scripts/build-research-bundle.mjs",
163
163
  "build:code-explorer-bundle": "node scripts/build-code-explorer-bundle.mjs",
164
164
  "build:mud-bundle": "node scripts/build-mud-bundle.mjs",
165
+ "build:mudiii-bundle": "node scripts/build-mudiii-bundle.mjs",
165
166
  "build:electron": "node scripts/build-electron-app.mjs",
166
167
  "electron": "electron electron/main.mjs",
167
168
  "test:electron": "node --test electron/smoke.test.mjs",
168
169
  "build:sprites-bundle": "node scripts/build-sprites-bundle.mjs",
169
170
  "build:wink-vendor": "node scripts/build-wink-vendor.mjs",
171
+ "build:three-vendor": "node scripts/build-three-vendor.mjs",
170
172
  "build:chat-seed": "node scripts/build-chat-seed.mjs",
171
173
  "build:demo-graph": "node scripts/build-demo-graph.mjs",
172
174
  "build:demo-pack": "node scripts/build-demo-pack.mjs",
@@ -190,6 +192,7 @@
190
192
  "ink-testing-library": "4.0.0",
191
193
  "playwright": "1.61.1",
192
194
  "publint": "0.3.21",
195
+ "three": "0.185.1",
193
196
  "yaml": "2.9.0"
194
197
  }
195
198
  }
@@ -0,0 +1,96 @@
1
+ // agent-belief.mjs — what one agent believes about where another agent or
2
+ // item is, this tick. Ground truth inside its own vision radius, else the
3
+ // newest position it was told, else nothing at all. The "nothing at all" rung
4
+ // is the point: an agent that has not seen and has not been told believes
5
+ // nothing, and a deceived agent stays visibly wrong until it looks.
6
+ //
7
+ // Vision is a plain Chebyshev radius with NO line of sight. Buildings block
8
+ // movement (a world pack omits its mgx:has-exit-* edges into an occupied cell)
9
+ // and do not block sight. Anyone tempted to add a raycast here should know
10
+ // that nothing else in the engine models occlusion, so belief would then
11
+ // disagree with the planner, the goal line and the belief panel at once.
12
+ //
13
+ // The module imports nothing, deliberately. Every world that runs on the
14
+ // planning engine shares it, and each world sizes its own board, so belief
15
+ // must not inherit any one world's grid constants. Asking a world for its
16
+ // clipped list of visible cells is what once bound this test to a 10x10 board
17
+ // and made every cell outside it invisible on a larger one.
18
+
19
+ export const DEFAULT_VISION_RADIUS = 4;
20
+
21
+ const CELL_ID_RE = /^cell-(\d+)-(\d+)$/;
22
+
23
+ const cellCoordsOf = (id) => {
24
+ const m = CELL_ID_RE.exec(String(id ?? ""));
25
+ return m ? { x: Number(m[1]), y: Number(m[2]) } : null;
26
+ };
27
+
28
+ const cellIdOf = (x, y) => `cell-${x}-${y}`;
29
+
30
+ const chebyshevSteps = (ax, ay, bx, by) => Math.max(Math.abs(ax - bx), Math.abs(ay - by));
31
+
32
+ /** Where `observerSubject` currently believes `targetSubject` to be, as an
33
+ * `{ x, y }` cell, or null for no belief at all.
34
+ *
35
+ * Four rungs, in order:
36
+ *
37
+ * 1. A target the board has removed (eaten, starved, hatched) is believed
38
+ * nowhere, and no told fact can put it back. A claim about an id that was
39
+ * never placed at all is a different case and still lands, which is what
40
+ * keeps deception working.
41
+ * 2. Ground truth, when the target's real cell sits within `visionRadius`
42
+ * Chebyshev steps of `observerCell`.
43
+ * 3. The newest told fact addressed to this observer about this target —
44
+ * `opts.toldFacts` rows shaped `{ subject, toAgent, cell, turn }`.
45
+ * 4. Otherwise null.
46
+ *
47
+ * `state` is a folded world state: `.placements` (subject -> `{ cell }`) and
48
+ * `.removed` (a Set of subjects off the board). Pure. */
49
+ export function believedCellOf(targetSubject, observerSubject, observerCell, state, opts = {}) {
50
+ if (state.removed.has(targetSubject)) return null;
51
+ const { visionRadius = DEFAULT_VISION_RADIUS, toldFacts = [] } = opts;
52
+ const place = state.placements.get(targetSubject);
53
+ const at = place ? cellCoordsOf(place.cell) : null;
54
+ if (at && chebyshevSteps(observerCell.x, observerCell.y, at.x, at.y) <= visionRadius) return at;
55
+ const told = toldFacts
56
+ .filter((f) => f.toAgent === observerSubject && f.subject === targetSubject)
57
+ .sort((a, b) => (b.turn ?? 0) - (a.turn ?? 0))[0];
58
+ return told ? cellCoordsOf(told.cell) : null;
59
+ }
60
+
61
+ /** The nearest candidate (by believed Chebyshev distance) an observer has
62
+ * any belief about at all — null when the observer believes nothing about
63
+ * any candidate. `candidates` must already be in a deterministic order
64
+ * (ties favor the earlier candidate). */
65
+ export function nearestBelievedTarget(observerSubject, observerCell, candidates, state, opts = {}) {
66
+ let best = null;
67
+ let bestDist = Infinity;
68
+ for (const subject of candidates) {
69
+ const cell = believedCellOf(subject, observerSubject, observerCell, state, opts);
70
+ if (!cell) continue;
71
+ const dist = chebyshevSteps(observerCell.x, observerCell.y, cell.x, cell.y);
72
+ if (dist < bestDist) { bestDist = dist; best = { subject, cell }; }
73
+ }
74
+ return best;
75
+ }
76
+
77
+ /** A full "world knowledge graph" snapshot for one observer this tick: every
78
+ * OTHER named candidate's believed cell (believedCellOf — ground truth
79
+ * inside vision, else the newest told fact addressed to this observer,
80
+ * else null for "unknown") — feeds a per-agent belief panel. Deliberately
81
+ * never ground truth: showing the observer's own honest gap
82
+ * between belief and reality (visibly widened by a deceiving pill or a fed
83
+ * false fact) is the whole point of that panel. Returns a plain
84
+ * `{ [candidateId]: cellId | null }` map. Exported as the read path for
85
+ * what one agent can currently observe — every caller (a tick loop, a viz
86
+ * panel, a chat lane) reads the same computation, never a re-derived copy
87
+ * of it. */
88
+ export function beliefSnapshotFor(observerSubject, observerCell, candidateIds, state, opts) {
89
+ const belief = {};
90
+ for (const candidateId of candidateIds) {
91
+ if (candidateId === observerSubject) continue;
92
+ const cell = believedCellOf(candidateId, observerSubject, observerCell, state, opts);
93
+ belief[candidateId] = cell ? cellIdOf(cell.x, cell.y) : null;
94
+ }
95
+ return belief;
96
+ }
@@ -10,7 +10,7 @@
10
10
  "ask.mjs metaFallbackEntityAnswer (\"X is a Y in this codebase, defined in Z ...\")"
11
11
  ],
12
12
  "deliberatelyNotUsedBy": [
13
- "ask.mjs renderCore whereShape (\"X is defined in Y at line(s) Z\" / \"X is defined in Y (no line span...)\") -- test-benchmarks/chatbench/graded-pool-max.jsonl pins this exact substring as ground truth for 11 'where is X defined' cases (2 in the always-run promoted subset), and that pool is append-only/never-edited-mid-arc per SKILL_BENCHMARK_CEFR_ENGLISH.md."
13
+ "ask.mjs renderCore whereShape (\"X is defined in Y at line(s) Z\" / \"X is defined in Y (no line span...)\") -- test-benchmarks/chatbench/graded-pool-max.jsonl pins this exact substring as ground truth for 11 'where is X defined' cases (2 in the always-run promoted subset), and that pool is append-only/never-edited-mid-arc per .claude/skills/benchmark-cefr-english/SKILL.md."
14
14
  ]
15
15
  },
16
16
  "is-dated": {
@@ -56,11 +56,43 @@ export const DEFAULT_GAME_CONFIG = Object.freeze({
56
56
  meerkatSpeed: 1,
57
57
  meerkatDigReach: 1,
58
58
  }),
59
+ // The town square's knobs are keyed by role, not by species: its cast is a
60
+ // predator/prey pair by construction, so a later pair is a new roles object
61
+ // and no edit here. (mud above went the other way because its cast is
62
+ // authored per world.) The drains sit on mud's scale against the deck's own
63
+ // 400-turn default: a prey that never eats starves after about 133 turns, a
64
+ // predator after 250.
65
+ //
66
+ // maxPreyPopulation and maxFoodItems are the two knobs that keep that
67
+ // arithmetic from running away. Prey arrive every 5 turns and live ~133, so
68
+ // an uncapped 400-turn run ends with roughly 80 of them on a 144-cell board
69
+ // — a solid carpet of goblins, and every tick paying for their planning.
70
+ // Food spawns faster still. Both caps are checked before a spawn, so
71
+ // reaching one simply skips that turn's arrival.
72
+ mudiii: Object.freeze({
73
+ predatorInitialMass: 20,
74
+ predatorMassDecrementPerTurn: 0.08,
75
+ predatorVisionRadius: 4,
76
+ preyInitialMass: 8,
77
+ preyMassDecrementPerTurn: 0.06,
78
+ preyVisionRadius: 3,
79
+ preySpawnIntervalTurns: 5,
80
+ foodSpawnIntervalTurns: 3,
81
+ spawnedFoodMass: 1,
82
+ placedFoodMass: 2,
83
+ maxPreyPopulation: 6,
84
+ maxFoodItems: 8,
85
+ }),
59
86
  guessNumber: Object.freeze({
60
87
  defaultLo: 1,
61
88
  defaultHi: 100,
62
89
  maxBound: 1_000_000_000,
63
90
  }),
91
+ // Whether the adventure surface accepts teaching. One knob behind a page
92
+ // checkbox, the CLI flag and the corpus rows, so all three agree.
93
+ adventure: Object.freeze({
94
+ teach: false,
95
+ }),
64
96
  planning: Object.freeze({
65
97
  maxDepth: 300,
66
98
  }),
@@ -107,6 +139,25 @@ const MUD_KEY_MAP = Object.freeze({
107
139
  meerkat_dig_reach: "meerkatDigReach",
108
140
  });
109
141
 
142
+ const MUDIII_KEY_MAP = Object.freeze({
143
+ predator_initial_mass: "predatorInitialMass",
144
+ predator_mass_decrement_per_turn: "predatorMassDecrementPerTurn",
145
+ predator_vision_radius: "predatorVisionRadius",
146
+ prey_initial_mass: "preyInitialMass",
147
+ prey_mass_decrement_per_turn: "preyMassDecrementPerTurn",
148
+ prey_vision_radius: "preyVisionRadius",
149
+ prey_spawn_interval_turns: "preySpawnIntervalTurns",
150
+ food_spawn_interval_turns: "foodSpawnIntervalTurns",
151
+ spawned_food_mass: "spawnedFoodMass",
152
+ placed_food_mass: "placedFoodMass",
153
+ max_prey_population: "maxPreyPopulation",
154
+ max_food_items: "maxFoodItems",
155
+ });
156
+
157
+ const ADVENTURE_KEY_MAP = Object.freeze({
158
+ teach: "teach",
159
+ });
160
+
110
161
  const GUESS_NUMBER_KEY_MAP = Object.freeze({
111
162
  default_lo: "defaultLo",
112
163
  default_hi: "defaultHi",
@@ -154,6 +205,18 @@ export function massScaleFor(cls, config) {
154
205
  return null;
155
206
  }
156
207
 
208
+ /** The mass denominator a town-square HUD bar scales against, from a resolved
209
+ * `mudiii` section. `role` is "predator" or "prey"; anything else — a crumb,
210
+ * a prop, a role a later cast adds — has no denominator and gets null, never
211
+ * a guessed number. Separate from massScaleFor above on purpose: that one
212
+ * takes a species literal off the spider-and-fly page, and folding a second
213
+ * vocabulary into it would turn a two-line read into a registry. Pure. */
214
+ export function mudiiiMassScaleFor(role, config) {
215
+ if (role === "predator") return config?.predatorInitialMass ?? null;
216
+ if (role === "prey") return config?.preyInitialMass ?? null;
217
+ return null;
218
+ }
219
+
157
220
  /**
158
221
  * Fold a normalized tmct.toml's `games`/`planning` tables (the raw sparse
159
222
  * pass-through src/adapters/toml-config.mjs's normalizeConfig produces —
@@ -168,7 +231,9 @@ export function resolveGameConfig(toml) {
168
231
  return {
169
232
  spiderFly: mergeSection(DEFAULT_GAME_CONFIG.spiderFly, games["spider-fly"], SPIDER_FLY_KEY_MAP),
170
233
  mud: mergeSection(DEFAULT_GAME_CONFIG.mud, games.mud, MUD_KEY_MAP),
234
+ mudiii: mergeSection(DEFAULT_GAME_CONFIG.mudiii, games.mudiii, MUDIII_KEY_MAP),
171
235
  guessNumber: mergeSection(DEFAULT_GAME_CONFIG.guessNumber, games["guess-number"], GUESS_NUMBER_KEY_MAP),
236
+ adventure: mergeSection(DEFAULT_GAME_CONFIG.adventure, games.adventure, ADVENTURE_KEY_MAP),
172
237
  planning: mergeSection(DEFAULT_GAME_CONFIG.planning, toml?.planning, PLANNING_KEY_MAP),
173
238
  };
174
239
  }
@@ -358,6 +358,42 @@ export function planWorldEditorSync(rows, state, triples) {
358
358
  return { toAppend, toRemoveIds };
359
359
  }
360
360
 
361
+ /** The additive half of planWorldEditorSync, for ONE already-parsed triple:
362
+ * the rows a single taught sentence implies, and never a retraction. A whole
363
+ * document says what the world contains, so a fact missing from it has gone;
364
+ * one sentence only ever says what it says, so nothing it leaves out is
365
+ * evidence of anything. Re-asserting a fact the world already holds appends
366
+ * nothing — `reason` says which of the two happened, in the caller's own
367
+ * words. Pure. */
368
+ export function planTaughtTriple(rows, state, triple) {
369
+ if (!triple?.subject || !triple?.object) return { toAppend: [], reason: "nothing parsed" };
370
+ if (triple.kind === PLACEMENT_KIND) {
371
+ const current = state?.placements?.get(triple.subject);
372
+ if (current && current.predicate === triple.predicate && current.object === triple.object) {
373
+ return { toAppend: [], reason: `${triple.subject} is already ${triple.predicate} ${triple.object}` };
374
+ }
375
+ return {
376
+ toAppend: [triple],
377
+ reason: current
378
+ ? `${triple.subject} moves from ${current.object} to ${triple.object}`
379
+ : `${triple.subject} is placed ${triple.predicate} ${triple.object}`,
380
+ };
381
+ }
382
+ if (triple.kind === OPENNESS_KIND) {
383
+ const current = state?.openness?.get(triple.subject);
384
+ const wantOpen = triple.object === "true";
385
+ if (current && current.open === wantOpen) {
386
+ return { toAppend: [], reason: `${triple.subject} is already ${wantOpen ? "open" : "closed"}` };
387
+ }
388
+ return { toAppend: [triple], reason: `${triple.subject} becomes ${wantOpen ? "open" : "closed"}` };
389
+ }
390
+ const key = tripleKey(triple);
391
+ if (editableOtherRows(rows).some((r) => tripleKey(r) === key)) {
392
+ return { toAppend: [], reason: `the world already says ${key}` };
393
+ }
394
+ return { toAppend: [triple], reason: `the world gains ${key}` };
395
+ }
396
+
361
397
  // ---- cursor-driven suggestions ---------------------------------------------
362
398
 
363
399
  export { wordBeforeCursor } from "./viz-theme.mjs";
@@ -18,6 +18,9 @@ import { compileDomain, precondHolds, roleBinding } from "../domain/domain.mjs";
18
18
  import { getWorldsPackProvider } from "../adapters/corpus/worlds-pack.mjs";
19
19
  import { appendFacts, appendRule, loadMemory, normFactTerm, readFactRows, readRuleRows } from "../adapters/memory/core.mjs";
20
20
  import { COMPLETIONS_STORE, generateCompletion } from "./completions.mjs";
21
+ import { parseEditorLine, planTaughtTriple } from "./adventure-editor.mjs";
22
+ import { parseMudEditorLine, planTaughtMudTriple } from "./mud-editor.mjs";
23
+ import { worldTeachTurn } from "./world-teach.mjs";
21
24
 
22
25
  // ---- recognizers: the closed opening/stop set --------------------------------
23
26
 
@@ -563,6 +566,20 @@ export function roomAffordances(rows, state, here, actingSubject = "player") {
563
566
 
564
567
  const affordanceSuffix = (actions) => (actions.length ? ` You can: ${actions.join(", ")}.` : "");
565
568
 
569
+ /** The auto-relook line every state-changing turn ends on: the room read back
570
+ * from a FRESH load of the store, in the same shape a manual "look"
571
+ * produces, so nobody has to retype "look" to see what just changed. Every
572
+ * writer that changes the world shares this one renderer — a second copy
573
+ * would drift the moment the digest or the affordance list moved. */
574
+ export async function worldRelook(room, { memoryDir, graph = null, actingSubject = "player" }) {
575
+ const memory = await loadMemory(memoryDir);
576
+ const rows = readFactRows(memory);
577
+ const state = foldWorldState(worldActionRows(rows));
578
+ const digest = await worldDigest(room, { memoryDir, memory, rows, state, graph, actingSubject });
579
+ const actions = roomAffordances(rows, state, room, actingSubject);
580
+ return `you are in the ${room}. ${digest ?? "Nothing more about it is written down yet."}${affordanceSuffix(actions)}`;
581
+ }
582
+
566
583
  /** The effect predicate a family writes (its action-effect row's slot, with
567
584
  * the mgx: prefix rule readers re-attach). Null when the family carries no
568
585
  * effect row — unlock's family stays signature-only (its instrument match
@@ -1218,7 +1235,7 @@ function freshRoomId(rows, here, direction) {
1218
1235
  * whole nested dig path ("carrot-sett-1-north-east-east"), which is an id, not
1219
1236
  * a name anyone can read. `alsoTaken` holds the ids minted earlier in this
1220
1237
  * same dig, which are not in `rows` yet. Pure. */
1221
- function freshObjectId(rows, kind, alsoTaken) {
1238
+ export function freshObjectId(rows, kind, alsoTaken = new Set()) {
1222
1239
  const taken = (id) => alsoTaken.has(id) || (rows || []).some((r) => r.subject === id || r.object === id);
1223
1240
  for (let n = 1; n <= (rows || []).length + 2; n += 1) {
1224
1241
  if (!taken(`${kind}-${n}`)) return `${kind}-${n}`;
@@ -1237,7 +1254,7 @@ function declaredKindsOr(rows, roomClass, predicate, fallback) {
1237
1254
  * nothing when the class declares no mass. eat reads the instance's mass, so a
1238
1255
  * dug carrot with none would be worth the flat default however the world
1239
1256
  * values a carrot. Pure. */
1240
- function classMassFacts(rows, instance, kind) {
1257
+ export function classMassFacts(rows, instance, kind) {
1241
1258
  const mass = factObjects(rows, kind, MASS_PREDICATE)[0];
1242
1259
  return mass ? [{ subject: instance, predicate: MASS_PREDICATE, object: mass }] : [];
1243
1260
  }
@@ -1911,15 +1928,7 @@ export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache
1911
1928
  const npcPass = runNpcPass({ rows, state, k, families, playerRoomAfter });
1912
1929
  await writeWorldTurn(memoryDir, world, k, [...facts, ...npcPass.writes], cache);
1913
1930
  const text2 = npcPass.lines.length ? `${text} ${npcPass.lines.join(" ")}` : text;
1914
- // Auto-relook: every state-changing command ends in the same shape a
1915
- // manual "look" produces, read from the FRESH post-write state, so the
1916
- // player is never left to retype "look" to see what just changed.
1917
- const freshMemory = await loadMemory(memoryDir);
1918
- const freshRows = readFactRows(freshMemory);
1919
- const freshState = foldWorldState(worldActionRows(freshRows));
1920
- const relookDigest = await worldDigest(playerRoomAfter, { memoryDir, memory: freshMemory, rows: freshRows, state: freshState, graph, actingSubject });
1921
- const actions = roomAffordances(freshRows, freshState, playerRoomAfter, actingSubject);
1922
- const relook = `you are in the ${playerRoomAfter}. ${relookDigest ?? "Nothing more about it is written down yet."}${affordanceSuffix(actions)}`;
1931
+ const relook = await worldRelook(playerRoomAfter, { memoryDir, graph, actingSubject });
1923
1932
  return answer(
1924
1933
  `${text2} ${relook}`,
1925
1934
  noteFor(`${detail}; turn ${k} snapshots written through appendFacts${npcPass.writes.length ? `; NPC pass fired ${npcPass.writes.length} scheduled move(s)` : ""}; auto-relook appended for the ${playerRoomAfter}`),
@@ -2362,7 +2371,7 @@ async function addressedLine(line, { memoryDir }) {
2362
2371
  * recognizer, injected so the two lanes can never disagree about what a plan
2363
2372
  * frame is.
2364
2373
  */
2365
- export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "", env, lexicon = null, graph = null, cache = null, isPlanFrameLine = () => false, discourseHolder = null, actingSubject = "player" }) {
2374
+ export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "", env, lexicon = null, graph = null, cache = null, isPlanFrameLine = () => false, discourseHolder = null, actingSubject = "player", gameConfig = null }) {
2366
2375
  const slot = planHolder?.state ?? null;
2367
2376
  const adventure = slot?.adventure ?? null;
2368
2377
  const opening = matchAdventureOpening(line);
@@ -2418,11 +2427,11 @@ export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "
2418
2427
  note: "ADVENTURE — a plan frame arrived mid-adventure; the slot holds one thing at a time",
2419
2428
  };
2420
2429
  }
2421
- const direct = await liveWorldAnswer(line, { world: adventure.world, memoryDir, env, graph, cache, lexicon, discourseHolder, actingSubject });
2430
+ const direct = await liveWorldAnswer(line, { world: adventure.world, memoryDir, env, graph, cache, lexicon, discourseHolder, actingSubject, gameConfig });
2422
2431
  if (direct) return direct;
2423
2432
  const addressed = await addressedLine(line, { memoryDir });
2424
2433
  if (addressed) {
2425
- const readdressed = await liveWorldAnswer(addressed, { world: adventure.world, memoryDir, env, graph, cache, lexicon, discourseHolder, actingSubject });
2434
+ const readdressed = await liveWorldAnswer(addressed, { world: adventure.world, memoryDir, env, graph, cache, lexicon, discourseHolder, actingSubject, gameConfig });
2426
2435
  if (readdressed) return readdressed;
2427
2436
  }
2428
2437
  return null; // a mid-game aside — the ordinary lanes answer, world untouched
@@ -2433,8 +2442,29 @@ export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "
2433
2442
  * lets an ordinary mid-game question keep its own lane. Split out from the
2434
2443
  * lane itself so a line carrying a vocative can be re-offered here once,
2435
2444
  * stripped, without the two paths ever drifting apart. */
2436
- async function liveWorldAnswer(line, { world, memoryDir, env, graph, cache, lexicon, discourseHolder, actingSubject }) {
2445
+ async function liveWorldAnswer(line, { world, memoryDir, env, graph, cache, lexicon, discourseHolder, actingSubject, gameConfig }) {
2437
2446
  if (INVENTORY_RE.test(line)) return inventoryAnswer({ memoryDir, graph, actingSubject });
2447
+ // The teach switch runs BEFORE the imperative parse, and that ordering is
2448
+ // the point. parseImperative fuzzy-repairs a leading noun into a verb when
2449
+ // one is an edit away ("book" -> "look"), so a declarative sentence about
2450
+ // such a noun would be executed as a command and decline on its own
2451
+ // residue. With teach on, the sentence is read as a fact first; with teach
2452
+ // off, nothing here runs and the lane behaves exactly as it always has.
2453
+ if (gameConfig?.adventure?.teach && memoryDir) {
2454
+ const teachRows = readFactRows(await loadMemory(memoryDir));
2455
+ const taught = await worldTeachTurn(line, {
2456
+ // A burrow says placement with different words than a manor does, so
2457
+ // the sentence table follows the world, never the lane. The world's own
2458
+ // origin fact is the test: only a burrow measures digs from one.
2459
+ ...(originRoomOf(teachRows)
2460
+ ? { parseLine: parseMudEditorLine, planTriple: planTaughtMudTriple }
2461
+ : { parseLine: parseEditorLine, planTriple: planTaughtTriple }),
2462
+ rows: teachRows,
2463
+ state: foldWorldState(worldActionRows(teachRows)),
2464
+ memoryDir, world, actingSubject, cache, graph,
2465
+ });
2466
+ if (taught) return taught;
2467
+ }
2438
2468
  const parsed = parseImperative(line, await worldAwareLexicon(memoryDir, lexicon));
2439
2469
  if (parsed) {
2440
2470
  const bound = await bindPronouns(parsed, { discourseHolder, memoryDir, actingSubject });
@@ -15376,7 +15376,7 @@ async function dispatchTurn(input, { config, source = defaultSource, graph = nul
15376
15376
  // otherwise read as a declarative or an orientation ask.
15377
15377
  {
15378
15378
  const advTurn = await adventureTurn(workingLine, {
15379
- planHolder, memoryDir, sessionId, env, lexicon, graph, cache: factRowsCache, isPlanFrameLine, discourseHolder, actingSubject,
15379
+ planHolder, memoryDir, sessionId, env, lexicon, graph, cache: factRowsCache, isPlanFrameLine, discourseHolder, actingSubject, gameConfig,
15380
15380
  });
15381
15381
  if (advTurn) {
15382
15382
  note(trace, `lane: ${advTurn.note}`);