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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,30 +1,85 @@
1
- // spider-fly-turn.mjs — the chat lane for the headless spider-and-fly game
2
- // (PLAN_SPIDER_FLY.md §6): loading the shipped board into the session's
3
- // memory store, the stop command, the addressed spatial teach-frame that
4
- // feeds a told-fact into the next tick, and the bare "tick" command this
5
- // game's no-player-controlled-entity posture (§1 — both agents move on
6
- // their own, every turn) needs for CLI use. Mirrors adventure.mjs's own
7
- // shape exactly: closed-regex openers/stop, a slot-tagged one-at-a-time
8
- // coexistence check against the other two lanes, a lane function returning
9
- // { text, goal?, lane, note, miss? } or null when the turn is not this
10
- // lane's to answer. The fourth of the four lanes sharing planState's slot.
1
+ // spider-fly-turn.mjs — the spider-and-fly cast: its bindings onto the shared
2
+ // predator/prey engine, and its chat lane.
11
3
  //
12
- // This module never plans a path or scores a move itself every bit of
13
- // game logic (fold, pathfinding, belief, ecology) lives in spider-fly.mjs;
14
- // this file only recognizes chat shapes, resolves them to the shapes
15
- // runSpiderFlyTick's own interface accepts, and renders its return value as
16
- // chat text.
4
+ // The bindings are the first section below, and they are the whole of what this
5
+ // game adds to predator-prey.mjs. Every piece of game logic the (epoch, turn)
6
+ // fold, the pathfinding, belief, the decision chains, the ecology pass — is the
7
+ // shared engine's, run with this board's layout, this board's roles and this
8
+ // board's knobs. Carrying a catch to a web, spinning webs and laying eggs are
9
+ // three opt-in engine features spiderFlyEngineConfig switches on; nothing about
10
+ // them is written twice.
11
+ //
12
+ // The lane is everything after that: loading the shipped board into the
13
+ // session's memory store, the stop command, the addressed spatial teach-frame
14
+ // that feeds a told-fact into the next tick, and the bare "tick" command this
15
+ // game's no-player-controlled-entity posture (both agents move on their own,
16
+ // every turn) needs for CLI use. It mirrors adventure.mjs's own shape exactly:
17
+ // closed-regex openers/stop, a slot-tagged one-at-a-time coexistence check
18
+ // against the other lanes, a lane function returning { text, goal?, lane, note,
19
+ // miss? } or null when the turn is not this lane's to answer.
17
20
 
18
21
  import {
19
- DIRECTION_DELTA, WORLD_NAME, cellId, parseCellId, inBounds, chebyshevDistance, oneStepDirectionBetween,
22
+ DIRECTION_DELTA, WORLD_NAME, SPIDER_FLY_LAYOUT, SPIDER_FLY_ROLES, spiderFlyEngineConfig,
23
+ cellId, parseCellId, inBounds, chebyshevDistance, oneStepDirectionBetween,
20
24
  agentKindOf, liveIdsOfKind,
21
25
  } from "../domain/spider-fly-world.mjs";
22
- import { foldSpiderFlyState, runSpiderFlyTick, startSpiderFlyGame, beliefSnapshotFor } from "./spider-fly.mjs";
26
+ import { perimeterCells } from "../domain/town-square-world.mjs";
27
+ import {
28
+ beliefSnapshotFor, foldTownSquareState, liveWebs, runTownSquareTick, seededSpawnCell,
29
+ startTownSquareGame, townSquareBoard,
30
+ } from "./predator-prey.mjs";
23
31
  import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
24
32
  import { getWorldsPackProvider } from "../adapters/corpus/worlds-pack.mjs";
25
33
  import { appendFacts, appendRule, loadMemory, readFactRows } from "../adapters/memory/core.mjs";
26
34
  import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
27
35
 
