@polycode-projects/the-mechanical-code-talker 2.7.23 → 2.7.25

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.
Files changed (41) hide show
  1. package/README.md +23 -0
  2. package/data/sprites/adventurer.toml +5 -0
  3. package/data/sprites/animal.toml +6 -0
  4. package/data/sprites/butler.toml +8 -0
  5. package/data/sprites/cabinet.toml +5 -0
  6. package/data/sprites/container.toml +5 -0
  7. package/data/sprites/cook.toml +7 -0
  8. package/data/sprites/desk.toml +4 -0
  9. package/data/sprites/dog-with-colour.toml +20 -0
  10. package/data/sprites/dog.toml +5 -0
  11. package/data/sprites/egg.toml +4 -0
  12. package/data/sprites/fly.toml +5 -0
  13. package/data/sprites/furniture.toml +5 -0
  14. package/data/sprites/gardener.toml +4 -0
  15. package/data/sprites/housekeeper.toml +6 -0
  16. package/data/sprites/key.toml +5 -0
  17. package/data/sprites/lamp.toml +4 -0
  18. package/data/sprites/letter.toml +5 -0
  19. package/data/sprites/person.toml +4 -0
  20. package/data/sprites/poodle.toml +5 -0
  21. package/data/sprites/portable.toml +6 -0
  22. package/data/sprites/portrait.toml +5 -0
  23. package/data/sprites/room.toml +5 -0
  24. package/data/sprites/spider.toml +5 -0
  25. package/package.json +1 -1
  26. package/src/adapters/corpus/sprite-template-files.mjs +52 -0
  27. package/src/adapters/toml-config.mjs +8 -0
  28. package/src/domain/game-config.mjs +90 -0
  29. package/src/domain/spider-fly-world.mjs +5 -2
  30. package/src/domain/sprite-map.mjs +25 -10
  31. package/src/domain/sprite-templates.mjs +131 -0
  32. package/src/services/adventure-viz.mjs +201 -18
  33. package/src/services/adventure.mjs +45 -3
  34. package/src/services/chat-session.mjs +9 -1
  35. package/src/services/chat.mjs +49 -27
  36. package/src/services/spider-fly-turn.mjs +11 -10
  37. package/src/services/spider-fly-viz.mjs +73 -8
  38. package/src/services/spider-fly.mjs +56 -36
  39. package/src/surfaces/web/adventure-browser-entry.mjs +57 -19
  40. package/src/surfaces/web/memory-ask-browser.bundle.js +42 -0
  41. package/src/surfaces/web/spider-fly-browser-entry.mjs +2 -1
@@ -28,15 +28,29 @@
28
28
  // import without duplicating adventure.mjs's own closed vocabulary reading)
29
29
  // and `roomSceneObjects` (every subject actually visible in a room, mirrored
30
30
  // from adventure.mjs's private `visibleRoomOf` the same way
31
- // adventure-autoplay.mjs's own `roomOfSubject` already has to). `roomCaptionText`
32
- // is a third pure helper, exported for testing, but NOT spliced it calls
33
- // `worldDigestRows` via a real ES import, since Node/test callers have one,
34
- // while the in-page script instead calls the browser bundle's own exposed
35
- // copy (mirroring how the inline script calls `tmctSpiderFly.*` rather than
36
- // re-importing spider-fly-world.mjs).
31
+ // adventure-autoplay.mjs's own `roomOfSubject` already has to). Two further
32
+ // pure helpers are exported for testing but NOT spliced, because each calls
33
+ // an adventure.mjs export the in-page script instead reaches through the
34
+ // browser bundle's own `tmctAdventure` global (mirroring how the inline
35
+ // script calls `tmctSpiderFly.*` rather than re-importing
36
+ // spider-fly-world.mjs): `roomCaptionText` (calls `worldDigestRows`; the
37
+ // in-page `captionFor` mirrors it against `tmctAdventure.worldDigestRows`)
38
+ // and `pillsForRoom` (the room's clickable command suggestions — a thin
39
+ // wrapper over adventure.mjs's own exported `roomAffordances`, whose header
40
+ // explains why its list can never promise an action one of take/open/talk/
41
+ // examine would then refuse; the in-page `pillsFor` mirrors it against
42
+ // `tmctAdventure.roomAffordances`).
43
+ //
44
+ // The chat dock (chatlog/chatform/chatq/pills, below) mirrors
45
+ // spider-fly-viz.mjs's own side panel: every manual exchange (via
46
+ // adventure-browser-entry.mjs's new `session.turn(line)`) and every
47
+ // auto-play tick's own narration append to the SAME scrolling `#chatlog`, so
48
+ // a visitor sees one continuous history rather than a line overwritten every
49
+ // tick. `#caption`/`#goalLine` keep their existing job as the current-state
50
+ // summary; the chat log is the persistent addition.
37
51
  import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
38
52
  import { createTicker } from "./viz-ticker.mjs";
39
- import { worldDigestRows } from "./adventure.mjs";
53
+ import { worldDigestRows, roomAffordances } from "./adventure.mjs";
40
54
 
41
55
  const DEFAULT_TITLE = "tmct — the adventure";
42
56
  const PREVIEW_MAX_TICKS = 30;
@@ -58,6 +72,31 @@ export function spriteClassForObject(rows, subject) {
58
72
  return typeRow ? typeRow.object : "portable";
59
73
  }
60
74
 
