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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "5.0.0",
3
+ "version": "5.0.2",
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.",
@@ -57,7 +57,6 @@
57
57
  "src/",
58
58
  "!src/adapters/pii-scan.mjs",
59
59
  "!src/domain/corpus-matrix.mjs",
60
- "!src/domain/inflect.mjs",
61
60
  "!src/domain/licences.mjs",
62
61
  "!src/domain/markdown-links.mjs",
63
62
  "!src/domain/pack-manifest.mjs",
@@ -101,11 +100,14 @@
101
100
  "check:pii": "node scripts/pii-lint.mjs",
102
101
  "check:pack": "node scripts/check-pack-manifest.mjs",
103
102
  "check:licences": "node scripts/check-licences.mjs",
103
+ "check:models": "node scripts/check-model-manifest.mjs",
104
+ "check:model-credits": "node scripts/gen-model-credits.mjs --check",
105
+ "gen:model-credits": "node scripts/gen-model-credits.mjs",
104
106
  "check:publint": "npx --no-install publint",
105
107
  "check:tool-docs": "node scripts/generate-tool-docs.mjs --check",
106
108
  "check:budgets": "node scripts/check-tier-budgets.mjs",
107
109
  "check:publish": "node scripts/check-publish.mjs",
108
- "check:all": "npm run check:links && npm run check:pii && npm run check:licences && npm run check:publint && npm run check:tool-docs && npm run check:budgets",
110
+ "check:all": "npm run check:links && npm run check:pii && npm run check:licences && npm run check:models && npm run check:model-credits && npm run check:publint && npm run check:tool-docs && npm run check:budgets",
109
111
  "smoke:deploy": "node scripts/post-deploy-smoke.mjs",
110
112
  "wait:site": "node scripts/wait-for-site.mjs",
111
113
  "chat": "node --disable-warning=ExperimentalWarning bin/tmct.mjs",
@@ -0,0 +1,67 @@
1
+ // inflect.mjs — the regular English -s/-ed/-ing rules, applied to a lemma.
2
+ //
3
+ // WordNet carries lemmas only ("rest" is present, "rests" is absent), and it is
4
+ // the inflected forms that collide with the fuzzy repair tier's targets —
5
+ // "rests" is one edit from "tests". So the real-word collision table expands
6
+ // every lemma through these rules before it looks for collisions.
7
+ //
8
+ // These are the REGULAR rules and nothing else. No irregular table, no stress
9
+ // model: pastOf("run") is "runned" and pastOf("make") is "maked". That is the
10
+ // intended shape. The table's job is to name words the repair tier must not
11
+ // rewrite, and inflectionsOf is generous on purpose (see below) — an extra form
12
+ // costs one repair we decline to make, and the sentence misses honestly, while
13
+ // a missing form costs a real word rewritten into a different question,
14
+ // answered with confidence. The first is the cheaper mistake.
15
+
16
+ import { STOPWORDS } from "./interpret/normalize.mjs";
17
+ import {
18
+ FUZZY_TARGET_WORDS, FUZZY_REPAIR_MIN_LENGTH, fuzzyMatchInSet, fuzzyBound,
19
+ } from "./interpret/fuzzy.mjs";
20
+
21
+ const VOWELS = new Set(["a", "e", "i", "o", "u"]);
22
+ const isVowel = (c) => VOWELS.has(c);
23
+
24
+ /** A single final consonant after a single vowel doubles before -ed/-ing
25
+ * ("run" -> "running"). w, x and y never double. Stress is not modelled, so a
26
+ * second syllable doubles too ("visit" -> "visitting"). */
27
+ export function doublesFinalConsonant(w) {
28
+ const [c3, c2, c1] = [w.at(-3), w.at(-2), w.at(-1)];
29
+ if (!c3 || isVowel(c1) || "wxy".includes(c1)) return false;
30
+ return isVowel(c2) && !isVowel(c3);
31
+ }
32
+
33
+ export function pluralOf(w) {
34
+ if (/(?:s|x|z|ch|sh)$/.test(w)) return `${w}es`;
35
+ if (/[^aeiou]y$/.test(w)) return `${w.slice(0, -1)}ies`;
36
+ return `${w}s`;
37
+ }
38
+
39
+ export function pastOf(w) {
40
+ if (w.endsWith("e")) return `${w}d`;
41
+ if (/[^aeiou]y$/.test(w)) return `${w.slice(0, -1)}ied`;
42
+ if (doublesFinalConsonant(w)) return `${w}${w.at(-1)}ed`;
43
+ return `${w}ed`;
44
+ }
45
+
46
+ export function gerundOf(w) {
47
+ if (w.endsWith("ie")) return `${w.slice(0, -2)}ying`;
48
+ if (w.endsWith("e") && !/(?:ee|oe|ye)$/.test(w)) return `${w.slice(0, -1)}ing`;
49
+ if (doublesFinalConsonant(w)) return `${w}${w.at(-1)}ing`;
50
+ return `${w}ing`;
51
+ }
52
+
53
+ /** Every surface form of `w` the collision table counts as real English. */
54
+ export const inflectionsOf = (w) => [w, pluralOf(w), pastOf(w), gerundOf(w)];
55
+
56
+ /** The words in `realWords` that the repair tier would rewrite onto one of its
57
+ * targets: long enough to reach the tier, not a stopword, not a target itself,
58
+ * and within the fuzzy bound of some target. Sorted, so the table it feeds is
59
+ * reproducible. */
60
+ export function collisionsFrom(realWords) {
61
+ return [...realWords]
62
+ .filter((w) => w.length >= FUZZY_REPAIR_MIN_LENGTH)
63
+ .filter((w) => !STOPWORDS.has(w))
64
+ .filter((w) => !FUZZY_TARGET_WORDS.includes(w))
65
+ .filter((w) => fuzzyMatchInSet(w, FUZZY_TARGET_WORDS, fuzzyBound(w)) !== null)
66
+ .sort();
67
+ }
@@ -29,6 +29,8 @@
29
29
  // right.