36
+ // ---- the cast's bindings onto the shared engine ------------------------------
37
+
38
+ /** Fold fact rows into the current board state — the engine's own fold, named
39
+ * for the game whose readers ask for it. */
40
+ export const foldSpiderFlyState = foldTownSquareState;
41
+
42
+ export { beliefSnapshotFor, liveWebs };
43
+
44
+ const engineOpts = (config) => ({
45
+ layout: SPIDER_FLY_LAYOUT,
46
+ roles: SPIDER_FLY_ROLES,
47
+ config: spiderFlyEngineConfig(config),
48
+ });
49
+
50
+ /** Mint spider-1 in its web and a spread of flies around the board edge — a
51
+ * fresh session's own starting state, never part of the shipped (reusable,
52
+ * static) world pack. A no-op once this epoch already holds a roster, so it is
53
+ * safe to call from a caller unsure whether the game has started. The flies'
54
+ * cells are seeded picks over the same perimeter list, and off the same
55
+ * keyspace, a mid-game arrival draws from. */
56
+ export async function startSpiderFlyGame(memoryDir, { flyCount = 1, config = DEFAULT_GAME_CONFIG.spiderFly } = {}) {
57
+ const homeCell = SPIDER_FLY_LAYOUT.webHomeCell;
58
+ const agents = { "spider-1": { role: "predator", cell: homeCell } };
59
+ const edge = perimeterCells(SPIDER_FLY_LAYOUT);
60
+ const taken = new Set([homeCell]);
61
+ for (let i = 1; i <= flyCount; i += 1) {
62
+ const flyId = `fly-${i}`;
63
+ const free = edge.filter((c) => !taken.has(c));
64
+ const cell = seededSpawnCell(free.length ? free : edge, { layoutName: SPIDER_FLY_LAYOUT.name, id: flyId });
65
+ taken.add(cell);
66
+ agents[flyId] = { role: "prey", cell };
67
+ }
68
+ return startTownSquareGame(memoryDir, { ...engineOpts(config), agents });
69
+ }
70
+
71
+ /** One full turn on this board. `toldFacts` is the belief layer's chat channel:
72
+ * `{ subject, toAgent, cell, turn }` rows, empty for an unaddressed tick. */
73
+ export function runSpiderFlyTick(memoryDir, { toldFacts = [], config = DEFAULT_GAME_CONFIG.spiderFly } = {}) {
74
+ return runTownSquareTick(memoryDir, { ...engineOpts(config), toldFacts });
75
+ }
76
+
77
+ /** The board as it stands, in a tick's own payload shape, without running one —
78
+ * what a renderer draws between opening a session and the first tick. */
79
+ export function spiderFlyBoard(memoryDir, { toldFacts = [], config = DEFAULT_GAME_CONFIG.spiderFly } = {}) {
80
+ return townSquareBoard(memoryDir, { ...engineOpts(config), toldFacts });
81
+ }
82
+
28
83
  // ---- recognizers: the closed opening/stop/tick/address set -------------------
29
84
 
30
85
  // The opener names the game without requiring a specific phrasing order —
@@ -42,15 +97,14 @@ const SPIDER_FLY_OPEN_RE =
42
97
  // player thinks of the session.
43
98
  const SPIDER_FLY_STOP_RE =
44
99
  /^(?:stop\s+(?:watching|playing)|quit\s+(?:the\s+)?(?:spider\s+and\s+fly\s+)?game|end\s+the\s+spider(?:\s+and\s+fly)?\s+game|leave\s+the\s+game)[.!?\s]*$/i;
45
- // The bare tick command NOT specified by PLAN_SPIDER_FLY.md itself (§11's
46
- // Play/step button is the page's own answer to "nothing requires the human
47
- // to act"; this is that same need's CLI/chat equivalent). Styled after the
48
- // plan lane's own PLAN_NEXT_RE ("next"/"next move"/"go on"/"continue").
100
+ // The chat equivalent of the page's own Play/step button: nothing here
101
+ // requires the human to act, so a watcher needs a word that advances a turn.
102
+ // Styled after the plan lane's own PLAN_NEXT_RE ("next"/"next move"/"go on").
49
103
  const SPIDER_FLY_TICK_RE = /^(?:tick|next\s+turn|advance(?:\s+the\s+turn)?)[.!?\s]*$/i;