75
+ /** `rows` plus one synthetic rdfs:subClassOf edge from `subject`'s own name
76
+ * to its declared sprite class (spriteClassForObject's own result) — never
77
+ * written back to the corpus, just handed to sprite-templates.mjs's
78
+ * resolver so a NAMED object (`cabinet`, `butler`) can carry its own sprite
79
+ * template while an unauthored object of the same declared class still
80
+ * falls back through it cleanly. Ashcombe Hall's own objects have no
81
+ * rdfs:subClassOf chain of their own (each is directly typed, e.g. `cabinet
82
+ * rdf:type furniture`); this is the exact same ancestor-walk mechanism
83
+ * spider-fly's real poodle-IsA-dog taxonomy already exercises, just
84
+ * synthesized at render time for a world whose taxonomy doesn't reach this
85
+ * deep. A no-op when the subject's own name already IS its declared class
86
+ * (nothing to synthesize). Pure. */
87
+ export function spriteAncestryRows(rows, subject) {
88
+ const declaredClass = spriteClassForObject(rows, subject);
89
+ if (subject === declaredClass) return rows;
90
+ return [...(rows || []), { subject, predicate: "rdfs:subClassOf", object: declaredClass }];
91
+ }
92
+
93
+ /** Every fact row belonging to `subject` — the small `{predicate, object}`
94
+ * set sprite-templates.mjs's resolver checks a parameterized/match template
95
+ * against (e.g. an mgx:hasProperty row). Pure. */
96
+ export function factsForSubject(rows, subject) {
97
+ return (rows || []).filter((r) => r.subject === subject);
98
+ }
99
+
61
100
  /** Every subject actually visible in `here`, sorted, each with its sprite
62
101
  * class — mirroring adventure.mjs's own private `visibleRoomOf` walk (one
63
102
  * containment hop through an OPEN container) so this can never draw a
@@ -86,6 +125,19 @@ export function roomSceneObjects(rows, state, here) {
86
125
  return out;
87
126
  }
88
127
 
128
+ /** The clickable command suggestions for the room the player is CURRENTLY
129
+ * in — a thin, testable wrapper over adventure.mjs's own `roomAffordances`
130
+ * (the exact same data take/open/talk/examine already check), so a pill can
131
+ * never promise an action one of those verbs would then refuse. Each
132
+ * returned string ("go north", "take lamp", "unlock cabinet", ...) is
133
+ * already a complete, submittable command in this world's own imperative
134
+ * grammar — the page inserts one into the chat input verbatim on click,
135
+ * never reformats it. Pure; recomputed fresh on every redraw, so a pill for
136
+ * a room the player has left, or an object already taken, never lingers. */
137
+ export function pillsForRoom(rows, state, here) {
138
+ return roomAffordances(rows, state, here);
139
+ }
140
+
89
141
  /** A short caption for `here`, built ONLY from the rows worldDigestRows
90
142
  * itself already produces (the exact same view the chat reply's own digest
91
143
  * reads) — every row already reads as a plain sentence
@@ -106,14 +158,26 @@ export function roomCaptionText(rows, state, here) {
106
158
  /** The self-contained adventure page. Pure given `worldPayload` (the build
107
159
  * step's own read of the real Ashcombe Hall world — `{ facts, rules,
108
160
  * opening }`), the same "byte-identical for identical input" invariant
109
- * every other viz page in this project holds. `?preview=1` switches into
110
- * the small, auto-playing, non-interactive mode the home page's hero iframe
111
- * embeds, matching spider-fly.html's own dual-purpose file. */
112
- export function renderAdventureHtml({ title = DEFAULT_TITLE, worldPayload = { facts: [], rules: [], opening: "" } } = {}) {
161
+ * every other viz page in this project holds. `spriteTemplates` is the
162
+ * build step's own read of data/sprites/*.toml (sprite-template-files.mjs's
163
+ * readSpriteTemplateFiles), embedded as page data the same way the world
164
+ * payload is the browser cannot read the filesystem, so the parsed
165
+ * templates travel as JSON rather than as a bundled fs read. Defaults to
166
+ * `[]` (every sprite falls back to the flat SPRITE_REGISTRY, unchanged from
167
+ * before this module existed) so existing callers that don't pass one keep
168
+ * working. `?preview=1` switches into the small, auto-playing, non-
169
+ * interactive mode the home page's hero iframe embeds, matching
170
+ * spider-fly.html's own dual-purpose file. */
171
+ export function renderAdventureHtml({
172
+ title = DEFAULT_TITLE,
173
+ worldPayload = { facts: [], rules: [], opening: "" },
174
+ spriteTemplates = [],
175
+ } = {}) {
113
176
  const pageData = embedJson({
114
177
  world: worldPayload,
115
178
  previewMaxTicks: PREVIEW_MAX_TICKS,
116
179
  tickWaitMs: TICK_WAIT_MS,
180
+ spriteTemplates,
117
181
  });
118
182
 
119
183
  return `<!doctype html>
@@ -132,6 +196,8 @@ ${THEME_TOKENS_CSS}
132
196
  h1 { font-size: 1.4rem; margin: .3rem 0 .9rem; text-wrap: balance; }
133
197
  button { font: inherit; color: inherit; background: none; cursor: pointer; }
134
198
  button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
199
+ .stage { display: grid; grid-template-columns: minmax(0, 1fr) 260px; gap: 1rem; align-items: start; }
200
+ @media (max-width: 760px) { .stage { grid-template-columns: 1fr; } }
135
201
  .room-frame { position: relative; min-height: 210px; background: var(--taught-soft); border: 1px solid var(--line); padding: 1rem; display: flex; flex-direction: column; gap: .8rem; justify-content: flex-end; }
136
202
  .sprite-row { display: flex; flex-wrap: wrap; gap: .6rem; align-items: flex-end; }
137
203
  .sprite { width: 44px; height: 44px; }
@@ -143,6 +209,23 @@ ${THEME_TOKENS_CSS}
143
209
  .sprite[data-cls="room"] { color: var(--muted); }
144
210
  .sprite-label { font-family: ${MONO_STACK}; font-size: .62rem; text-align: center; color: var(--muted); margin-top: .15rem; }
145
211
  .caption { background: var(--card); border: 1px solid var(--line); padding: .6rem .75rem; font-size: .9rem; }
212
+ .side { display: flex; flex-direction: column; gap: .8rem; min-width: 0; }
213
+ .chat { background: var(--card); border: 1px solid var(--line); padding: .6rem .75rem; }
214
+ .chat h2 { font-family: ${MONO_STACK}; font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); font-weight: 400; margin: 0 0 .5rem; }
215
+ .chatlog { display: flex; flex-direction: column; gap: .4rem; max-height: 320px; overflow-y: auto; margin-bottom: .5rem; }
216
+ .chatlog .u { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); }
217
+ .chatlog .u::before { content: "tmct> "; color: var(--taught); }
218
+ .chatlog .a { font-size: .88rem; line-height: 1.4; white-space: pre-wrap; }
219
+ .chatlog .t { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); font-style: italic; }
220
+ .chatlog .t::before { content: "\\2022 "; }
221
+ .pills { display: flex; flex-wrap: wrap; gap: .35rem; margin-bottom: .5rem; }
222
+ .pills:empty { display: none; margin-bottom: 0; }
223
+ .pill { font-family: ${MONO_STACK}; font-size: .72rem; padding: .25rem .6rem; border: 1px solid var(--line); background: var(--bg); color: var(--ink); border-radius: 999px; }
224
+ .pill:hover { border-color: var(--taught); }
225
+ .chatask { display: flex; align-items: center; gap: .5rem; border-top: 1px solid var(--line); padding-top: .5rem; }
226
+ .chatask .prompt { color: var(--taught); font-size: .78rem; font-family: ${MONO_STACK}; }
227
+ .chatask input { flex: 1; font-family: ${MONO_STACK}; font-size: .78rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); padding: .32rem .55rem; min-width: 0; }
228
+ .chatask input:disabled { opacity: .5; }
146
229
  .controls-row { display: flex; align-items: center; gap: .6rem; margin-top: 1rem; flex-wrap: wrap; }
147
230
  .controls-row button { font-family: ${MONO_STACK}; font-size: .78rem; padding: .3rem .7rem; border: 1px solid var(--line); background: var(--card); color: var(--ink); }
148
231
  .controls-row button:hover:not(:disabled) { border-color: var(--taught); }
@@ -150,8 +233,9 @@ ${THEME_TOKENS_CSS}
150
233
  .controls-row .turn { margin-left: auto; font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); font-variant-numeric: tabular-nums; }
151
234
  .goal-line { font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); margin-top: .5rem; }
152
235
  .status { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); margin-top: .3rem; }
153
- body.preview .controls-row, body.preview .status { display: none; }
236
+ body.preview .side, body.preview .controls-row, body.preview .status { display: none; }
154
237
  body.preview main { padding: 0; max-width: none; }
238
+ body.preview .stage { display: block; }
155
239
  body.preview .eyebrow, body.preview h1 { display: none; }
156
240
  </style>
157
241
  </head>
@@ -159,8 +243,21 @@ ${THEME_TOKENS_CSS}
159
243
  <main>
160
244
  <div class="eyebrow">tmct &middot; the adventure</div>
161
245
  <h1>A room, drawn from exactly what the text already says is there</h1>
162
- <div class="room-frame" id="roomFrame">
163
- <div class="sprite-row" id="spriteRow"></div>
246
+ <div class="stage">
247
+ <div class="room-frame" id="roomFrame">
248
+ <div class="sprite-row" id="spriteRow"></div>
249
+ </div>
250
+ <aside class="side" aria-label="The adventure's log and chat">
251
+ <div class="chat">
252
+ <h2>what's happened, and what you can do</h2>
253
+ <div class="chatlog" id="chatlog" aria-live="polite"></div>
254
+ <div class="pills" id="pills"></div>
255
+ <form class="chatask" id="chatform">
256
+ <span class="prompt mono">tmct&gt;</span>
257
+ <input id="chatq" type="text" placeholder="go north" aria-label="Type a command, or ask a question" disabled>
258
+ </form>
259
+ </div>
260
+ </aside>
164
261
  </div>
165
262
  <div class="caption" id="caption"></div>
166
263
  <div class="goal-line" id="goalLine"></div>
@@ -182,6 +279,8 @@ const ADVENTURE = ${pageData};
182
279
  const createTicker = ${createTicker.toString()};
183
280
  const spriteClassForObject = ${spriteClassForObject.toString()};
184
281
  const roomSceneObjects = ${roomSceneObjects.toString()};
282
+ const spriteAncestryRows = ${spriteAncestryRows.toString()};
283
+ const factsForSubject = ${factsForSubject.toString()};
185
284
  const esc = ${escapeHtml.toString()};
186
285
  const el = (id) => document.getElementById(id);
187
286
  const spriteRow = el("spriteRow");
@@ -192,6 +291,10 @@ const ADVENTURE = ${pageData};
192
291
  const resetBtn = el("resetBtn");
193
292
  const playBtn = el("playBtn");
194
293
  const stepBtn = el("stepBtn");
294
+ const chatlogEl = el("chatlog");
295
+ const pillsEl = el("pills");
296
+ const chatformEl = el("chatform");
297
+ const chatqEl = el("chatq");
195
298
 
196
299
  const params = new URLSearchParams(location.search);
197
300
  const preview = params.get("preview") === "1";
@@ -208,44 +311,124 @@ const ADVENTURE = ${pageData};
208
311
  return lines.length ? lines.join(" ") : "Nothing more about the " + here + " is written down yet.";
209
312
  }
210
313
 
314
+ // ---- chat/event log — every manual exchange AND every auto-play tick's
315
+ // own narration append here, in order, so it reads as one continuous
316
+ // history rather than a line overwritten every tick.
317
+ function addChatLine(cls, html) {
318
+ const d = document.createElement("div");
319
+ d.className = cls; d.innerHTML = html;
320
+ chatlogEl.appendChild(d); chatlogEl.scrollTop = chatlogEl.scrollHeight;
321
+ }
322
+
323
+ // ---- contextual pills — refreshed every redraw from the CURRENT room's
324
+ // own roomAffordances-derived list (mirroring pillsForRoom against
325
+ // tmctAdventure.roomAffordances, the same way captionFor above mirrors
326
+ // roomCaptionText against tmctAdventure.worldDigestRows), so a pill for a
327
+ // room the player has left, or an object already taken, never lingers.
328
+ // Clicking one inserts its exact command text into the input and focuses
329
+ // it; it never auto-submits, so free typing still works and a clicked
330
+ // suggestion can still be edited first.
331
+ function pillsFor(rows, state, here) {
332
+ return tmctAdventure.roomAffordances(rows, state, here);
333
+ }
334
+ function renderPills(rows, state, here) {
335
+ const actions = pillsFor(rows, state, here);
336
+ pillsEl.innerHTML = actions.map((a) => '<button type="button" class="pill">' + esc(a) + "</button>").join("");
337
+ }
338
+ pillsEl.addEventListener("click", (e) => {
339
+ const btn = e.target.closest(".pill");
340
+ if (!btn || !chatqEl) return;
341
+ chatqEl.value = btn.textContent;
342
+ chatqEl.focus();
343
+ });
344
+
345
+ // ---- sprite resolution — property/instance-aware (sprite-templates.mjs's
346
+ // resolveSpriteAsset), keyed on the OBJECT'S OWN NAME (via
347
+ // spriteAncestryRows' synthetic subClassOf edge to its declared class) so
348
+ // a named object (cabinet, butler) can carry its own data/sprites/*.toml
349
+ // template while the data-cls attribute still reflects the DECLARED class
350
+ // (container/furniture/portable/person/room) — the CSS accent-color rules
351
+ // stay keyed on that declared class unchanged. The player's own "you" row
352
+ // has no backing fact row, so it resolves directly by its fixed
353
+ // "adventurer" class with no ancestry/property rows to check.
354
+ function resolveObjectSprite(rows, s) {
355
+ if (s.subject === "you") return tmctAdventure.resolveSpriteAsset("adventurer", [], [], ADVENTURE.spriteTemplates, tmctAdventure.SPRITE_REGISTRY);
356
+ return tmctAdventure.resolveSpriteAsset(
357
+ s.subject, spriteAncestryRows(rows, s.subject), factsForSubject(rows, s.subject),
358
+ ADVENTURE.spriteTemplates, tmctAdventure.SPRITE_REGISTRY,
359
+ );
360
+ }
361
+
211
362
  function redraw(snap) {
212
363
  const objects = roomSceneObjects(snap.rows, snap.state, snap.here);
213
364
  const sprites = [{ subject: "you", spriteClass: "adventurer" }, ...objects];
214
365
  spriteRow.innerHTML = sprites.map((s) => {
215
- const svg = tmctAdventure.resolveSpriteForClass(s.spriteClass, [], tmctAdventure.SPRITE_REGISTRY);
366
+ const svg = resolveObjectSprite(snap.rows, s);
216
367
  return '<div><div class="sprite" data-cls="' + esc(s.spriteClass) + '">' + svg + '</div>'
217
368
  + '<div class="sprite-label">' + esc(s.subject) + "</div></div>";
218
369
  }).join("");
219
370
  captionEl.textContent = captionFor(snap.rows, snap.state, snap.here);
220
371
  turnLabelEl.textContent = "turn: " + snap.turn;
372
+ renderPills(snap.rows, snap.state, snap.here);
373
+ }
374
+
375
+ // ---- serialize every engine-touching call: the ticker and the chat dock
376
+ // share one in-memory store, and an overlapping tick()/turn() pair could
377
+ // race against the same @turnN write.
378
+ let lock = Promise.resolve();
379
+ function withLock(fn) {
380
+ const run = lock.then(fn, fn);
381
+ lock = run.catch(() => {});
382
+ return run;
221
383
  }
222
384
 
385
+ chatformEl.addEventListener("submit", (e) => {
386
+ e.preventDefault();
387
+ const q = chatqEl.value.trim();
388
+ if (!q || !session) return;
389
+ chatqEl.value = "";
390
+ // A manual command lands mid-tick otherwise — pause first, and stay
391
+ // paused until the visitor presses play again.
392
+ ticker.pause();
393
+ addChatLine("u", esc(q));
394
+ withLock(async () => {
395
+ const result = await session.turn(q);
396
+ addChatLine("a", esc(result.answer).replace(/\\n/g, "<br>"));
397
+ const snap = await session.snapshot();
398
+ redraw(snap);
399
+ });
400
+ });
401
+
223
402
  async function boot() {
224
403
  session = await tmctAdventure.createAdventureSession(ADVENTURE.world);
225
404
  lastTicks = 0;
226
405
  const snap = await session.snapshot();
227
406
  redraw(snap);
228
407
  goalLineEl.textContent = "";
408
+ chatlogEl.innerHTML = "";
229
409
  statusEl.textContent = ADVENTURE.world.opening || "";
410
+ addChatLine("t", esc(ADVENTURE.world.opening || "the adventure begins."));
411
+ chatqEl.disabled = false;
230
412
  resetBtn.disabled = false; playBtn.disabled = false; stepBtn.disabled = false;
231
413
  }
232
414
 
233
415
  const ticker = createTicker({
234
- onTick: async () => {
416
+ onTick: () => withLock(async () => {
235
417
  const result = await session.autoplayTick();
236
418
  lastTicks += 1;
237
419
  const snap = await session.snapshot();
238
420
  redraw(snap);
239
421
  goalLineEl.textContent = result.goal || "";
422
+ addChatLine("t", esc(result.goal || ""));
240
423
  if (result.done || result.stalled) ticker.pause();
241
- },
424
+ }),
242
425
  onRender: (state) => {
243
426
  playBtn.textContent = state.playing ? "\\u23f8 pause" : "\\u25b6 play";
244
427
  playBtn.disabled = state.animating;
245
428
  stepBtn.disabled = state.animating || state.playing;
246
429
  resetBtn.disabled = state.animating;
247
430
  },
248
- onReset: () => boot(),
431
+ onReset: () => withLock(boot),
249
432
  hasNext: () => !preview || lastTicks < ADVENTURE.previewMaxTicks,
250
433
  waitMs: ADVENTURE.tickWaitMs,
251
434
  });
@@ -251,6 +251,20 @@ const carriedByPlayer = (state, thing) => {
251
251
  return !!place && place.predicate === "mgx:located-in" && place.object === "player";
252
252
  };
253
253
 
254
+ /** True when `object` is never a real placed game entity (no entry in
255
+ * state.placements at all — checked first, so a hidden or elsewhere-placed
256
+ * object never counts, only a term with NO placement anywhere) but some
257
+ * OTHER fact still names it — almost always the background human/ConceptNet
258
+ * corpus overlapping a room's own vocabulary ("garden mgx:hasA flower"),
259
+ * which worldDigestRows already renders into the room's own prose ("Garden
260
+ * has flower"). Without this distinction, examine/take/talk's shared
261
+ * presence decline ("I don't see a flower here") directly contradicts what
262
+ * the room's own description just said, in the same conversation. */
263
+ function backgroundOnlyMention(rows, state, object) {
264
+ if (state.placements.has(object)) return false;
265
+ return (rows || []).some((r) => r.subject === object || r.object === object);
266
+ }
267
+
254
268
  /** The room's real affordances — every exit, and every visible object's
255
269
  * applicable verb — read from the EXACT SAME data take/open/talk/examine
256
270
  * already check (visibleRoomOf, isContainer, isTyped, the placement
@@ -552,7 +566,23 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
552
566
  // existing rather than the true state (you're in it). Treat naming the
553
567
  // current room itself as always present.
554
568
  const isCurrentRoom = object === here;
555
- if (!carried && !isCurrentRoom && visibleRoomOf(object, { rows, state }) !== here) {
569
+ const notHere = !carried && !isCurrentRoom && visibleRoomOf(object, { rows, state }) !== here;
570
+ // A background-only mention (e.g. "flower", surfaced only through the
571
+ // human corpus overlapping this room's own vocabulary — see
572
+ // backgroundOnlyMention's own docblock) is real, sourced knowledge, just
573
+ // never a placed prop. "talk" still declines (it's not a person), but
574
+ // honestly — never claiming the term doesn't exist when the room's own
575
+ // digest just said otherwise. "examine" instead falls through to the
576
+ // ordinary digest below, the same one "what is a flower" already answers
577
+ // from outside the game.
578
+ if (notHere && cmd.verb === "talk" && backgroundOnlyMention(rows, state, object)) {
579
+ return answer(
580
+ `the ${object} isn't someone you can talk to here — it's only mentioned in passing, not a real person in this scene.`,
581
+ noteFor(`talk — ${object} is a background-only mention, not a placed NPC; declined honestly`),
582
+ { miss: true },
583
+ );
584
+ }
585
+ if (notHere && !(cmd.verb === "examine" && backgroundOnlyMention(rows, state, object))) {
556
586
  return answer(
557
587
  `I don't see a ${object} here.`,
558
588
  noteFor(`${cmd.verb} — ${object} isn't visible in the ${here}; declined, hidden things stay hidden`),
@@ -650,6 +680,13 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
650
680
  return answer(`you can't take the ${object}.`, noteFor("take — the object is one of the cast; declined"), { miss: true });
651
681
  }
