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

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.
@@ -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
+ }
@@ -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 });
@@ -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
@@ -15376,7 +15377,7 @@ async function dispatchTurn(input, { config, source = defaultSource, graph = nul
15376
15377
  // otherwise read as a declarative or an orientation ask.
15377
15378
  {
15378
15379
  const advTurn = await adventureTurn(workingLine, {
15379
- planHolder, memoryDir, sessionId, env, lexicon, graph, cache: factRowsCache, isPlanFrameLine, discourseHolder, actingSubject,
15380
+ planHolder, memoryDir, sessionId, env, lexicon, graph, cache: factRowsCache, isPlanFrameLine, discourseHolder, actingSubject, gameConfig,
15380
15381
  });
15381
15382
  if (advTurn) {
15382
15383
  note(trace, `lane: ${advTurn.note}`);
@@ -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