@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,670 @@
1
+ // mudiii-turn.mjs — the chat lane for the headless town-square game: loading
2
+ // one of the three shipped layouts into the session's memory store, the stop
3
+ // command, the addressed teach-frame (extended to the food channel spider-fly
4
+ // has no equivalent of), the bare "tick" command, the player's own food-
5
+ // placement verb, and the belief and orientation asides. The fifth lane on
6
+ // the shared plan slot, shaped exactly like spider-fly-turn.mjs (PLAN_MUD_
7
+ // MUDIII.md, "The chat lane") with vocabulary from predator-prey.mjs's own
8
+ // MUDIII_ROLES: fox hunts goblin, goblins forage crumbs and morsels.
9
+ //
10
+ // This module never plans a move or runs the ecology pass itself — every bit
11
+ // of game logic (fold, pathfinding, belief, ecology, food placement) lives in
12
+ // predator-prey.mjs; this file only recognizes chat shapes, resolves them to
13
+ // that engine's own interface, and renders the result as chat text.
14
+
15
+ import {
16
+ TOWN_SQUARE_LAYOUTS, DEFAULT_GRID_SIZE, DIRECTION_DELTA,
17
+ cellId, parseCellId, inBounds, chebyshevDistance, oneStepDirectionBetween,
18
+ agentKindOf, liveIdsOfKind, layoutNamed,
19
+ } from "../domain/town-square-world.mjs";
20
+ import {
21
+ MUDIII_ROLES, foldTownSquareState, startTownSquareGame, runTownSquareTick,
22
+ placeFood, roleOfId, beliefSnapshotFor,
23
+ } from "./predator-prey.mjs";
24
+ import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
25
+ import { getWorldsPackProvider } from "../adapters/corpus/worlds-pack.mjs";
26
+ import { appendFacts, appendRule, loadMemory, readFactRows } from "../adapters/memory/core.mjs";
27
+ import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
28
+
29
+ const PREDATOR_KIND = MUDIII_ROLES.predator.kind; // "fox"
30
+ const PREY_KIND = MUDIII_ROLES.prey.kind; // "goblin"
31
+ const SPAWNED_FOOD_KIND = MUDIII_ROLES.food.spawnedKind; // "crumb"
32
+ const PLACED_FOOD_KIND = MUDIII_ROLES.food.placedKind; // "morsel"
33
+
34
+ // ---- recognizers: the closed opening/stop/tick/address set -------------------
35
+
36
+ // One opener per shipped layout, plus a role-named alias for the headline
37
+ // square. Closed vocabulary (visit/watch/enter/start/begin — never "play",
38
+ // which is adventure's own named-opener verb and would otherwise race it for
39
+ // "play town square"). None of these ever collides with adventure's own
40
+ // generic opener (requires the literal word "adventure") or spider-fly's
41
+ // (requires the literal word "spider").
42
+ const MUDIII_HEADLINE_OPEN_RE =
43
+ /^(?:let'?s\s+)?(?:visit|watch|enter|start|begin)\s+(?:the\s+)?town\s+square(?:\s+game)?[.!?\s]*$/i;
44
+ const MUDIII_FOX_GOBLIN_OPEN_RE =
45
+ /^(?:let'?s\s+)?watch\s+the\s+foxe?s?\s+and\s+(?:the\s+)?goblins?[.!?\s]*$/i;
46
+ const MUDIII_MARKET_OPEN_RE =
47
+ /^(?:let'?s\s+)?(?:visit|watch|enter|start|begin)\s+(?:the\s+)?market\s+(?:square|day)(?:\s+game)?[.!?\s]*$/i;
48
+ const MUDIII_CHAPEL_OPEN_RE =
49
+ /^(?:let'?s\s+)?(?:visit|watch|enter|start|begin)\s+(?:the\s+)?chapel\s+corner(?:\s+game)?[.!?\s]*$/i;
50
+
51
+ /** The layout name an opening line names, or null when the line opens
52
+ * nothing this lane recognizes. Shape-only, closed vocabulary — never a
53
+ * generic "play X" grammar, so a name this pack does not ship (e.g. "play
54
+ * mudiii") stays unclaimed and falls to chat's own last-resort honest
55
+ * decline rather than this lane guessing at it. */
56
+ function matchMudiiiOpening(line) {
57
+ const l = String(line).trim();
58
+ if (MUDIII_HEADLINE_OPEN_RE.test(l) || MUDIII_FOX_GOBLIN_OPEN_RE.test(l)) return "town-square";
59
+ if (MUDIII_MARKET_OPEN_RE.test(l)) return "town-square-market";
60
+ if (MUDIII_CHAPEL_OPEN_RE.test(l)) return "town-square-chapel";
61
+ return null;
62
+ }
63
+
64
+ // "stop watching" is this game's own stop word, same reasoning as
65
+ // spider-fly's: there is nothing to "play" in the sense of typing moves.
66
+ const MUDIII_STOP_RE =
67
+ /^(?:stop\s+(?:watching|playing)|quit\s+(?:the\s+)?(?:town\s+square\s+)?game|end\s+the\s+town\s+square\s+game|leave\s+the\s+game)[.!?\s]*$/i;
68
+ // The bare tick command, styled after spider-fly-turn.mjs's own (itself
69
+ // styled after the plan lane's PLAN_NEXT_RE).
70
+ const MUDIII_TICK_RE = /^(?:tick|next\s+turn|advance(?:\s+the\s+turn)?)[.!?\s]*$/i;
71
+
72
+ // The addressed teach-frame: "@fox the goblin is west" / "@fox-1 the goblin
73
+ // is at cell-7-3". Only a fox or a goblin can be ADDRESSED (only an agent has
74
+ // belief to plant), but the SUBJECT of the claim can also be a crumb or a
75
+ // morsel — the one extension spider-fly's teach-frame has no equivalent of,
76
+ // since food is a forage target a goblin can be lied to about. A closed
77
+ // regex, not a route through the general grammar, mirroring spider-fly-
78
+ // turn.mjs's own reasoning (the general placement/copula grammar hits real
79
+ // gaps for this exact shape).
80
+ const MUDIII_ADDRESS_LEAD_RE = /^@(fox|goblin)(?:-(\d+))?\b/i;
81
+ const MUDIII_TOLD_RE = new RegExp(
82
+ "^@(fox|goblin)(?:-(\\d+))?[,:]?\\s+the\\s+(fox|goblin|crumb|morsel)(?:-(\\d+))?\\s+is\\s+"
83
+ + "(?:(north|south|east|west)|at\\s+(cell-\\d+-\\d+))[.!?\\s]*$",
84
+ "i",
85
+ );
86
+
87
+ // The observable-facts read: "what does the goblin see?" — belief only,
88
+ // never ground truth.
89
+ const MUDIII_SEE_RE = /^what (?:does|can) the (fox|goblin)(?:-(\d+))?\s+see[.!?\s]*$/i;
90
+
91
+ // The player's own verb: "put food at cell-3-4" (primary, never shadowed —
92
+ // the adventure imperative grammar's own "put" arm hard-requires a literal
93
+ // "in", so "at" never parses there) and "drop a morsel at cell-3-4"
94
+ // (secondary alias: free while a mudiii game is the only thing live on the
95
+ // shared slot, but shadowed by the adventure/mud imperative grammar's bare-
96
+ // object "drop" arm should one of those be live in the same session instead —
97
+ // which can't happen while THIS game holds the slot, since the slot holds
98
+ // one thing at a time).
99
+ const MUDIII_PUT_FOOD_RE = /^(?:put|place)\s+(?:a\s+|some\s+)?(?:food|morsel)\s+(?:at|on)\s+(cell-\d+-\d+)[.!?\s]*$/i;
100
+ const MUDIII_DROP_FOOD_RE = /^drop\s+(?:a\s+|some\s+)?(?:morsel|food)\s+(?:at|on)\s+(cell-\d+-\d+)[.!?\s]*$/i;
101
+
102
+ const WORLD_OPENING_FALLBACK =
103
+ "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.";
104
+
105
+ // ---- the opening turn: load the shipped board through the worlds pack -------
106
+
107
+ async function openMudiiiGame(world, { planHolder, memoryDir, env, cache, gameConfig = DEFAULT_GAME_CONFIG }) {
108
+ if (!memoryDir) {
109
+ return {
110
+ text: "the town square needs a session with a memory store to hold the board — start tmct inside a repo first.",
111
+ lane: "game-inform",
112
+ note: "MUDIII — opening declined: no memory store to load the board into",
113
+ };
114
+ }
115
+ const provider = getWorldsPackProvider(env);
116
+ let payload = null;
117
+ try { payload = await provider.load(world); } catch { payload = null; }
118
+ if (!payload) {
119
+ return {
120
+ text: 'no worlds pack here — the town square ships in corpus/worlds/ (or the directory TMCT_WORLDS_PACK_DIR names), and it is missing or unreadable, so there is no board to load.',
121
+ lane: "game-inform",
122
+ note: "MUDIII — opening declined: the worlds pack is absent/unreadable",
123
+ };
124
+ }
125
+
126
+ const tag = worldProvenanceTag(world);
127
+ await appendFacts(memoryDir, payload.facts.map((f) => ({
128
+ subject: f.subject, predicate: f.predicate, object: f.object, provenance: tag,
129
+ })));
130
+ for (const rule of payload.rules) {
131
+ try { await appendRule(memoryDir, { name: rule.name, kind: rule.ruleKind, slots: rule.slots, provenance: tag }); }
132
+ catch { /* one malformed rule row loses that rule, not the board */ }
133
+ }
134
+ if (cache) cache.rows = null; // the fact-rows cache predates these writes
135
+
136
+ const layout = layoutNamed(world) ?? TOWN_SQUARE_LAYOUTS[world];
137
+ const { started } = await startTownSquareGame(memoryDir, { layout, config: gameConfig?.mudiii, roles: MUDIII_ROLES });
138
+ planHolder.state = { mudiii: { world, turn: 0 } };
139
+ const opener = started
140
+ ? (payload.meta?.opening || layout?.opening || WORLD_OPENING_FALLBACK)
141
+ : 'back to the town square — the fox and the goblins are already in play. Say "tick" to advance, or address one, e.g. "@fox the goblin is east".';
142
+ return {
143
+ text: opener,
144
+ goal: 'watch the fox and the goblins, or address one (e.g. "@fox the goblin is east")',
145
+ lane: "game-inform",
146
+ note: `MUDIII — loaded the "${world}" board from the worlds pack into this session's memory (facts + rule rows, provenance ${tag}) and ${started ? "minted" : "found"} the starting cast`,
147
+ };
148
+ }
149
+
150
+ // ---- resolving an addressed agent / belief target / food subject -------------
151
+
152
+ /** An exact "kind-num" reference, or (no number given) the first live
153
+ * individual of that kind — null when nothing live matches. `kind` names a
154
+ * fox, a goblin, a crumb or a morsel: the resolution is identical for an
155
+ * agent and an inert item, since both are placed subjects on the same
156
+ * folded board. */
157
+ function resolveAgentId(kind, num, state) {
158
+ if (num) {
159
+ const id = `${kind}-${num}`;
160
+ return state.placements.has(id) && !state.removed.has(id) ? id : null;
161
+ }
162
+ const live = liveIdsOfKind(state.placements, kind, state.removed);
163
+ return live[0] ?? null;
164
+ }
165
+
166
+ /** Same as resolveAgentId, but with no number given it picks the live
167
+ * individual NEAREST `nearCell` — the natural reading of "the goblin" from a
168
+ * particular addressee's own position once the ecology has minted more than
169
+ * one fox or goblin, or more than one crumb. */
170
+ function resolveNearestAgentId(kind, num, state, nearCell) {
171
+ if (num) return resolveAgentId(kind, num, state);
172
+ const live = liveIdsOfKind(state.placements, kind, state.removed);
173
+ if (!live.length || !nearCell) return live[0] ?? null;
174
+ let best = live[0];
175
+ let bestDist = Infinity;
176
+ for (const id of live) {
177
+ const c = parseCellId(state.placements.get(id).cell);
178
+ const d = chebyshevDistance(nearCell.x, nearCell.y, c.x, c.y);
179
+ if (d < bestDist) { bestDist = d; best = id; }
180
+ }
181
+ return best;
182
+ }
183
+
184
+ function noSuchAgentAnswer(kind, role) {
185
+ const text = role === "addressee"
186
+ ? `there's no live ${kind} on the board to address.`
187
+ : `there's no live ${kind} on the board for that to be about.`;
188
+ return { text, lane: "game-inform", note: `MUDIII — told-fact declined: no live ${kind} resolves as the ${role}`, miss: true };
189
+ }
190
+
191
+ /** The believed target cell, either the literal cell-<x>-<y> or the
192
+ * addressee's own current cell shifted one step in the stated compass
193
+ * direction — null when the result would fall off `gridSize`'s board (a
194
+ * layout property, not a module constant — the three shipped layouts run
195
+ * 10, 12 and 14 to a side). */
196
+ function resolveTargetCell({ direction, cellLiteral, fromCell, gridSize }) {
197
+ if (cellLiteral) {
198
+ const parsed = parseCellId(cellLiteral);
199
+ return parsed && inBounds(gridSize, parsed.x, parsed.y) ? parsed : null;
200
+ }
201
+ const delta = DIRECTION_DELTA[direction.toLowerCase()];
202
+ const nx = fromCell.x + delta.dx;
203
+ const ny = fromCell.y + delta.dy;
204
+ return inBounds(gridSize, nx, ny) ? { x: nx, y: ny } : null;
205
+ }
206
+
207
+ // oneStepDirectionBetween lives in town-square-world.mjs (the shared grid
208
+ // geometry both this chat-turn layer and the engine need); re-exported here
209
+ // so a caller building a deception pill's direction wording never has to
210
+ // reach into the domain layer by hand.
211
+ export { oneStepDirectionBetween };
212
+
213
+ // ---- deception pills, built on the addressed teach-frame above: dynamic,
214
+ // per-tick chat-dock suggestions alongside (never replacing) any static
215
+ // address/direction rail. No new grammar at all — every pill's sentence is
216
+ // exactly the SAME MUDIII_TOLD_RE line above already accepts, filled in with
217
+ // either the subject's real position or a deliberately false one, so a human
218
+ // clicking one submits a plain "@fox the goblin is east"-shaped line
219
+ // indistinguishable from a hand-typed claim, true or false alike. A pill's
220
+ // `truth` tag is for the human eye only — it never rides along in the
221
+ // submitted text itself.
222
+
223
+ /** "fox"/"goblin" bare when exactly one individual of that kind is live
224
+ * (nothing to disambiguate), else the individual's own numbered id. */
225
+ function agentPillLabel(kind, id, liveIdsOfKindArr) {
226
+ return liveIdsOfKindArr.length > 1 ? id : kind;
227
+ }
228
+
229
+ /** The point reflection of `cell` through the `gridSize`x`gridSize` board's
230
+ * center — cell-<gridSize+1-x>-<gridSize+1-y> — the canonical false-claim
231
+ * cell: deterministic, always in-bounds (1..gridSize reflects onto
232
+ * 1..gridSize), and never accidentally true for any of the three shipped
233
+ * layouts, all of which run an EVEN gridSize (10, 12, 14): x =
234
+ * (gridSize+1)-x has no integer solution when gridSize+1 is odd. */
235
+ function reflectedCell(cell, gridSize) {
236
+ return { x: gridSize + 1 - cell.x, y: gridSize + 1 - cell.y };
237
+ }
238
+
239
+ /**
240
+ * The town-square chat dock's dynamic pill set for one tick's live `agents`
241
+ * and `items` (runTownSquareTick's own `{ id: { cell, ... } }` shapes — only
242
+ * `.cell` is read from either): one address pill per live fox/goblin (bare
243
+ * "@fox" while exactly one of that kind is alive, numbered "@fox-2" once more
244
+ * than one is), plus — for whichever individual is currently addressed
245
+ * (`explicitAddresseeId`, falling back to `opts.defaultKind`'s first live
246
+ * individual, the predator kind by default) — one true-claim and one
247
+ * canonical false-claim pill per live individual of the OPPOSITE role (you
248
+ * address a fox about a goblin's position, or a goblin about a fox's), plus,
249
+ * when the addressee is prey (the one role that forages), one true/false
250
+ * claim pill per live food item — the channel spider-fly has no equivalent
251
+ * of, since only a forager has any use for a claim about where food sits.
252
+ *
253
+ * Returns `{ addressPills, claimPills, addresseeId }` — `addressPills` is
254
+ * `[{ id, kind, label }]`; `claimPills` is `[{ subjectId, truth, text,
255
+ * sentence }]` (`sentence` is the complete, ready-to-submit chat line); both
256
+ * empty when nothing is live. `opts.gridSize` must name the live layout's own
257
+ * board size (falls back to the headline layout's 12 otherwise) — the false-
258
+ * claim reflection needs it. Pure.
259
+ */
260
+ export function pillsForMudiii(agents, items, explicitAddresseeId, opts = {}) {
261
+ const { defaultKind = PREDATOR_KIND, gridSize = DEFAULT_GRID_SIZE, roles = MUDIII_ROLES } = opts;
262
+ const predatorKind = roles.predator.kind;
263
+ const preyKind = roles.prey.kind;
264
+ const livePredators = liveIdsOfKind(agents, predatorKind);
265
+ const livePrey = liveIdsOfKind(agents, preyKind);
266
+
267
+ const addressPills = [
268
+ ...livePredators.map((id) => ({ id, kind: predatorKind, label: `@${agentPillLabel(predatorKind, id, livePredators)}` })),
269
+ ...livePrey.map((id) => ({ id, kind: preyKind, label: `@${agentPillLabel(preyKind, id, livePrey)}` })),
270
+ ];
271
+
272
+ const fallbackAddresseeId = (defaultKind === preyKind ? livePrey[0] : livePredators[0]) ?? livePrey[0] ?? livePredators[0] ?? null;
273
+ const addresseeId = (explicitAddresseeId && agents[explicitAddresseeId]) ? explicitAddresseeId : fallbackAddresseeId;
274
+ if (!addresseeId) return { addressPills, claimPills: [], addresseeId: null };
275
+
276
+ const addresseeKind = agentKindOf(addresseeId);
277
+ const addresseeLabel = agentPillLabel(addresseeKind, addresseeId, addresseeKind === predatorKind ? livePredators : livePrey);
278
+ const addresseeCell = parseCellId(agents[addresseeId].cell);
279
+
280
+ const candidateKind = addresseeKind === predatorKind ? preyKind : predatorKind;
281
+ const candidateIds = candidateKind === predatorKind ? livePredators : livePrey;
282
+
283
+ const claimPills = [];
284
+ const addClaim = (subjectId, subjectLabel, trueCell) => {
285
+ const direction = oneStepDirectionBetween(addresseeCell, trueCell);
286
+ const trueValue = direction ? direction : `at ${cellId(trueCell.x, trueCell.y)}`;
287
+ const falseCell = reflectedCell(trueCell, gridSize);
288
+ const falseValue = `at ${cellId(falseCell.x, falseCell.y)}`;
289
+ claimPills.push({
290
+ subjectId, truth: true,
291
+ text: `the ${subjectLabel} is ${trueValue}`,
292
+ sentence: `@${addresseeLabel} the ${subjectLabel} is ${trueValue}`,
293
+ });
294
+ claimPills.push({
295
+ subjectId, truth: false,
296
+ text: `the ${subjectLabel} is ${falseValue}`,
297
+ sentence: `@${addresseeLabel} the ${subjectLabel} is ${falseValue}`,
298
+ });
299
+ };
300
+
301
+ for (const subjectId of candidateIds) {
302
+ const subjectLabel = agentPillLabel(candidateKind, subjectId, candidateIds);
303
+ addClaim(subjectId, subjectLabel, parseCellId(agents[subjectId].cell));
304
+ }
305
+
306
+ // The food channel: only prey forages, so only a goblin addressee gets
307
+ // claim pills about a crumb or a morsel — a fox never plans toward food at
308
+ // all (predator-prey.mjs's decide() never reads the item roster for a
309
+ // predator), so offering it there would be a pill with no effect.
310
+ if (addresseeKind === roles.prey.kind) {
311
+ const liveItemIds = Object.keys(items || {}).sort();
312
+ for (const itemId of liveItemIds) {
313
+ addClaim(itemId, itemId, parseCellId(items[itemId].cell));
314
+ }
315
+ }
316
+
317
+ return { addressPills, claimPills, addresseeId };
318
+ }
319
+
320
+ // ---- rendering one tick's return value as plain chat text --------------------
321
+
322
+ const ECOLOGY_EVENT_TEXT = {
323
+ "eat-agent": (e) => `${e.predator} caught ${e.prey} at ${e.cell}`,
324
+ "eat-item": (e) => `${e.agent} ate ${e.item} at ${e.cell}`,
325
+ starve: (e) => `${e.agent} starved at ${e.cell}`,
326
+ "spawn-prey": (e) => `${e.agent} arrived at ${e.cell}`,
327
+ "spawn-food": (e) => `${e.item} appeared at ${e.cell}`,
328
+ "place-food": (e) => `${e.placedBy} put ${e.item} at ${e.cell}`,
329
+ };
330
+
331
+ function renderTickText(tick, addressedNote) {
332
+ const parts = [];
333
+ if (addressedNote) parts.push(`${addressedNote}.`);
334
+ const agentIds = Object.keys(tick.agents).sort();
335
+ parts.push(agentIds.length
336
+ ? `Turn ${tick.turn} — ${agentIds.map((id) => `${id} is now at ${tick.agents[id].cell}`).join("; ")}.`
337
+ : `Turn ${tick.turn} — no agents remain on the board.`);
338
+ const itemIds = Object.keys(tick.items).sort();
339
+ if (itemIds.length) parts.push(`Food: ${itemIds.map((id) => `${id} at ${tick.items[id].cell}`).join("; ")}.`);
340
+ const events = tick.ecology.map((e) => ECOLOGY_EVENT_TEXT[e.type]?.(e)).filter(Boolean);
341
+ if (events.length) parts.push(`${events.join("; ")}.`);
342
+ return parts.join(" ");
343
+ }
344
+
345
+ /** Every live agent's own goal line folded into ONE string — withGoalLine
346
+ * only renders a single "Goal (inferred): …" suffix per turn, and this game
347
+ * always has several live agents with independent goals. Null once nothing
348
+ * survives the tick. */
349
+ function combinedGoalLine(agents) {
350
+ const ids = Object.keys(agents).sort();
351
+ if (!ids.length) return null;
352
+ return ids.map((id) => `${id} — ${agents[id].goal.replace(/\.\s*$/, "")}`).join("; ");
353
+ }
354
+
355
+ function describeEcologyNote(events) {
356
+ const counts = {};
357
+ for (const e of events) counts[e.type] = (counts[e.type] ?? 0) + 1;
358
+ const label = { "eat-agent": "caught", "eat-item": "foraged", starve: "starved", "spawn-prey": "arrived", "spawn-food": "spawned", "place-food": "placed" };
359
+ const bits = Object.entries(counts).map(([type, n]) => `${n} ${label[type] ?? type}`);
360
+ return bits.length ? `; ${bits.join(", ")}` : "";
361
+ }
362
+
363
+ async function runTickAndRender({ planHolder, memoryDir, cache, world, toldFacts = [], addressedNote = null, gameConfig = DEFAULT_GAME_CONFIG }) {
364
+ const tick = await runTownSquareTick(memoryDir, { layout: world, toldFacts, config: gameConfig?.mudiii, roles: MUDIII_ROLES });
365
+ if (cache) cache.rows = null;
366
+ planHolder.state = { mudiii: { world, turn: tick.turn } };
367
+ return {
368
+ text: renderTickText(tick, addressedNote),
369
+ goal: combinedGoalLine(tick.agents),
370
+ lane: "game-answer",
371
+ note: `MUDIII — turn ${tick.turn} (${world}): ran runTownSquareTick (${toldFacts.length ? "with an addressed told-fact" : "no addressed target"})${describeEcologyNote(tick.ecology)}`,
372
+ };
373
+ }
374
+
375
+ // ---- the player's own verb: placing food ------------------------------------
376
+
377
+ async function runPlaceFoodTurn(cellLiteral, { memoryDir, gameConfig = DEFAULT_GAME_CONFIG, world, layout }) {
378
+ const result = await placeFood(memoryDir, {
379
+ layout, cell: cellLiteral, config: gameConfig?.mudiii, roles: MUDIII_ROLES, placedBy: "player",
380
+ });
381
+ if (!result.placed) {
382
+ return {
383
+ text: result.reason,
384
+ lane: "game-inform",
385
+ note: `MUDIII — food placement declined: ${result.reason}`,
386
+ miss: true,
387
+ };
388
+ }
389
+ return {
390
+ text: `noted — a ${result.kind} now sits at ${result.cell}. Say "tick" to see what happens.`,
391
+ goal: "bait or feed the goblins",
392
+ lane: "game-answer",
393
+ note: `MUDIII — placed ${result.item} (${result.kind}) at ${result.cell} for turn ${result.turn} (${world}), provenance carries mgx:placed-by ${result.placedBy}`,
394
+ };
395
+ }
396
+
397
+ // ---- the observable-facts read: "what does the goblin see?" -----------------
398
+
399
+ /** One `[id, cellId | null]` belief entry as a sentence: `"fox-1 is at
400
+ * cell-3-4."` when observed/told, `"crumb-2 has not been observed."`
401
+ * otherwise. Pure, self-contained, `.toString()`-splice safe. */
402
+ export function believedFactSentence(id, believedCell) {
403
+ return believedCell ? `${id} is at ${believedCell}.` : `${id} has not been observed.`;
404
+ }
405
+
406
+ /** "what does the goblin see?" / "what does the fox see?" rendered as plain
407
+ * text: the same beliefSnapshotFor read predator-prey.mjs's own tick loop
408
+ * uses, over the CURRENT board state — read-only, no tick runs. Candidates
409
+ * are every other live agent of either role AND every live food item (a
410
+ * goblin's own forage target), exactly the beliefCandidates set
411
+ * runTownSquareTick itself builds. toldFacts is empty — a told position only
412
+ * ever arrives fresh alongside a tick, so there is none standing between
413
+ * ticks to read back here. */
414
+ async function mudiiiBeliefAnswer(match, { memoryDir, gameConfig = DEFAULT_GAME_CONFIG }) {
415
+ const kind = match[1].toLowerCase();
416
+ const num = match[2];
417
+ const rows = readFactRows(await loadMemory(memoryDir));
418
+ const state = foldTownSquareState(rows);
419
+ const observerId = resolveAgentId(kind, num, state);
420
+ if (!observerId) return noSuchAgentAnswer(kind, "addressee");
421
+ const observerCell = parseCellId(state.placements.get(observerId).cell);
422
+ const role = roleOfId(observerId, MUDIII_ROLES);
423
+ const candidateIds = [
424
+ ...liveIdsOfKind(state.placements, PREDATOR_KIND, state.removed),
425
+ ...liveIdsOfKind(state.placements, PREY_KIND, state.removed),
426
+ ...liveIdsOfKind(state.placements, SPAWNED_FOOD_KIND, state.removed),
427
+ ...liveIdsOfKind(state.placements, PLACED_FOOD_KIND, state.removed),
428
+ ];
429
+ const visionRadius = role === "predator"
430
+ ? gameConfig?.mudiii?.predatorVisionRadius
431
+ : gameConfig?.mudiii?.preyVisionRadius;
432
+ const belief = beliefSnapshotFor(observerId, observerCell, candidateIds, state, { visionRadius });
433
+ const entries = Object.entries(belief);
434
+ const text = entries.length
435
+ ? `${observerId} sees: ${entries.map(([id, cell]) => believedFactSentence(id, cell)).join(" ")}`
436
+ : `${observerId} is alone on the board — nothing else to see.`;
437
+ return {
438
+ text,
439
+ lane: "game-inform",
440
+ note: `MUDIII — belief snapshot rendered for ${observerId} via beliefSnapshotFor (read-only, no tick run)`,
441
+ };
442
+ }
443
+
444
+ /** The addressed teach-frame turn: resolve the addressee and the belief
445
+ * subject (an agent OR a food item), resolve the told cell, and run ONE tick
446
+ * with that told-fact fed in. Told-facts are not persisted across turns —
447
+ * each addressed line supplies belief for the NEXT tick only, mirroring
448
+ * spider-fly-turn.mjs's runToldFactTurn exactly. */
449
+ async function runToldFactTurn(match, { planHolder, memoryDir, cache, gameConfig = DEFAULT_GAME_CONFIG, world, layout }) {
450
+ const [, addrKindRaw, addrNum, subjKindRaw, subjNum, direction, cellLiteral] = match;
451
+ const addrKind = addrKindRaw.toLowerCase();
452
+ const subjKind = subjKindRaw.toLowerCase();
453
+ const rows = readFactRows(await loadMemory(memoryDir));
454
+ const state = foldTownSquareState(rows);
455
+
456
+ const addresseeId = resolveAgentId(addrKind, addrNum, state);
457
+ if (!addresseeId) return noSuchAgentAnswer(addrKind, "addressee");
458
+ const addresseeCell = parseCellId(state.placements.get(addresseeId).cell);
459
+ const subjectId = resolveNearestAgentId(subjKind, subjNum, state, addresseeCell);
460
+ if (!subjectId) return noSuchAgentAnswer(subjKind, "subject");
461
+
462
+ const targetCell = resolveTargetCell({ direction, cellLiteral, fromCell: addresseeCell, gridSize: layout.gridSize });
463
+ if (!targetCell) {
464
+ return {
465
+ text: `that falls off the edge of the ${layout.gridSize}x${layout.gridSize} board from where the ${addrKind} is — try a direction or cell that stays on the board.`,
466
+ lane: "game-inform",
467
+ note: "MUDIII — told-fact declined: the resolved cell falls outside the board",
468
+ miss: true,
469
+ };
470
+ }
471
+
472
+ const targetCellId = cellId(targetCell.x, targetCell.y);
473
+ const toldFacts = [{ subject: subjectId, toAgent: addresseeId, cell: targetCellId, turn: state.tickCount + 1 }];
474
+ return runTickAndRender({
475
+ planHolder, memoryDir, cache, world, toldFacts, gameConfig,
476
+ addressedNote: `told the ${addresseeId} the ${subjectId} is at ${targetCellId}`,
477
+ });
478
+ }
479
+
480
+ // ---- in-game orientation asides ---------------------------------------------
481
+ //
482
+ // "where is the fox", "where am I", "what can I do", "what is the fox's
483
+ // goal" — while the board is live these must answer from the board, not fall
484
+ // through to the code-graph lanes, where "where is the fox" would read "fox"
485
+ // as a module name. There is no player piece here (every fox and goblin
486
+ // moves on its own), so "where am I" reports the watcher stance.
487
+
488
+ const MUDIII_WHERE_AGENT_RE = /^where(?:'s|\s+is|\s+are)\s+(?:the\s+)?(fox|goblin)(?:-\d+)?(?:\s+now)?[?.!\s]*$/i;
489
+ const MUDIII_WHERE_AM_I_RE = /^where\s+am\s+i(?:\s+now)?[?.!\s]*$/i;
490
+ const MUDIII_OPTIONS_RE = /^(?:what\s+can\s+i\s+do(?:\s+(?:here|now))?|what\s+are\s+my\s+options|what\s+(?:should|do)\s+i\s+do(?:\s+(?:here|now))?|what\s+now)[?.!\s]*$/i;
491
+ const MUDIII_GOAL_AGENT_RE = /^what(?:'s|\s+is)\s+(?:the\s+)?(fox|goblin)(?:-\d+)?'s\s+goal[?.!\s]*$/i;
492
+ const MUDIII_GOAL_OF_RE = /^what(?:'s|\s+is)\s+(?:the\s+)?goal\s+of\s+the\s+(fox|goblin)(?:-\d+)?[?.!\s]*$/i;
493
+ const MUDIII_GOAL_GENERIC_RE = /^(?:what(?:'s|\s+is)\s+(?:the\s+)?(?:goal|objective|point|aim)|what\s+are\s+they\s+(?:doing|trying\s+to\s+do))[?.!\s]*$/i;
494
+
495
+ const WATCHER_STANCE =
496
+ 'you have no piece here — every fox and goblin moves on its own. Watch, say "tick" to advance, or address one, e.g. "@fox the goblin is east".';
497
+ const OPTIONS_TEXT =
498
+ 'say "tick" to advance a turn, address an agent — e.g. "@fox the goblin is at cell-7-3" — or place food, e.g. "put food at cell-3-4". Say "stop watching" to end.';
499
+ const PREDATOR_GOAL_TEXT = "the fox hunts goblins for their mass, and keeps clear of any other fox it can see.";
500
+ const PREY_GOAL_TEXT = "goblins forage for crumbs and morsels, and evade any fox they can see — hunger loses to survival every time.";
501
+ const GENERIC_GOAL_TEXT =
502
+ 'the fox hunts the goblins; the goblins forage for food and evade the fox. You watch it play out — plant a belief to nudge one, place food to bait one, or say "tick" to advance.';
503
+
504
+ const positionsOfKind = (kind, state) =>
505
+ liveIdsOfKind(state.placements, kind, state.removed).map((id) => `${id} at ${state.placements.get(id).cell}`);
506
+
507
+ async function mudiiiContextAnswer(line, { memoryDir }) {
508
+ const l = String(line).trim();
509
+ const whereAgent = l.match(MUDIII_WHERE_AGENT_RE);
510
+ const asksWhereMe = MUDIII_WHERE_AM_I_RE.test(l);
511
+ const asksOptions = MUDIII_OPTIONS_RE.test(l);
512
+ const goalAgentMatch = l.match(MUDIII_GOAL_AGENT_RE) || l.match(MUDIII_GOAL_OF_RE);
513
+ const asksGoalGeneric = MUDIII_GOAL_GENERIC_RE.test(l);
514
+ if (!whereAgent && !asksWhereMe && !asksOptions && !goalAgentMatch && !asksGoalGeneric) return null;
515
+ let state;
516
+ try { state = foldTownSquareState(readFactRows(await loadMemory(memoryDir))); } catch { return null; }
517
+
518
+ if (whereAgent) {
519
+ const kind = whereAgent[1].toLowerCase();
520
+ const positions = positionsOfKind(kind, state);
521
+ return {
522
+ text: positions.length ? `${positions.join("; ")}.` : `there's no live ${kind} on the board right now.`,
523
+ lane: "game-answer",
524
+ note: `MUDIII — where-aside: ${kind} positions from the current board fold`,
525
+ goal: `find the ${kind}`,
526
+ miss: !positions.length,
527
+ };
528
+ }
529
+
530
+ if (asksWhereMe) {
531
+ return { text: WATCHER_STANCE, lane: "game-inform", note: "MUDIII — where-am-I aside: the watcher stance (no player piece)", goal: "understand your role" };
532
+ }
533
+
534
+ if (asksOptions) {
535
+ return { text: OPTIONS_TEXT, lane: "game-inform", note: "MUDIII — options aside: the live game's own commands", goal: "see what you can do" };
536
+ }
537
+
538
+ if (goalAgentMatch) {
539
+ const kind = goalAgentMatch[1].toLowerCase();
540
+ const text = kind === PREDATOR_KIND ? PREDATOR_GOAL_TEXT : PREY_GOAL_TEXT;
541
+ return { text, lane: "game-inform", note: `MUDIII — goal aside: the ${kind}'s own role objective`, goal: `understand the ${kind}` };
542
+ }
543
+
544
+ return { text: GENERIC_GOAL_TEXT, lane: "game-inform", note: "MUDIII — goal aside: the game's predator/prey/forage objective", goal: "understand the game" };
545
+ }
546
+
547
+ // ---- the lane ------------------------------------------------------------
548
+
549
+ /**
550
+ * The whole town-square lane for one turn: the opening moves (one per
551
+ * layout), the stop command, the addressed teach-frame (agents and food
552
+ * alike), the bare tick command, the food-placement verb, the belief and
553
+ * orientation asides, and the one-at-a-time declines against the other
554
+ * lanes sharing planState's slot. Returns { text, lane, note, goal?, miss? }
555
+ * or null when the turn is not this lane's to answer.
556
+ */
557
+ export async function mudiiiTurn(line, { planHolder, memoryDir, env, cache = null, isPlanFrameLine = () => false, gameConfig = DEFAULT_GAME_CONFIG }) {
558
+ const slot = planHolder?.state ?? null;
559
+ const mudiii = slot?.mudiii ?? null;
560
+ const openingWorld = matchMudiiiOpening(line);
561
+
562
+ if (!mudiii) {
563
+ if (!openingWorld) return null;
564
+ if (slot?.game) {
565
+ return {
566
+ text: 'a guess-the-number game is active — say "I give up" to end it, then start the town square.',
567
+ lane: "game-inform",
568
+ note: "MUDIII — an opening arrived mid-number-game; the slot holds one thing at a time",
569
+ };
570
+ }
571
+ if (slot?.adventure) {
572
+ return {
573
+ text: 'an adventure is running — say "stop playing" to end it, then start the town square.',
574
+ lane: "game-inform",
575
+ note: "MUDIII — an opening arrived mid-adventure; the slot holds one thing at a time",
576
+ };
577
+ }
578
+ if (slot?.spiderFly) {
579
+ return {
580
+ text: 'the spider-and-fly game is running — say "stop watching" to end it, then start the town square.',
581
+ lane: "game-inform",
582
+ note: "MUDIII — an opening arrived mid-spider-fly-game; the slot holds one thing at a time",
583
+ };
584
+ }
585
+ const planActive = slot && !slot.done
586
+ && ((Array.isArray(slot.goals) && slot.goals.length) || (Array.isArray(slot.actions) && slot.actions.length));
587
+ if (planActive) {
588
+ return {
589
+ text: 'a plan is in progress — finish it or say "forget the goal" before we visit the town square.',
590
+ lane: "game-inform",
591
+ note: "MUDIII — an opening arrived while a plan frame is active; the slot holds one thing at a time",
592
+ };
593
+ }
594
+ return openMudiiiGame(openingWorld, { planHolder, memoryDir, env, cache, gameConfig });
595
+ }
596
+
597
+ // A game is live.
598
+ const layout = layoutNamed(mudiii.world);
599
+ if (!layout) {
600
+ planHolder.state = null;
601
+ return {
602
+ text: 'the town square\'s own board name did not resolve — the game ends here; say "visit the town square" to start fresh.',
603
+ lane: "game-inform",
604
+ note: `MUDIII — the plan slot named an unknown layout ("${mudiii.world}"); the game was ended rather than run against nothing`,
605
+ miss: true,
606
+ };
607
+ }
608
+
609
+ if (openingWorld) {
610
+ return {
611
+ text: 'the town square is already running — say "stop watching" to end it first.',
612
+ lane: "game-inform",
613
+ note: "MUDIII — an opening arrived mid-game; declined, the running game stands",
614
+ };
615
+ }
616
+ if (MUDIII_STOP_RE.test(line)) {
617
+ planHolder.state = null;
618
+ return {
619
+ text: 'OK — the town square game ends here. Everything the board wrote stays remembered; say "visit the town square" to pick it back up.',
620
+ lane: "game-inform",
621
+ note: "MUDIII — the game ended on request; the board's facts stay in the store",
622
+ };
623
+ }
624
+ // The food verb is checked BEFORE the plan-frame guard below, because
625
+ // "put food at cell-3-4" reads as a planning frame on its leading verb and
626
+ // would otherwise be answered with "stop watching, then set your goal" — a
627
+ // refusal to do the one thing this lane's own grammar most explicitly
628
+ // offers. A line the lane owns outright is not a plan frame.
629
+ const trimmed = String(line).trim();
630
+ const putMatch = trimmed.match(MUDIII_PUT_FOOD_RE) || trimmed.match(MUDIII_DROP_FOOD_RE);
631
+ if (putMatch) {
632
+ return runPlaceFoodTurn(putMatch[1], { memoryDir, gameConfig, world: mudiii.world, layout });
633
+ }
634
+
635
+ if (isPlanFrameLine(line)) {
636
+ return {
637
+ text: 'the town square game is running — say "stop watching" to end it, then set your goal.',
638
+ lane: "game-inform",
639
+ note: "MUDIII — a plan frame arrived mid-game; the slot holds one thing at a time",
640
+ };
641
+ }
642
+
643
+ if (MUDIII_ADDRESS_LEAD_RE.test(line)) {
644
+ const told = trimmed.match(MUDIII_TOLD_RE);
645
+ if (!told) {
646
+ const addrKind = line.match(MUDIII_ADDRESS_LEAD_RE)[1].toLowerCase();
647
+ return {
648
+ text: `I heard you address the ${addrKind} but couldn't read a position from that — try "@${addrKind} the goblin is east" or "@${addrKind} the goblin is at cell-7-3".`,
649
+ lane: "game-inform",
650
+ note: "MUDIII — an addressed line didn't match the spatial teach-frame; honest decline, never a guess",
651
+ miss: true,
652
+ };
653
+ }
654
+ return runToldFactTurn(told, { planHolder, memoryDir, cache, gameConfig, world: mudiii.world, layout });
655
+ }
656
+
657
+ const seeMatch = trimmed.match(MUDIII_SEE_RE);
658
+ if (seeMatch) {
659
+ return mudiiiBeliefAnswer(seeMatch, { memoryDir, gameConfig });
660
+ }
661
+
662
+ if (MUDIII_TICK_RE.test(line)) {
663
+ return runTickAndRender({ planHolder, memoryDir, cache, world: mudiii.world, toldFacts: [], gameConfig });
664
+ }
665
+
666
+ const contextAside = await mudiiiContextAnswer(line, { memoryDir });
667
+ if (contextAside) return contextAside;
668
+
669
+ return null; // an unaddressed aside — the ordinary lanes answer, board untouched
670
+ }