30
30
  //
31
31
  // Pure: this module plans and merges summary RECORDS. core.mjs owns the payload.
32
+ // The CRDT vocabulary above (G-Set, join, replicated delete) is pinned in
33
+ // docs/references/papers/crdt.md.
32
34
 
33
35
  const FACT_CLASS = "Fact";
34
36
 
@@ -0,0 +1,416 @@
1
+ // town-square-world.mjs — the pure definition of every town-square board: cell
2
+ // naming, Chebyshev geometry, the prop tables that make buildings solid, the
3
+ // seed taxonomy, and the static fact/rule/meta rows each shipped layout
4
+ // carries. Pure — the only import is the sibling pure-data game-config.mjs, so
5
+ // the structural self-description facts below quote the same shipped defaults
6
+ // the engine actually runs.
7
+ //
8
+ // The one structural difference from spider-fly-world.mjs: grid size is a
9
+ // property of the layout, never a module constant. Three layouts ship at three
10
+ // sizes, so every geometry function takes the size (or the layout) as its first
11
+ // argument. A module-level GRID_SIZE would silently clip the 14x14 chapel board
12
+ // to whichever number was written here.
13
+ //
14
+ // Both scripts/gen-town-square-worlds.mjs (which writes
15
+ // corpus/worlds/src/<layout>.jsonl) and src/services/predator-prey.mjs (the
16
+ // runtime) read the SAME layouts from here, so a shipped world and the engine
17
+ // that plays it cannot drift apart.
18
+
19
+ import { DEFAULT_GAME_CONFIG } from "./game-config.mjs";
20
+
21
+ export const DEFAULT_GRID_SIZE = 12;
22
+ export const DEFAULT_FACING = "south";
23
+
24
+ /** The species each role is cast as. The engine's own MUDIII_ROLES object
25
+ * (src/services/predator-prey.mjs) names the same four words; they are
26
+ * repeated rather than imported because a domain module may not read a
27
+ * service, and a test asserts the two agree. */
28
+ export const CAST_KINDS = Object.freeze({
29
+ predator: "fox",
30
+ prey: "goblin",
31
+ spawnedFood: "crumb",
32
+ placedFood: "morsel",
33
+ });
34
+
35
+ export const cellId = (x, y) => `cell-${x}-${y}`;
36
+
37
+ const CELL_ID_RE = /^cell-(\d+)-(\d+)$/;
38
+
39
+ /** {x,y} from a "cell-<x>-<y>" id, or null when the string isn't one. */
40
+ export function parseCellId(id) {
41
+ const m = CELL_ID_RE.exec(String(id ?? ""));
42
+ return m ? { x: Number(m[1]), y: Number(m[2]) } : null;
43
+ }
44
+
45
+ export const chebyshevDistance = (ax, ay, bx, by) => Math.max(Math.abs(ax - bx), Math.abs(ay - by));
46
+
47
+ export const inBounds = (gridSize, x, y) => x >= 1 && x <= gridSize && y >= 1 && y <= gridSize;
48
+
49
+ /** Every cell within Chebyshev `radius` of (cx, cy), clipped to a `gridSize`
50
+ * board, in raster order. Vision only — a prop's cell is visible, it just
51
+ * can't be walked into (see agent-belief.mjs: buildings block movement, not
52
+ * sight). */
53
+ export function visibleCells(gridSize, cx, cy, radius) {
54
+ const out = [];
55
+ for (let y = Math.max(1, cy - radius); y <= Math.min(gridSize, cy + radius); y += 1) {
56
+ for (let x = Math.max(1, cx - radius); x <= Math.min(gridSize, cx + radius); x += 1) {
57
+ out.push(cellId(x, y));
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ // direction -> (dx, dy). Plain labels reused verbatim from Ashcombe's
64
+ // mgx:has-exit-<direction> predicate (src/services/adventure.mjs) — north
65
+ // decreases y, south increases y, east increases x, west decreases x. Key
66
+ // order is the canonical direction order every consumer iterates in, for
67
+ // deterministic search tie-breaking.
68
+ export const DIRECTION_DELTA = Object.freeze({
69
+ north: Object.freeze({ dx: 0, dy: -1 }),
70
+ south: Object.freeze({ dx: 0, dy: 1 }),
71
+ east: Object.freeze({ dx: 1, dy: 0 }),
72
+ west: Object.freeze({ dx: -1, dy: 0 }),
73
+ });
74
+
75
+ /** The single compass direction from `fromCell` to `toCell` when `toCell` sits
76
+ * EXACTLY one cardinal step away — null for the same cell, a diagonal, or any
77
+ * multi-step gap, so a caller never overstates "adjacent". */
78
+ export function oneStepDirectionBetween(fromCell, toCell) {
79
+ for (const [direction, { dx, dy }] of Object.entries(DIRECTION_DELTA)) {
80
+ if (fromCell.x + dx === toCell.x && fromCell.y + dy === toCell.y) return direction;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ // ---- the prop vocabulary ------------------------------------------------------
86
+
87
+ /** The closed set of noun stems a prop id may use ("house-1" -> "house"). A
88
+ * town square's scenery is authored here and nowhere else, so the set is
89
+ * closed by construction and a reader can tell a prop from an animal or a
90
+ * crumb by its id alone. */
91
+ export const PROP_KINDS = Object.freeze([
92
+ "blacksmith", "bush", "cart", "fence", "house", "inn", "oak", "stall", "well",
93
+ ]);
94
+
95
+ /** The closed set of noun stems a food item's id may use. */
96
+ export const FOOD_KINDS = Object.freeze([CAST_KINDS.spawnedFood, CAST_KINDS.placedFood]);
97
+
98
+ /** The class an individual's id names — "goblin-2" -> "goblin", "crumb-10" ->
99
+ * "crumb". Self-contained, `.toString()`-splice safe. */
100
+ export function agentKindOf(id) {
101
+ return String(id).replace(/-\d+$/, "");
102
+ }
103
+
104
+ /** True for a prop individual's id. A prop is placed with mgx:currently-in
105
+ * exactly like a live agent and is never one. */
106
+ export function isPropId(id) {
107
+ return PROP_KINDS.includes(agentKindOf(id));
108
+ }
109
+
110
+ /** True for a food individual's id. Food is inert: a subject with a cell, no
111
+ * belief, no goal, no plan — the tick payload carries it in `items`, not in
112
+ * `agents`. */
113
+ export function isFoodId(id) {
114
+ return FOOD_KINDS.includes(agentKindOf(id));
115
+ }
116
+
117
+ /** Whether `id` belongs on a rendered AGENT roster built from `state` (a
118
+ * folded world state with a `.removed` Set): live, and neither a prop nor a
119
+ * food item. The one world rule every renderer of `state.placements` shares. */
120
+ export function isLiveRenderableAgent(id, state) {
121
+ return !state.removed.has(id) && !isPropId(id) && !isFoodId(id);
122
+ }
123
+
124
+ /** Every live id of `kind` among `agents`, sorted. `agents` is either a plain
125
+ * `{ id: ... }` roster or a `Map` keyed by id — pass the matching `removed`
126
+ * Set in the Map case to exclude anything the board has taken off it. Pure. */
127
+ export function liveIdsOfKind(agents, kind, removed = null) {
128
+ const re = new RegExp(`^${kind}-\\d+$`);
129
+ const ids = agents instanceof Map ? [...agents.keys()] : Object.keys(agents || {});
130
+ return ids.filter((id) => re.test(id) && !(removed && removed.has(id))).sort();
131
+ }
132
+
133
+ // ---- the three shipped layouts ------------------------------------------------
134
+ // Each is a literal prop table plus its cast counts. `model` is an asset key
135
+ // resolved by the render layer (data/mudiii-assets.json's `key` column), never
136
+ // a file path; `rotation` is degrees, written as a string because a fact object
137
+ // is a term.
138
+
139
+ const layout = (spec) => Object.freeze({ ...spec, props: Object.freeze(spec.props.map((p) => Object.freeze(p))), cast: Object.freeze(spec.cast) });
140
+
141
+ /** The headline square: a terrace of houses down the east side of the north
142
+ * edge, a well at the centre, one stall by the west wall and the inn in the
143
+ * south-east. The prop table is pinned by test/fixtures/mudiii-ticks.json —
144
+ * the ten recorded ticks are a seeded run over exactly this board, so moving
145
+ * a prop moves every cell in the fixture. */
146
+ const TOWN_SQUARE = layout({
147
+ name: "town-square",
148
+ gridSize: 12,
149
+ opening: "a fox prowls the town square; goblins pick over the stalls for scraps. Neither is yours to move. Watch, or address one by name in chat.",
150
+ cast: { predators: 1, prey: 3 },
151
+ props: [
152
+ { id: "house-1", model: "house-1", cell: "cell-8-1", rotation: "180" },
153
+ { id: "house-2", model: "house-2", cell: "cell-8-2", rotation: "180" },
154
+ { id: "house-3", model: "house-3", cell: "cell-8-3", rotation: "180" },
155
+ { id: "well-1", model: "well", cell: "cell-6-7", rotation: "0" },
156
+ { id: "stall-1", model: "market-stall-1", cell: "cell-3-9", rotation: "90" },
157
+ { id: "inn-1", model: "inn", cell: "cell-11-10", rotation: "270" },
158
+ ],
159
+ });
160
+
161
+ /** Market day: two stall rows cut the square into three lanes, with gaps at
162
+ * the west wall, the middle and the east wall so no lane is sealed. */
163
+ const TOWN_SQUARE_MARKET = layout({
164
+ name: "town-square-market",
165
+ gridSize: 10,
166
+ opening: "market day in the town square: two rows of stalls, goblins working the lanes between them, and a fox somewhere among the crowd.",
167
+ cast: { predators: 1, prey: 4 },
168
+ props: [
169
+ { id: "stall-1", model: "market-stall-1", cell: "cell-2-4", rotation: "0" },
170
+ { id: "stall-2", model: "market-stall-2", cell: "cell-3-4", rotation: "0" },
171
+ { id: "stall-3", model: "market-stall-1", cell: "cell-4-4", rotation: "0" },
172
+ { id: "stall-4", model: "market-stall-2", cell: "cell-6-4", rotation: "0" },
173
+ { id: "stall-5", model: "market-stall-1", cell: "cell-7-4", rotation: "0" },
174
+ { id: "stall-6", model: "market-stall-2", cell: "cell-8-4", rotation: "0" },
175
+ { id: "stall-7", model: "market-stall-2", cell: "cell-2-7", rotation: "180" },
176
+ { id: "stall-8", model: "market-stall-1", cell: "cell-3-7", rotation: "180" },
177
+ { id: "stall-9", model: "market-stall-2", cell: "cell-4-7", rotation: "180" },
178
+ { id: "stall-10", model: "market-stall-1", cell: "cell-6-7", rotation: "180" },
179
+ { id: "stall-11", model: "market-stall-2", cell: "cell-7-7", rotation: "180" },
180
+ { id: "stall-12", model: "market-stall-1", cell: "cell-8-7", rotation: "180" },
181
+ { id: "well-1", model: "well", cell: "cell-5-5", rotation: "0" },
182
+ { id: "blacksmith-1", model: "blacksmith", cell: "cell-10-5", rotation: "270" },
183
+ { id: "cart-1", model: "cart", cell: "cell-1-1", rotation: "90" },
184
+ { id: "cart-2", model: "cart", cell: "cell-10-10", rotation: "270" },
185
+ { id: "bush-1", model: "bush", cell: "cell-10-1", rotation: "0" },
186
+ { id: "bush-2", model: "bush", cell: "cell-1-10", rotation: "0" },
187
+ ],
188
+ });
189
+
190
+ /** The chapel corner: an L of buildings in the north-west, a fence line across
191
+ * the south, three oaks. The only shipped layout with two predators, so it is
192
+ * where the avoid branch actually runs. */
193
+ const TOWN_SQUARE_CHAPEL = layout({
194
+ name: "town-square-chapel",
195
+ gridSize: 14,
196
+ opening: "the chapel corner of the town square, fenced to the south and shaded by three oaks. Two foxes hunt here, and two goblins know it.",
197
+ cast: { predators: 2, prey: 2 },
198
+ props: [
199
+ { id: "inn-1", model: "inn", cell: "cell-2-2", rotation: "180" },
200
+ { id: "house-1", model: "house-1", cell: "cell-3-2", rotation: "180" },
201
+ { id: "house-2", model: "house-2", cell: "cell-2-3", rotation: "90" },
202
+ { id: "fence-1", model: "fence", cell: "cell-7-11", rotation: "0" },
203
+ { id: "fence-2", model: "fence", cell: "cell-8-11", rotation: "0" },
204
+ { id: "fence-3", model: "fence", cell: "cell-9-11", rotation: "0" },
205
+ { id: "oak-1", model: "oak-tree", cell: "cell-12-4", rotation: "0" },
206
+ { id: "oak-2", model: "oak-tree", cell: "cell-12-12", rotation: "90" },
207
+ { id: "oak-3", model: "oak-tree", cell: "cell-5-8", rotation: "180" },
208
+ ],
209
+ });
210
+
211
+ export const TOWN_SQUARE_LAYOUTS = Object.freeze({
212
+ [TOWN_SQUARE.name]: TOWN_SQUARE,
213
+ [TOWN_SQUARE_MARKET.name]: TOWN_SQUARE_MARKET,
214
+ [TOWN_SQUARE_CHAPEL.name]: TOWN_SQUARE_CHAPEL,
215
+ });
216
+
217
+ /** Every shipped layout name, in the order the scenario dropdown lists them —
218
+ * the headline square first. */
219
+ export const WORLD_NAMES = Object.freeze(Object.keys(TOWN_SQUARE_LAYOUTS));
220
+
221
+ /** The layout `name` denotes, or null. Never a guessed fallback: a caller that
222
+ * asked for a world this pack doesn't hold gets nothing, not the headline
223
+ * square wearing the wrong name. */
224
+ export function layoutNamed(name) {
225
+ return TOWN_SQUARE_LAYOUTS[String(name ?? "")] ?? null;
226
+ }
227
+
228
+ // ---- solid cells: the one primitive the buildings mean --------------------------
229
+ // A prop's cell is solid. The exit-omission rule below, prey arrival, crumb
230
+ // spawning and the page's own click refusal all read this and nothing else.
231
+
232
+ const solidCache = new WeakMap();
233
+
234
+ /** Every cell a prop stands on, as a Set of cell ids. */
235
+ export function solidCells(lay) {
236
+ let cached = solidCache.get(lay);
237
+ if (!cached) {
238
+ cached = new Set(lay.props.map((p) => p.cell));
239
+ solidCache.set(lay, cached);
240
+ }
241
+ return cached;
242
+ }
243
+
244
+ /** Whether `cell` holds a prop, and so can never be entered. */
245
+ export function isSolid(lay, cell) {
246
+ return solidCells(lay).has(cell);
247
+ }
248
+
249
+ /** Every in-bounds cell no prop stands on, raster order. Where a crumb may be
250
+ * minted, where an agent may stand, and the set the connectivity guard floods
251
+ * through. */
252
+ export function openCells(lay) {
253
+ const solid = solidCells(lay);
254
+ const out = [];
255
+ for (let y = 1; y <= lay.gridSize; y += 1) {
256
+ for (let x = 1; x <= lay.gridSize; x += 1) {
257
+ const id = cellId(x, y);
258
+ if (!solid.has(id)) out.push(id);
259
+ }
260
+ }
261
+ return out;
262
+ }
263
+
264
+ /** Every board-edge cell no prop stands on, raster order — where a spawned
265
+ * prey arrives. Prop-free matters here rather than being a nicety: the
266
+ * headline layout has a house terrace and an inn sitting on the perimeter. */
267
+ export function perimeterCells(lay) {
268
+ const solid = solidCells(lay);
269
+ const out = [];
270
+ for (let y = 1; y <= lay.gridSize; y += 1) {
271
+ for (let x = 1; x <= lay.gridSize; x += 1) {
272
+ if (x !== 1 && x !== lay.gridSize && y !== 1 && y !== lay.gridSize) continue;
273
+ const id = cellId(x, y);
274
+ if (!solid.has(id)) out.push(id);
275
+ }
276
+ }
277
+ return out;
278
+ }
279
+
280
+ // ---- the world's fact rows ------------------------------------------------------
281
+
282
+ /** The seed taxonomy every town-square layout ships. It has one hard job: make
283
+ * objectClassChain reach "food" from both a crumb and a morsel, since that
284
+ * chain IS the forage read's precondition and a goblin must not be able to
285
+ * tell world food from player food. */
286
+ export const SEED_TAXONOMY = Object.freeze([
287
+ Object.freeze(["fox", "canine"]),
288
+ Object.freeze(["canine", "animal"]),
289
+ Object.freeze(["goblin", "humanoid"]),
290
+ Object.freeze(["humanoid", "creature"]),
291
+ Object.freeze(["creature", "animal"]),
292
+ Object.freeze(["crumb", "food"]),
293
+ Object.freeze(["morsel", "food"]),
294
+ Object.freeze(["food", "thing"]),
295
+ Object.freeze(["prop", "thing"]),
296
+ ]);
297
+
298
+ /** Four rows per prop: its class, its cell, the asset key a renderer resolves
299
+ * to a mesh, and its rotation in degrees. Every object is a string — "90",
300
+ * never 90 — because a fact object is a term, and the row validator takes
301
+ * non-empty strings only. */
302
+ export function* propFactRows(lay) {
303
+ for (const prop of lay.props) {
304
+ yield { world: lay.name, kind: "fact", subject: prop.id, predicate: "rdf:type", object: "prop" };
305
+ yield { world: lay.name, kind: "fact", subject: prop.id, predicate: "mgx:currently-in", object: prop.cell };
306
+ yield { world: lay.name, kind: "fact", subject: prop.id, predicate: "mgx:model", object: prop.model };
307
+ yield { world: lay.name, kind: "fact", subject: prop.id, predicate: "mgx:rotation", object: prop.rotation };
308
+ }
309
+ }
310
+
311
+ /** Every fact row the shipped world source carries: cell typing, grid
312
+ * adjacency with prop cells cut out of it, the prop placements, the seed
313
+ * taxonomy and the structural self-description.
314
+ *
315
+ * The omission rule, which is the whole of what a building does: EVERY
316
+ * in-bounds cell is typed, solid ones included, so "what is at cell-8-1?"
317
+ * grounds instead of missing on an undeclared individual. A solid cell emits
318
+ * no exits out, and no neighbour emits an exit into it. That is symmetric by
319
+ * construction, so a one-way edge into a building cannot be authored by
320
+ * accident, and gridApplyActions — which builds its whole successor closure
321
+ * from mgx:has-exit-* rows — routes around the buildings with no planner code
322
+ * at all. */
323
+ export function* worldFactRows(lay) {
324
+ const solid = solidCells(lay);
325
+ for (let y = 1; y <= lay.gridSize; y += 1) {
326
+ for (let x = 1; x <= lay.gridSize; x += 1) {
327
+ const id = cellId(x, y);
328
+ yield { world: lay.name, kind: "fact", subject: id, predicate: "rdf:type", object: "cell" };
329
+ if (solid.has(id)) continue;
330
+ for (const [direction, { dx, dy }] of Object.entries(DIRECTION_DELTA)) {
331
+ const nx = x + dx;
332
+ const ny = y + dy;
333
+ if (!inBounds(lay.gridSize, nx, ny)) continue;
334
+ const target = cellId(nx, ny);
335
+ if (solid.has(target)) continue;
336
+ yield { world: lay.name, kind: "fact", subject: id, predicate: `mgx:has-exit-${direction}`, object: target };
337
+ }
338
+ }
339
+ }
340
+ yield* propFactRows(lay);
341
+ for (const [subject, superclass] of SEED_TAXONOMY) {
342
+ yield { world: lay.name, kind: "fact", subject, predicate: "rdfs:subClassOf", object: superclass };
343
+ }
344
+ yield* structuralFactRows(lay);
345
+ }
346
+
347
+ const pluralize = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
348
+
349
+ /** The board, the props and the cast restated as askable facts, so "what is
350
+ * the board?" and "what is the vision radius?" ground instead of missing.
351
+ * Every number is either fixed layout geometry or a shipped tuning default
352
+ * from game-config.mjs; the tunable ones say "by default", because a slider
353
+ * drag or a tmct.toml override moves them and a fact a slider falsifies is a
354
+ * lie. */
355
+ export function* structuralFactRows(lay) {
356
+ const fact = (subject, predicate, object) => ({ world: lay.name, kind: "fact", subject, predicate, object });
357
+ const knobs = DEFAULT_GAME_CONFIG.mudiii;
358
+ const { predator, prey, spawnedFood, placedFood } = CAST_KINDS;
359
+ const solidCount = solidCells(lay).size;
360
+ const cellCount = lay.gridSize * lay.gridSize;
361
+
362
+ yield fact("board", "rdf:type", "grid");
363
+ yield fact("board", "mgx:hasA", pluralize(lay.gridSize, "row"));
364
+ yield fact("board", "mgx:hasA", pluralize(lay.gridSize, "column"));
365
+ yield fact("board", "mgx:hasA", pluralize(cellCount, "cell"));
366
+ yield fact("square", "mgx:cover", `${pluralize(cellCount - solidCount, "open cell")} and ${pluralize(solidCount, "cell")} a prop stands on`);
367
+ yield fact("square", "mgx:start-with", `${pluralize(lay.cast.predators, predator)} and ${pluralize(lay.cast.prey, prey)} by default`);
368
+ yield fact("square", "mgx:hasA", `limit of ${pluralize(knobs.maxPreyPopulation, prey)} and ${pluralize(knobs.maxFoodItems, "food item")} at once by default`);
369
+ yield fact("prop", "mgx:cover", "one cell each, which nothing can walk into");
370
+
371
+ yield fact(predator, "mgx:hasA", `vision radius of ${pluralize(knobs.predatorVisionRadius, "cell")} by default`);
372
+ yield fact(predator, "mgx:start-with", `mass ${knobs.predatorInitialMass} by default`);
373
+ yield fact(predator, "mgx:lose", `${knobs.predatorMassDecrementPerTurn} mass per turn by default`);
374
+ yield fact(prey, "mgx:hasA", `vision radius of ${pluralize(knobs.preyVisionRadius, "cell")} by default`);
375
+ yield fact(prey, "mgx:start-with", `mass ${knobs.preyInitialMass} by default`);
376
+ yield fact(prey, "mgx:lose", `${knobs.preyMassDecrementPerTurn} mass per turn by default`);
377
+ yield fact(prey, "mgx:arrive", `every ${pluralize(knobs.preySpawnIntervalTurns, "turn")} at the edge of the board by default`);
378
+ yield fact(spawnedFood, "mgx:arrive", `every ${pluralize(knobs.foodSpawnIntervalTurns, "turn")} on any open cell by default`);
379
+ yield fact(spawnedFood, "mgx:start-with", `mass ${knobs.spawnedFoodMass} by default`);
380
+ yield fact(placedFood, "mgx:start-with", `mass ${knobs.placedFoodMass} by default`);
381
+ yield fact(placedFood, "mgx:arrive", "where the player puts it");
382
+
383
+ // The page's own slider label is "vision radius", so that exact term has to
384
+ // describe too, not just the per-class rows above. One combined row while
385
+ // the two class defaults agree, one row per class once they split — never a
386
+ // combined claim the config doesn't make. This cast splits them: the
387
+ // predator sees further than the prey, and the one-cell band that opens is
388
+ // the point.
389
+ if (knobs.predatorVisionRadius === knobs.preyVisionRadius) {
390
+ yield fact("vision radius", "mgx:hasProperty", `${pluralize(knobs.predatorVisionRadius, "cell")} for both the ${predator} and the ${prey} by default`);
391
+ } else {
392
+ yield fact("vision radius", "mgx:hasProperty", `${pluralize(knobs.predatorVisionRadius, "cell")} for the ${predator} by default`);
393
+ yield fact("vision radius", "mgx:hasProperty", `${pluralize(knobs.preyVisionRadius, "cell")} for the ${prey} by default`);
394
+ }
395
+ }
396
+
397
+ /** The layout's one meta row (its opening line). */
398
+ export function worldMetaRow(lay) {
399
+ return { world: lay.name, kind: "meta", opening: lay.opening };
400
+ }
401
+
402
+ /** A minimal, inert rule-row family, so scripts/build-worlds-pack.mjs's shared
403
+ * validator ("every world needs at least one rule row") passes. The engine
404
+ * never reads these back: grid movement is hand-written pathfinding over
405
+ * findActionPath/findReachableSet, not the taught action-Rule DSL, whose
406
+ * precondition shapes cannot express grid adjacency. */
407
+ export function* worldRuleRows(lay) {
408
+ yield {
409
+ world: lay.name, kind: "rule", name: "go", ruleKind: "action-signature",
410
+ slots: { subjectClass: "animal", targetClass: "cell" },
411
+ };
412
+ yield {
413
+ world: lay.name, kind: "rule", name: "go", ruleKind: "action-effect",
414
+ slots: { predicate: "currently-in", subjectRole: "subject", objectRole: "target" },
415
+ };
416
+ }
@@ -63,6 +63,7 @@ import { subClassParents, ancestryChain, clusterSenses } from "../domain/sense-s
63
63
  import { relatedForTerm } from "../domain/skos-view.mjs";
64
64
  import { adventureTurn, unclaimedAdventureOpening, foldWorldState } from "./adventure.mjs";
65
65
  import { spiderFlyTurn } from "./spider-fly-turn.mjs";
66
+ import { mudiiiTurn } from "./mudiii-turn.mjs";
66
67
  import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
67
68
 
68
69
  // Composition: the chat surface supplies the domain parser's default lemma/POS
@@ -15411,6 +15412,29 @@ async function dispatchTurn(input, { config, source = defaultSource, graph = nul
15411
15412
  }
15412
15413
  }
15413
15414
 
15415
+ // MUDIII — the town-square openers (one per shipped layout), stop, the
15416
+ // addressed teach-frame for agents and food alike, the bare tick, the
15417
+ // food-placement verb, and a live game's own turns. It sits AFTER spider-fly
15418
+ // and BEFORE the unclaimed-opener decline below on purpose: this lane's
15419
+ // opener vocabulary never includes the bare word "mudiii", so "play mudiii"
15420
+ // falls through to that decline and gets named honestly rather than being
15421
+ // silently absorbed here.
15422
+ {
15423
+ const mTurn = await mudiiiTurn(workingLine, {
15424
+ planHolder, memoryDir, env, cache: factRowsCache, isPlanFrameLine, gameConfig: resolvedGameConfig,
15425
+ });
15426
+ if (mTurn) {
15427
+ note(trace, `lane: ${mTurn.note}`);
15428
+ if (mTurn.goal) note(trace, `goal: ${mTurn.goal}`);
15429
+ const result = plainTurn(workingLine, mTurn.text, { via: "game", miss: !!mTurn.miss, focus });
15430
+ if (mTurn.goal) result.goal = mTurn.goal;
15431
+ result.lane = mTurn.lane;
15432
+ const rec = withLast(result, mTurn.goal ?? "watch the town square");
15433
+ rec.planState = planHolder.state;
15434
+ return rec;
15435
+ }
15436
+ }
15437
+
15414
15438
  // A "play X" naming no world EITHER game lane above claimed — last resort,
15415
15439
  // checked only once the adventure lane's own fallthrough and spider-fly's
15416
15440
  // own opener have both passed on the line, so this never outguesses a
@@ -69,6 +69,33 @@ const TRUE_ONLY_FLAG_PREDICATES = ["mgx:is-container", "mgx:is-predator", "mgx:i
69
69
 
70
70
  // ---- rendering ---------------------------------------------------------------
71
71
 
72
+ /** A GRID world's folded state in the shape this module's own renderer and
73
+ * differ read. A room world folds a placement into `{ predicate, object }`,
74
+ * because a thing can be carried, hidden or fixed as well as stood in. A grid
75
+ * world folds it into `{ cell, turn, epoch }`: on a board there is one way to
76
+ * be somewhere, and the extra fields carry the (epoch, turn) rank instead.
77
+ *
78
+ * Handed a grid fold directly, `renderMudEditorText` reads `place.predicate`
79
+ * as undefined and renders no placement sentence at all, and
80
+ * `planMudEditorSync` reads every placement line as a change and re-appends
81
+ * it. One translation here keeps both honest, and keeps a second copy of the
82
+ * mapping out of each page that needs it.
83
+ *
84
+ * `masses` comes across too, since a grid fold names it `mass`. There is no
85
+ * openness on a board, so that map is empty. Pure, self-contained (spliced by
86
+ * `.toString()` alongside the two functions it feeds). */
87
+ export function gridWorldEditorState(state) {
88
+ const placements = new Map();
89
+ for (const [subject, place] of (state && state.placements) || new Map()) {
90
+ if (place && place.cell) placements.set(subject, { predicate: "mgx:currently-in", object: place.cell });
91
+ }
92
+ const masses = new Map();
93
+ for (const [subject, mass] of (state && state.mass) || new Map()) {
94
+ if (mass && mass.value !== undefined) masses.set(subject, { value: mass.value });
95
+ }
96
+ return { placements, masses, openness: new Map() };
97
+ }
98
+
72
99
  /** The whole burrow's editable facts as plain sentences, one per line, sorted by
73
100
  * (subject, predicate, object) so two edits apart produce a reviewable diff
74
101
  * rather than a reshuffle. Placement and openness come from the FOLDED state, so