652
682
  if (visibleRoomOf(object, { rows, state }) !== here) {
683
+ if (backgroundOnlyMention(rows, state, object)) {
684
+ return answer(
685
+ `the ${object} isn't something you can take — it's only mentioned in passing here, not a real prop in this scene.`,
686
+ noteFor(`take — ${object} is a background-only mention, never a placed object; declined honestly`),
687
+ { miss: true },
688
+ );
689
+ }
653
690
  return answer(`I don't see a ${object} here.`, noteFor(`take — ${object} isn't visible in the ${here}; declined, hidden things stay hidden`), { miss: true });
654
691
  }
655
692
  return commit(
@@ -693,8 +730,13 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
693
730
  // precond/effect rows consulted through domain.mjs below; only the
694
731
  // hidden-contents reveal (a variable-arity effect over a discovered set)
695
732
  // stays hand-written JS, since no shipped rule shape covers that either.
696
- const presentHere = place && place.predicate !== "mgx:hidden-in" && place.predicate !== "mgx:currently-in" && place.object === here;
697
- if (!presentHere) {
733
+ // Presence reuses visibleRoomOf (the SAME check examine/talk/take already
734
+ // use), not a hand-rolled duplicate: an earlier version of this check
735
+ // excluded mgx:currently-in outright, so a genuinely-present NPC (placed
736
+ // that way, not fixed-in/stands-locked-in) fell into "I don't see a X
737
+ // here" instead of reaching the isContainer check just below, which would
738
+ // have honestly said "the X doesn't open."
739
+ if (visibleRoomOf(object, { rows, state }) !== here) {
698
740
  return answer(`I don't see a ${object} here.`, noteFor(`${cmd.verb} — ${object} isn't in the ${here}; declined`), { miss: true });
699
741
  }
700
742
  if (!isContainer(rows, object)) {
@@ -27,6 +27,7 @@ import { createTelemetry } from "./telemetry.mjs";
27
27
  import * as defaultSource from "../adapters/source.mjs";
28
28
  import { resolveExtensions, mergedLexiconExtra } from "./extensions.mjs";
29
29
  import { runTurn, hasSeededVocabulary, vocabExampleHint } from "./chat.mjs";
30
+ import { resolveGameConfig } from "../domain/game-config.mjs";
30
31
 
31
32
  /** Where session logs live, relative to the target repo. `.tmct/` is the repo's
32
33
  * one artifact directory (gitignored, machine-local) — flip this single constant
@@ -182,6 +183,13 @@ export async function createSession({
182
183
  }
183
184
  }
184
185
 
186
+ // Every game's tuning knobs (spider-fly's mass economy, guess-the-number's
187
+ // bounds, the shared plan lane's search-depth cap), resolved once per
188
+ // session from this repo's tmct.toml — resolveGameConfig tolerates `toml`
189
+ // being null (no file, or one that failed to load above) by falling back
190
+ // to the shipped defaults for every key.
191
+ const gameConfig = resolveGameConfig(toml);
192
+
185
193
  // Ephemeral: keep config.graphFile pointing at the READ graph, but divert the
186
194
  // write base (repo → logs/memory/sessions) to a throwaway temp dir. The committed
187
195
  // target is never touched; the demo's memory simply doesn't persist across runs.
@@ -338,7 +346,7 @@ export async function createSession({
338
346
  async turn(line) {
339
347
  let result;
340
348
  try {
341
- result = await runTurn(line, { config, source, graph, focus, last, memoryDir, sessionId, env, lexicon, narrate: narrateOn, vocabHint, tel, biasByBundle, planState });
349
+ result = await runTurn(line, { config, source, graph, focus, last, memoryDir, sessionId, env, lexicon, narrate: narrateOn, vocabHint, tel, biasByBundle, planState, gameConfig });
342
350
  } catch (e) {
343
351
  const ts = new Date().toISOString();
344
352
  const message = e instanceof Error ? e.message : String(e);
@@ -54,6 +54,7 @@ import { dialogueActForLane } from "../domain/dialogue-acts.mjs";
54
54
  import { relatedForTerm } from "../domain/skos-view.mjs";
55
55
  import { adventureTurn, unclaimedAdventureOpening } from "./adventure.mjs";
56
56
  import { spiderFlyTurn } from "./spider-fly-turn.mjs";
57
+ import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
57
58
 
58
59
  // Composition: the chat surface supplies the domain parser's default lemma/POS
59
60
  // adapter (the browser bundle's ask-nlp stub carries no factory, so this is a
@@ -1559,6 +1560,16 @@ export function renderVerbose(last) {
1559
1560
  function conversationalTurn(line, ctx) {
1560
1561
  const raw = String(line);
1561
1562
  const q = raw.toLowerCase().replace(/[.!?]+$/, "").replace(/\s+/g, " ").trim();
1563
+ // A live game (adventure/spider-fly/guess-the-number) already recognizes its
1564
+ // OWN exact stop phrase ("stop playing", "I give up", ...) before this lane
1565
+ // ever sees the line, so a bare word reaching here mid-game was never meant
1566
+ // as a farewell — it's an in-game noun that fell through every game-shaped
1567
+ // command check (e.g. "player", the adventure's own subject). The fuzzy-typo
1568
+ // fallback below is a GUESS (bounded edit distance against "later" etc.),
1569
+ // and a wrong guess ends the whole session — too costly a mistake to risk
1570
+ // mid-game. The exact/closed-set farewell just above and below this guard
1571
+ // stays live either way (a real "bye"/"exit" is unambiguous, never a guess).
1572
+ const gameActive = Boolean(ctx.planHolder?.state?.adventure || ctx.planHolder?.state?.spiderFly || ctx.planHolder?.state?.game);
1562
1573
  const t = (id, slots = {}) => tRender(ctx.templates, id, slots) ?? TEMPLATES_UNAVAILABLE;
1563
1574
  const mk = (answer, { end = false, miss = false, via = "template", lane = null } = {}) => {
1564
1575
  const ts = new Date().toISOString();
@@ -1686,8 +1697,10 @@ function conversationalTurn(line, ctx) {
1686
1697
  // Fuzzy-typo fallback (A4): every exact/collapsed closed-set lookup above missed —
1687
1698
  // try a bounded edit-distance match against the flattened conversational phrase
1688
1699
  // pool ("helo", "thnx", "wat r u", "byee"), restricted to short non-code-ish
1689
- // input so a genuine near-miss structural question is never grabbed.
1690
- {
1700
+ // input so a genuine near-miss structural question is never grabbed. Skipped
1701
+ // entirely mid-game (see gameActive above) — never reached the CLI's own
1702
+ // process-exit path from a guess before this fix existed.
1703
+ if (!gameActive) {
1691
1704
  const fuzzyHit = fuzzyConversationalMatch(raw);
1692
1705
  if (fuzzyHit) {
1693
1706
  const bucket = classifyConversational(fuzzyHit);
@@ -10054,7 +10067,7 @@ const sameGoalSpec = (a, b) =>
10054
10067
  * taught action rules (PLAN_HANOI's chat surface). Returns
10055
10068
  * { text, via, deduced, note, plan? } or null when the query is none of the
10056
10069
  * three shapes. Mutates planHolder.state (the session's plan slot). */
10057
- async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", }) {
10070
+ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", gameConfig = DEFAULT_GAME_CONFIG }) {
10058
10071
  let q = String(query).trim();
10059
10072
  // GOAL REVISION — "actually the goal is …", "instead, the goal is …", "the
10060
10073
  // goal is now …": a revision marker ahead of (or inside) a goal frame means
@@ -10349,9 +10362,10 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
10349
10362
  return { text: `I can't compile that goal: ${err?.message ?? err}`, via: "plan", deduced: "plan a move sequence (uncompilable goal)", note: "plan lane — goal compile decline" };
10350
10363
  }
10351
10364
  const { findActionPath } = await import("../domain/planning.mjs");
10365
+ const maxDepth = gameConfig?.planning?.maxDepth ?? DEFAULT_GAME_CONFIG.planning.maxDepth;
10352
10366
  let found;
10353
10367
  try {
10354
- found = findActionPath(state, isGoal, (s) => movesFromRules(s, domain), { maxDepth: 300, stateKey: stateKeyFor });
10368
+ found = findActionPath(state, isGoal, (s) => movesFromRules(s, domain), { maxDepth, stateKey: stateKeyFor });
10355
10369
  } catch (err) {
10356
10370
  if (err instanceof PlanBudgetError) {
10357
10371
  return { text: `the search space is too large (${err.message}) — narrow the classes involved.`, via: "plan", deduced: "plan a move sequence (budget exceeded)", note: "plan lane — budget decline" };
@@ -10360,7 +10374,7 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
10360
10374
  }
10361
10375
  if (!found) {
10362
10376
  return {
10363
- text: `no plan found within 300 moves from the current state to: ${goalText}.`,
10377
+ text: `no plan found within ${maxDepth} moves from the current state to: ${goalText}.`,
10364
10378
  via: "plan", deduced: "plan a move sequence (no path)", note: "plan lane — honest miss: findActionPath returned null",
10365
10379
  };
10366
10380
  }
@@ -10635,7 +10649,7 @@ const DECISION_RECALL_RE = /^(?:remind\s+me\s+)?what\s+(?:did\s+)?(?:we|i|you)\s
10635
10649
  * than silently accepted alongside the current location. */
10636
10650
  const MOVE_HISTORY_RE = /^where\s+did\s+(.+?)\s+(?:move|get\s+moved|go)(?:\s+to)?[?.!\s]*$/i;
10637
10651
 
10638
- async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {}, cache = null, vocabAntecedent = null, planHolder = null }) {
10652
+ async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {}, cache = null, vocabAntecedent = null, planHolder = null, gameConfig = DEFAULT_GAME_CONFIG }) {
10639
10653
  const ts = new Date().toISOString();
10640
10654
  // DISCOURSE ANAPHORA: a follow-up like "which of those are tested" / "count
10641
10655
  // them" filters or counts the PREVIOUS answer's entity set, threaded as
@@ -11006,7 +11020,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11006
11020
  // orientation card before this lane ever ran.
11007
11021
  let planResult = null;
11008
11022
  if (!handled && miss && memoryDir && planHolder) {
11009
- const planLane = await planLaneAnswer(query, { memoryDir, planHolder, sessionId });
11023
+ const planLane = await planLaneAnswer(query, { memoryDir, planHolder, sessionId, gameConfig });
11010
11024
  if (planLane) {
11011
11025
  answer = planLane.text; via = planLane.via; recordMiss = false; handled = true;
11012
11026
  if (planLane.lane) dialogueLaneOverride = planLane.lane;
@@ -12449,22 +12463,23 @@ async function skosRelatedAnswer(memoryDir, query, cache) {
12449
12463
  * captures (\S+) so a non-numeric bound is SEEN and declined rather than
12450
12464
  * silently defaulted. */
12451
12465
  const GAME_BOUNDS_CLAUSE_RE = /\b(?:between\s+(\S+)\s+and\s+(\S+)|up\s+to\s+(\S+))\b/i;
12452
- const GAME_BOUND_MAX = 1_000_000_000;
12453
12466
 
12454
- /** The bounds an opening line states — { lo, hi } (default 1–100), or
12455
- * { problem } naming why the stated range is unplayable. */
12456
- function parseGameBounds(text) {
12467
+ /** The bounds an opening line states — { lo, hi } (default `gameConfig`'s own
12468
+ * defaultLo/defaultHi), or { problem } naming why the stated range is
12469
+ * unplayable. `gameConfig` defaults to DEFAULT_GAME_CONFIG.guessNumber. */
12470
+ function parseGameBounds(text, gameConfig = DEFAULT_GAME_CONFIG.guessNumber) {
12471
+ const { defaultLo, defaultHi, maxBound } = gameConfig;
12457
12472
  const m = String(text).match(GAME_BOUNDS_CLAUSE_RE);
12458
- if (!m) return { lo: 1, hi: 100 };
12459
- const tokens = (m[3] !== undefined ? ["1", m[3]] : [m[1], m[2]])
12473
+ if (!m) return { lo: defaultLo, hi: defaultHi };
12474
+ const tokens = (m[3] !== undefined ? [String(defaultLo), m[3]] : [m[1], m[2]])
12460
12475
  .map((t) => String(t).replace(/[,.?!]+$/, ""));
12461
12476
  if (!tokens.every((t) => /^-?\d+$/.test(t))) {
12462
- return { problem: 'I can only play with whole-number bounds — say "between 1 and 100".' };
12477
+ return { problem: `I can only play with whole-number bounds — say "between ${defaultLo} and ${defaultHi}".` };
12463
12478
  }
12464
12479
  const lo = Number(tokens[0]);
12465
12480
  const hi = Number(tokens[1]);
12466
- if (Math.abs(lo) > GAME_BOUND_MAX || Math.abs(hi) > GAME_BOUND_MAX) {
12467
- return { problem: `that range is too big for a fair game — keep both bounds within ${GAME_BOUND_MAX.toLocaleString("en-US")}.` };
12481
+ if (Math.abs(lo) > maxBound || Math.abs(hi) > maxBound) {
12482
+ return { problem: `that range is too big for a fair game — keep both bounds within ${maxBound.toLocaleString("en-US")}.` };
12468
12483
  }
12469
12484
  if (hi < lo) return { problem: `no number is between ${lo} and ${hi} — that range is empty. Put the smaller bound first.` };
12470
12485
  if (hi === lo) return { problem: `between ${lo} and ${hi} leaves exactly one number, so there is nothing to guess. Pick a wider range.` };
@@ -12485,20 +12500,21 @@ const THINKER_OPEN_TAIL_RE = /^[\s,.!?—-]*(?:and\s+)?(?:i\s*(?:'ll|\s+will)\s+
12485
12500
  const INVITATION_OPEN_LEAD_RE = /^(?:let'?s\s+play|wanna\s+play|want\s+to\s+play|can\s+we\s+play|shall\s+we\s+play|do\s+you\s+want\s+to\s+play|will\s+you\s+play|play)\s+(?:a\s+)?(?:game\s+of\s+)?(?:guess[- ]the[- ]number|number[- ]guessing(?:\s+game)?|guessing\s+game)\b(.*)$/i;
12486
12501
  const INVITATION_OPEN_TAIL_RE = /^[\s,.!?—-]*(?:with\s+me|together)?[\s,.!?—-]*$/i;
12487
12502
 
12488
- /** An opening move — { mode, bounds } — or null. */
12489
- function matchGameOpening(line) {
12503
+ /** An opening move — { mode, bounds } — or null. `gameConfig` defaults to
12504
+ * DEFAULT_GAME_CONFIG.guessNumber and threads through to parseGameBounds. */
12505
+ function matchGameOpening(line, gameConfig = DEFAULT_GAME_CONFIG.guessNumber) {
12490
12506
  const l = String(line).trim();
12491
12507
  const guesser = l.match(GUESSER_OPEN_LEAD_RE);
12492
12508
  if (guesser && GUESSER_OPEN_TAIL_RE.test(guesser[1].replace(GAME_BOUNDS_CLAUSE_RE, " "))) {
12493
- return { mode: "guesser", bounds: parseGameBounds(l) };
12509
+ return { mode: "guesser", bounds: parseGameBounds(l, gameConfig) };
12494
12510
  }
12495
12511
  const thinker = l.match(THINKER_OPEN_LEAD_RE);
12496
12512
  if (thinker && THINKER_OPEN_TAIL_RE.test(thinker[1].replace(GAME_BOUNDS_CLAUSE_RE, " "))) {
12497
- return { mode: "thinker", bounds: parseGameBounds(l) };
12513
+ return { mode: "thinker", bounds: parseGameBounds(l, gameConfig) };
12498
12514
  }
12499
12515
  const invite = l.match(INVITATION_OPEN_LEAD_RE);
12500
12516
  if (invite && INVITATION_OPEN_TAIL_RE.test(invite[1].replace(GAME_BOUNDS_CLAUSE_RE, " "))) {
12501
- return { mode: "thinker", bounds: parseGameBounds(l) };
12517
+ return { mode: "thinker", bounds: parseGameBounds(l, gameConfig) };
12502
12518
  }
12503
12519
  return null;
12504
12520
  }
@@ -12611,10 +12627,10 @@ function gameContinuationAnswer(line, game, planHolder) {
12611
12627
  /** The whole game lane for one turn: continuations first (active game only),
12612
12628
  * then opening moves, with the one-at-a-time declines both ways across the
12613
12629
  * shared plan slot. Null when the turn is not the game's to answer. */
12614
- function guessNumberTurn(line, { planHolder, env }) {
12630
+ function guessNumberTurn(line, { planHolder, env, gameConfig = DEFAULT_GAME_CONFIG }) {
12615
12631
  const state = planHolder?.state ?? null;
12616
12632
  const game = state?.game ?? null;
12617
- const opening = matchGameOpening(line);
12633
+ const opening = matchGameOpening(line, gameConfig?.guessNumber);
12618
12634
  if (game) {
12619
12635
  const continuation = gameContinuationAnswer(line, game, planHolder);
12620
12636
  if (continuation) return continuation;
@@ -12855,7 +12871,13 @@ function vocabAntecedentFrom(last) {
12855
12871
  return m[1];
12856
12872
  }
12857
12873
 
12858
- export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null, planState = null, _noSplit = false } = {}) {
12874
+ export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null, planState = null, gameConfig = null, _noSplit = false } = {}) {
12875
+ // Every game's tuning knobs (spider-fly's mass economy, guess-the-number's
12876
+ // bounds, the shared plan lane's search-depth cap) — a caller's own
12877
+ // gameConfig (chat-session.mjs resolves one per session from tmct.toml)
12878
+ // wins outright; direct/library callers that omit it get the shipped
12879
+ // defaults, byte-identical to before this seam existed.
12880
+ const resolvedGameConfig = gameConfig ?? DEFAULT_GAME_CONFIG;
12859
12881
  const line = String(input ?? "").trim();
12860
12882
  // ONE fresh, empty cache for this turn only — every factRows() reader
12861
12883
  // reached from this call shares it, so the first reader computes
@@ -12912,7 +12934,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
12912
12934
  // the PLAN NEXT block below write planHolder.state; every other path leaves
12913
12935
  // it untouched, and the caller re-threads whatever comes back.
12914
12936
  const planHolder = { state: planState };
12915
- const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint, tel, biasByBundle, cache: factRowsCache, vocabAntecedent, planHolder };
12937
+ const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint, tel, biasByBundle, cache: factRowsCache, vocabAntecedent, planHolder, gameConfig: resolvedGameConfig };
12916
12938
  // A DISPATCHED turn (count / slash-command / ask) becomes the new "last
12917
12939
  // answer" that why/say-more re-renders; a conversational turn does not.
12918
12940
  // Every dispatched turn's result passes through finish() here — the LAST
@@ -12973,7 +12995,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
12973
12995
  // line would otherwise read as a declarative to remember. A mid-game line
12974
12996
  // matching no game shape returns null here and the game stands untouched.
12975
12997
  {
12976
- const gameTurn = guessNumberTurn(workingLine, { planHolder, env });
12998
+ const gameTurn = guessNumberTurn(workingLine, { planHolder, env, gameConfig: resolvedGameConfig });
12977
12999
  if (gameTurn) {
12978
13000
  note(trace, `lane: ${gameTurn.note}`);
12979
13001
  if (gameTurn.goal) note(trace, `goal: ${gameTurn.goal}`);
@@ -13015,7 +13037,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
13015
13037
  // opening line would otherwise read as a declarative or an orientation ask.
13016
13038
  {
13017
13039
  const sfTurn = await spiderFlyTurn(workingLine, {
13018
- planHolder, memoryDir, env, cache: factRowsCache, isPlanFrameLine,
13040
+ planHolder, memoryDir, env, cache: factRowsCache, isPlanFrameLine, gameConfig: resolvedGameConfig,
13019
13041
  });
13020
13042
  if (sfTurn) {
13021
13043
  note(trace, `lane: ${sfTurn.note}`);