50
104
 
51
- // The spatial teach-frame (§6.1): "@spider the fly is east" / "@spider the
52
- // fly is at cell-7-3". Its own closed regex, not a route through
53
- // parseRelation/parseCopula/parseOfForm — §6.1 found both hit real grammar
105
+ // The spatial teach-frame: "@spider the fly is east" / "@spider the fly is at
106
+ // cell-7-3". Its own closed regex, not a route through
107
+ // parseRelation/parseCopula/parseOfForm — both hit real grammar
54
108
  // gaps for this exact shape (the bare copula reading mints a nonsense
55
109
  // subclass axiom; the "of" form hits parseAce's own hard-null guard before
56
110
  // ever reaching parseRelation/parseCopula). A fixed compass set — north,
@@ -284,6 +338,21 @@ export function pillsForSpiderFly(agents, explicitAddresseeId, opts = {}) {
284
338
 
285
339
  // ---- rendering one tick's return value as plain chat text --------------------
286
340
 
341
+ /** One ecology event as the clause a player reads. Null for an event type this
342
+ * board never produces, so a cast that grows one later reads as silence rather
343
+ * than as a broken sentence. */
344
+ function ecologyClause(event) {
345
+ switch (event.type) {
346
+ case "catch-prey": return `${event.predator} caught ${event.prey} at ${event.cell}`;
347
+ case "eat-agent": return `${event.prey} was eaten by ${event.predator} at ${event.cell}`;
348
+ case "starve": return `${event.agent} starved`;
349
+ case "lay-egg": return `${event.egg} was laid`;
350
+ case "hatch-egg": return `${event.egg} hatched into ${event.hatchlings.map((h) => h.id).join(" and ")} at ${event.cell}`;
351
+ case "spawn-prey": return `${event.agent} arrived at the board edge`;
352
+ default: return null;
353
+ }
354
+ }
355
+
287
356
  function renderTickText(tick, addressedNote) {
288
357
  const parts = [];
289
358
  if (addressedNote) parts.push(`${addressedNote}.`);
@@ -291,15 +360,8 @@ function renderTickText(tick, addressedNote) {
291
360
  parts.push(ids.length
292
361
  ? `Turn ${tick.turn} — ${ids.map((id) => `${id} is now at ${tick.agents[id].cell}`).join("; ")}.`
293
362
  : `Turn ${tick.turn} — no agents remain on the board.`);
294
- const eco = tick.ecology;
295
- const events = [];
296
- for (const c of eco.caught) events.push(`${c.spider} caught ${c.fly} at ${c.cell}`);
297
- for (const e of eco.eaten) events.push(`${e.fly} was eaten by ${e.spider} at ${e.cell}`);
298
- for (const f of eco.starved) events.push(`${f} starved`);
299
- if (eco.laid) events.push(`${eco.laid} was laid`);
300
- for (const h of eco.hatched) events.push(`${h.egg} hatched into ${h.spiders.map((s) => s.spider).join(" and ")} at ${h.cell}`);
301
- if (eco.spawned) events.push(`${eco.spawned} arrived at the board edge`);
302
- if (events.length) parts.push(`${events.join("; ")}.`);
363
+ const clauses = tick.ecology.map(ecologyClause).filter(Boolean);
364
+ if (clauses.length) parts.push(`${clauses.join("; ")}.`);
303
365
  return parts.join(" ");
304
366
  }
305
367
 
@@ -316,14 +378,26 @@ function combinedGoalLine(agents) {
316
378
  return ids.map((id) => `${id} — ${agents[id].goal.replace(/\.\s*$/, "")}`).join("; ");
317
379
  }
318
380
 
319
- function describeEcologyNote(eco) {
320
- const bits = [];
321
- if (eco.caught.length) bits.push(`${eco.caught.length} caught`);
322
- if (eco.eaten.length) bits.push(`${eco.eaten.length} eaten`);
323
- if (eco.starved.length) bits.push(`${eco.starved.length} starved`);
324
- if (eco.laid) bits.push("1 laid");
325
- if (eco.hatched.length) bits.push(`${eco.hatched.reduce((n, h) => n + h.spiders.length, 0)} hatched`);
326
- if (eco.spawned) bits.push("1 spawned");
381
+ const ECOLOGY_NOTE_WORDS = Object.freeze({
382
+ "catch-prey": "caught",
383
+ "eat-agent": "eaten",
384
+ starve: "starved",
385
+ "lay-egg": "laid",
386
+ "hatch-egg": "hatched",
387
+ "spawn-prey": "spawned",
388
+ });
389
+
390
+ function describeEcologyNote(events) {
391
+ const tally = new Map();
392
+ for (const event of events) {
393
+ const word = ECOLOGY_NOTE_WORDS[event.type];
394
+ if (!word) continue;
395
+ const n = event.type === "hatch-egg" ? event.hatchlings.length : 1;
396
+ tally.set(word, (tally.get(word) ?? 0) + n);
397
+ }
398
+ const bits = Object.values(ECOLOGY_NOTE_WORDS)
399
+ .filter((word) => tally.has(word))
400
+ .map((word) => `${tally.get(word)} ${word}`);
327
401
  return bits.length ? `; ${bits.join(", ")}` : "";
328
402
  }
329
403
 
@@ -352,8 +426,8 @@ export function believedFactSentence(id, believedCell) {
352
426
  }
353
427
 
354
428
  /** "what does the fly see?" / "what does the spider see?" rendered as plain
355
- * text: the same beliefSnapshotFor read spider-fly.mjs's own tick loop and
356
- * the browser panel already use, over the CURRENT board state — read-only,
429
+ * text: the same beliefSnapshotFor read the engine's own tick loop and the
430
+ * browser panel already use, over the CURRENT board state — read-only,
357
431
  * no tick runs, nothing is written. Candidates are every OTHER live agent
358
432
  * of either kind; toldFacts is empty (a told position only ever arrives
359
433
  * fresh alongside a tick — see runToldFactTurn — so there is none standing
@@ -385,11 +459,10 @@ async function spiderFlyBeliefAnswer(match, { memoryDir, gameConfig = DEFAULT_GA
385
459
  /** The addressed teach-frame turn: resolve the addressee and the belief
386
460
  * subject, resolve the told cell, and run ONE tick with that told-fact fed
387
461
  * in. Told-facts are NOT persisted on the session slot across turns — each
388
- * addressed line supplies belief for the NEXT tick only, then is gone
389
- * (the simplest of the plan doc's own named options, §4's "carry only the
390
- * current turn's told-facts"). This also matches how runSpiderFlyTick
391
- * itself already works: it holds no standing plan or belief between calls,
392
- * recomputing everything fresh from the folded fact rows every tick. */
462
+ * addressed line supplies belief for the NEXT tick only, then is gone. That
463
+ * matches how the engine itself already works: it holds no standing plan or
464
+ * belief between calls, recomputing everything fresh from the folded fact
465
+ * rows every tick. */
393
466
  async function runToldFactTurn(match, { planHolder, memoryDir, cache, gameConfig = DEFAULT_GAME_CONFIG }) {
394
467
  const [, addrKindRaw, addrNum, subjKindRaw, subjNum, direction, cellLiteral] = match;
395
468
  const addrKind = addrKindRaw.toLowerCase();
@@ -414,7 +487,7 @@ async function runToldFactTurn(match, { planHolder, memoryDir, cache, gameConfig
414
487
  }
415
488
 
416
489
  const targetCellId = cellId(targetCell.x, targetCell.y);
417
- const toldFacts = [{ subject: subjectId, toAgent: addresseeId, cell: targetCellId, turn: state.turnCount + 1 }];
490
+ const toldFacts = [{ subject: subjectId, toAgent: addresseeId, cell: targetCellId, turn: state.tickCount + 1 }];
418
491
  return runTickAndRender({
419
492
  planHolder, memoryDir, cache, toldFacts, gameConfig,
420
493
  addressedNote: `told the ${addresseeId} the ${subjectId} is at ${targetCellId}`,
@@ -487,13 +560,12 @@ async function spiderFlyContextAnswer(line, { memoryDir }) {
487
560
 
488
561
  /**
489
562
  * The whole spider-and-fly lane for one turn: the opening moves, the stop
490
- * command, the addressed spatial teach-frame (§6.1), the bare tick command,
491
- * and the one-at-a-time declines across the shared plan slot both other
492
- * lanes already implement pairwise. Returns { text, lane, note, goal?,
493
- * miss? } or null when the turn is not this lane's to answer — an
494
- * unaddressed aside (e.g. "where is the spider") falls through to the
495
- * ordinary lanes unchanged, board untouched (§6.2 no special-cased
496
- * spider-fly code path for plain questions).
563
+ * command, the addressed spatial teach-frame, the bare tick command, and the
564
+ * one-at-a-time declines across the shared plan slot the other lanes already
565
+ * implement pairwise. Returns { text, lane, note, goal?, miss? } or null when
566
+ * the turn is not this lane's to answer — an unaddressed aside (e.g. "where is
567
+ * the spider") falls through to the ordinary lanes unchanged, board untouched,
568
+ * with no special-cased code path for plain questions.
497
569
  */
498
570
  export async function spiderFlyTurn(line, { planHolder, memoryDir, env, cache = null, isPlanFrameLine = () => false, gameConfig = DEFAULT_GAME_CONFIG }) {
499
571
  const slot = planHolder?.state ?? null;
@@ -37,7 +37,6 @@ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embed
37
37
  import { createTicker } from "./viz-ticker.mjs";
38
38
  import { loadWinkVendor } from "./viz-boot.mjs";
39
39
  import { GRID_SIZE, WEB_HOME, WEB_RADIUS, isInWebBlock, cellId, agentKindOf } from "../domain/spider-fly-world.mjs";
40
- import { FLY_INITIAL_MASS, EGG_LAY_MASS_THRESHOLD } from "./spider-fly.mjs";
41
40
  import { believedFactSentence } from "./spider-fly-turn.mjs";
42
41
  import { DEFAULT_GAME_CONFIG, massScaleFor } from "../domain/game-config.mjs";
43
42
  import { resolveSpriteRequest } from "../domain/sprite-request.mjs";
@@ -69,11 +68,11 @@ function webCellIds() {
69
68
  /**
70
69
  * Reconstruct the spider's remaining silk-thread path — the sequence of
71
70
  * cell ids from its CURRENT cell (after this tick's one executed step) to
72
- * wherever `findActionPath` was aiming — from spider-fly.mjs's own
71
+ * wherever `findActionPath` was aiming — from spider-fly-turn.mjs's own
73
72
  * `agents[spiderId]` shape ({ cell, plan }), where `plan` is the FULL
74
73
  * direction list `findActionPath` returned (the step already taken this
75
74
  * tick, `plan[0]`, plus every step still to come). Only a spider with a
76
- * REAL multi-step plan draws a thread at all: spider-fly.mjs's own
75
+ * REAL multi-step plan draws a thread at all: spider-fly-turn.mjs's own
77
76
  * `planSpiderPath` only ever returns one when the believed fly cell sits
78
77
  * inside the web block (its `isGoal`'s own requirement) — most ticks the
79
78
  * spider is greedily closing distance with no such plan, and this
@@ -104,7 +103,7 @@ export function threadCellsForSpiderPlan(agents, geometry) {
104
103
  }
105
104
 
106
105
  /** The sprite-facing rotation (degrees) for one agent this tick, driven by
107
- * its CURRENT plan's first step (spider-fly.mjs's own `agents[id].plan`),
106
+ * its CURRENT plan's first step (spider-fly-turn.mjs's own `agents[id].plan`),
108
107
  * never its actual next move — the two usually coincide, but re-planning
109
108
  * fresh every tick means they can visibly diverge as a plan gets clobbered
110
109
  * and replaced, which is the intended, honest demonstration of "plans get
@@ -188,13 +187,11 @@ export function renderSpiderFlyHtml({ title = DEFAULT_TITLE, spriteTemplates = [
188
187
  previewMaxTurns: PREVIEW_MAX_TURNS,
189
188
  tickWaitMs: TICK_WAIT_MS,
190
189
  corpseLingerTurns: CORPSE_LINGER_TURNS,
191
- maxFlyMass: FLY_INITIAL_MASS,
192
- // The spider's mass bar now scales against the EGG-LAY threshold, not
193
- // its own starting mass "how close to laying" is the meaningful cap
194
- // to visualize under the new mass-gated lay mechanic (§A.2.2); the old
195
- // denominator (a flat starting mass) said nothing about progress toward
196
- // the spider's actual goal.
197
- maxSpiderMass: EGG_LAY_MASS_THRESHOLD,
190
+ maxFlyMass: DEFAULT_GAME_CONFIG.spiderFly.flyInitialMass,
191
+ // The spider's mass bar scales against the egg-lay threshold rather than
192
+ // its own starting mass: "how close to laying" is what a viewer wants to
193
+ // read, and a flat starting mass says nothing about progress toward it.
194
+ maxSpiderMass: DEFAULT_GAME_CONFIG.spiderFly.eggLayMassThreshold,
198
195
  defaultConfig: DEFAULT_GAME_CONFIG.spiderFly,
199
196
  spriteTemplates,
200
197
  });
@@ -349,7 +346,7 @@ ${THEME_TOKENS_CSS}
349
346
  .hud-plan, .hud-belief { font-family: ${MONO_STACK}; font-size: .66rem; color: var(--muted); margin-top: .25rem; line-height: 1.4; padding-left: .5rem; border-left: 2px solid var(--chrome-accent); }
350
347
  /* the click-expand facts panel (§28): beside the clicked spider/fly's own
351
348
  row, never a separate popover or a second panel elsewhere on the page —
352
- the same believedCellOf/beliefSnapshotFor read path spider-fly.mjs
349
+ the same believedCellOf/beliefSnapshotFor read path spider-fly-turn.mjs
353
350
  already computes every tick for planning, rendered here as full
354
351
  sentences instead of the compact believes:-line above. */
355
352
  .hud-detail { flex: 1 1 auto; min-width: 0; font-family: ${MONO_STACK}; font-size: .64rem; line-height: 1.5; color: var(--chrome-well-ink); background: var(--chrome-well); border: 1px solid var(--chrome-edge-lo); box-shadow: var(--chrome-shadow-inset); border-radius: 2px; padding: .35rem .5rem; }
@@ -34,7 +34,7 @@
34
34
  // page's own edit mode already makes.
35
35
  import { appendFacts, loadMemory, readFactRows } from "../adapters/memory/core.mjs";
36
36
  import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
37
- import { correctMisspellings } from "../domain/interpret/normalize.mjs";
37
+ import { correctMisspellings, QUESTION_LEAD_RE } from "../domain/interpret/normalize.mjs";
38
38
  import {
39
39
  classMassFacts, foldWorldState, freshObjectId, snapshotSubject, worldActionRows, worldRelook,
40
40
  } from "./adventure.mjs";
@@ -44,8 +44,8 @@ import {
44
44
  // word earlier, run over the closed misspelling repair so a typo'd "wat is the
45
45
  // lamp" goes back to the question side rather than reading as a declarative.
46
46
  // Both mirror chat.mjs's own teach lane, which stands the whole lane down on
47
- // either — a world teach has exactly the same reason to.
48
- const QUESTION_LEAD_RE = /^(?:what|who|which|where|when|why|how|is|are|do|does|did|can|could|should|would|will|has|have)\b/i;
47
+ // either — a world teach has exactly the same reason to (see
48
+ // normalize.mjs's QUESTION_LEAD_RE).
49
49
 
50
50
  // A subject with no referent of its own. The generic "X is a Y." fallback in
51
51
  // both sentence tables would otherwise read "There is a book in the study" as
@@ -43,6 +43,7 @@ import {
43
43
  import { parseEntities } from "../../domain/codegraph.mjs";
44
44
  import { memoryFactGraphPayload } from "../../domain/memory-facts.mjs";
45
45
  import { loadLexicon } from "../../domain/grammar/lexicon.mjs";
46
+ import { DEFAULT_GAME_CONFIG } from "../../domain/game-config.mjs";
46
47
  import { foldWorldState, worldDigestRows, roomAffordances, worldActionRows, snapshotSubject } from "../../services/adventure.mjs";
47
48
  import { runAdventureAutoplayTick, exposedFacts } from "../../services/adventure-autoplay.mjs";
48
49
  import { parseWorldEditorText, planWorldEditorSync } from "../../services/adventure-editor.mjs";
@@ -66,8 +67,13 @@ import { openPersistedStore } from "./idb-persist.mjs";
66
67
  * already carries them, plus every @turnN state row played since, so the
67
68
  * world resumes exactly where the fold left it. `restoredVisitedRoomIds`
68
69
  * carries the matching exposure set forward; without it, only the player's
69
- * current room counts as visited. */
70
- export async function createAdventureSession(worldPayload, { restoredPayload = null, restoredVisitedRoomIds = null } = {}) {
70
+ * current room counts as visited.
71
+ *
72
+ * `getTeachEnabled` (optional) is read fresh on every turn, never once at
73
+ * boot — the page's own teach checkbox, so flipping it mid-game changes the
74
+ * very next line's reading without a reset. Defaults to always-off, which is
75
+ * DEFAULT_GAME_CONFIG.adventure.teach's own default. */
76
+ export async function createAdventureSession(worldPayload, { restoredPayload = null, restoredVisitedRoomIds = null, getTeachEnabled = () => false } = {}) {
71
77
  const memoryDir = createInMemoryStore();
72
78
  const tag = `world:${worldPayload.name}`;
73
79
  if (restoredPayload) {
@@ -134,7 +140,10 @@ export async function createAdventureSession(worldPayload, { restoredPayload = n
134
140
  const turnSession = createTurnSession({
135
141
  memoryDir, graph: codeGraph, lexicon, sessionId,
136
142
  vocabHint: 'Try a world question ("where is the key"), or teach me: "remember: the moat is a ditch".',
137
- buildExtraOptions: () => ({ planState: planHolder.state }),
143
+ buildExtraOptions: () => ({
144
+ planState: planHolder.state,
145
+ gameConfig: { ...DEFAULT_GAME_CONFIG, adventure: { ...DEFAULT_GAME_CONFIG.adventure, teach: getTeachEnabled() } },
146
+ }),
138
147
  captureExtraState: async (result) => {
139
148
  if ("planState" in result) planHolder.state = result.planState;
140
149
  const here = foldWorldState(worldActionRows(readFactRows(await loadMemory(memoryDir)))).placements.get("player")?.object ?? null;