@polycode-projects/the-mechanical-code-talker 2.7.22 → 2.7.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -847,6 +847,29 @@ enabled = false # local-only counters; never phones home
847
847
  [memory]
848
848
  retention_versions = 5 # snapshot generations memory/core.mjs keeps on manifest bootstrap
849
849
  backend = "sqlite" # default | memory | sqlite (see "Memory backends" above)
850
+
851
+ # Game tuning knobs (src/domain/game-config.mjs). Every OTHER game parameter
852
+ # (disk/peg counts, the goal, ...) lives in the game's own taught-English
853
+ # world instead — these are the handful of genuine magic numbers that
854
+ # aren't expressible that way.
855
+ [games.spider-fly]
856
+ spider_initial_mass = 15
857
+ spider_mass_decrement_per_turn = 0.5 # lower = slower to starve
858
+ fly_initial_mass = 10
859
+ fly_mass_decrement_per_turn = 1
860
+ vision_radius = 4 # Chebyshev radius an agent can see other agents within
861
+ egg_hatch_delay_turns = 3 # turns between a lay and its hatch
862
+ fly_spawn_interval_turns = 3 # a new fly arrives every Nth turn
863
+ eggs_eaten_threshold = 2 # flies eaten since the last egg before the next one lays
864
+ web_duration_turns = 10 # turns a spider-built web stays active
865
+
866
+ [games.guess-number]
867
+ default_lo = 1 # the range's default lower bound, when the opening line states none
868
+ default_hi = 100 # the range's default upper bound, when the opening line states none
869
+ max_bound = 1000000000 # sanity cap on either bound, however the opening line states it
870
+
871
+ [planning]
872
+ max_depth = 300 # the "solve it" plan lane's search-depth cap (hanoi, river-crossing, any taught-rule domain)
850
873
  ```
851
874
 
852
875
  ### Try it on an example graph
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "2.7.22",
3
+ "version": "2.7.24",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
@@ -117,6 +117,14 @@ export async function normalizeConfig(raw, { configDir } = {}) {
117
117
  if (src.extensions !== undefined) cfg.extensions = src.extensions;
118
118
  if (src.bias !== undefined) cfg.bias = src.bias;
119
119
 
120
+ // Game tuning knobs (src/domain/game-config.mjs): sparse PASS-THROUGH only,
121
+ // same discipline as [extensions]/[bias] above — the raw `[games.*]`/
122
+ // `[planning]` tables ride through unmodified (snake_case keys); mapping
123
+ // them onto the internal camelCase shape and filling in unset keys from
124
+ // the shipped defaults is resolveGameConfig's job, never this module's.
125
+ if (src.games !== undefined) cfg.games = src.games;
126
+ if (src.planning !== undefined) cfg.planning = src.planning;
127
+
120
128
  const idx = src.index || {};
121
129
  const index = {};
122
130
  if (idx.languages !== undefined) index.languages = idx.languages;
@@ -0,0 +1,90 @@
1
+ // game-config.mjs — the shipped defaults for every game's tuning knobs
2
+ // (spider-fly's mass economy, guess-the-number's default/max bounds, the
3
+ // shared plan lane's search-depth cap) and the pure function that folds a
4
+ // normalized tmct.toml's [games]/[planning] tables over them.
5
+ //
6
+ // Every other game parameter lives in the game's own taught-English world
7
+ // definition (data/games/hanoi-3.txt and friends) and never needs a knob
8
+ // here — only a genuine magic number with no taught-fact home does.
9
+ //
10
+ // Pure: no filesystem access, no module-level mutable state. resolveGameConfig
11
+ // is safe to call once per session and the result handed around freely —
12
+ // several games/tests running in the same process never share or mutate one
13
+ // another's resolved config.
14
+
15
+ export const DEFAULT_GAME_CONFIG = Object.freeze({
16
+ spiderFly: Object.freeze({
17
+ spiderInitialMass: 15,
18
+ spiderMassDecrementPerTurn: 0.5,
19
+ flyInitialMass: 10,
20
+ flyMassDecrementPerTurn: 1,
21
+ visionRadius: 4,
22
+ eggHatchDelayTurns: 3,
23
+ flySpawnIntervalTurns: 3,
24
+ eggsEatenThreshold: 2,
25
+ webDurationTurns: 10,
26
+ }),
27
+ guessNumber: Object.freeze({
28
+ defaultLo: 1,
29
+ defaultHi: 100,
30
+ maxBound: 1_000_000_000,
31
+ }),
32
+ planning: Object.freeze({
33
+ maxDepth: 300,
34
+ }),
35
+ });
36
+
37
+ // snake_case tmct.toml key -> camelCase internal key, one map per table —
38
+ // mirrors how toml-config.mjs's normalizeConfig maps every other section
39
+ // (e.g. [tune]) onto its own internal shape.
40
+ const SPIDER_FLY_KEY_MAP = Object.freeze({
41
+ spider_initial_mass: "spiderInitialMass",
42
+ spider_mass_decrement_per_turn: "spiderMassDecrementPerTurn",
43
+ fly_initial_mass: "flyInitialMass",
44
+ fly_mass_decrement_per_turn: "flyMassDecrementPerTurn",
45
+ vision_radius: "visionRadius",
46
+ egg_hatch_delay_turns: "eggHatchDelayTurns",
47
+ fly_spawn_interval_turns: "flySpawnIntervalTurns",
48
+ eggs_eaten_threshold: "eggsEatenThreshold",
49
+ web_duration_turns: "webDurationTurns",
50
+ });
51
+
52
+ const GUESS_NUMBER_KEY_MAP = Object.freeze({
53
+ default_lo: "defaultLo",
54
+ default_hi: "defaultHi",
55
+ max_bound: "maxBound",
56
+ });
57
+
58
+ const PLANNING_KEY_MAP = Object.freeze({
59
+ max_depth: "maxDepth",
60
+ });
61
+
62
+ /** `defaults` with every key `keyMap` names overridden by its raw snake_case
63
+ * counterpart in `raw`, when actually present — every unset sibling keeps
64
+ * the default, so the result is always fully populated. */
65
+ function mergeSection(defaults, raw, keyMap) {
66
+ const out = { ...defaults };
67
+ if (!raw || typeof raw !== "object") return out;
68
+ for (const [tomlKey, camelKey] of Object.entries(keyMap)) {
69
+ if (raw[tomlKey] !== undefined) out[camelKey] = raw[tomlKey];
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /**
75
+ * Fold a normalized tmct.toml's `games`/`planning` tables (the raw sparse
76
+ * pass-through src/adapters/toml-config.mjs's normalizeConfig produces —
77
+ * snake_case keys, present only when actually set in the file) over
78
+ * DEFAULT_GAME_CONFIG. `toml` may be null/undefined (no tmct.toml, or one
79
+ * that failed to load) — every key then falls back to its default. Returns a
80
+ * fully populated object of the same shape as DEFAULT_GAME_CONFIG; toml value
81
+ * wins per key when present, default otherwise.
82
+ */
83
+ export function resolveGameConfig(toml) {
84
+ const games = toml?.games ?? {};
85
+ return {
86
+ spiderFly: mergeSection(DEFAULT_GAME_CONFIG.spiderFly, games["spider-fly"], SPIDER_FLY_KEY_MAP),
87
+ guessNumber: mergeSection(DEFAULT_GAME_CONFIG.guessNumber, games["guess-number"], GUESS_NUMBER_KEY_MAP),
88
+ planning: mergeSection(DEFAULT_GAME_CONFIG.planning, toml?.planning, PLANNING_KEY_MAP),
89
+ };
90
+ }
@@ -25,9 +25,12 @@ export const WEB_DURATION_TURNS = 10;
25
25
  // FLY_INITIAL_MASS/FLY_MASS_DECREMENT_PER_TURN): a spider starves like a fly
26
26
  // does, and gains exactly a fly's remaining mass on an eat. Heavier starting
27
27
  // mass than a single fly's worth on purpose — a spider that eats nothing for
28
- // a while has some runway before starving.
28
+ // a while has some runway before starving. The decrement is half a fly's own
29
+ // (spiders live longer between meals than flies do), and — like every other
30
+ // tunable here — overridable per session via tmct.toml's [games.spider-fly]
31
+ // (src/domain/game-config.mjs).
29
32
  export const SPIDER_INITIAL_MASS = 15;
30
- export const SPIDER_MASS_DECREMENT_PER_TURN = 1;
33
+ export const SPIDER_MASS_DECREMENT_PER_TURN = 0.5;
31
34
 
32
35
  export const cellId = (x, y) => `cell-${x}-${y}`;
33
36
 
@@ -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;
@@ -86,6 +100,19 @@ export function roomSceneObjects(rows, state, here) {
86
100
  return out;
87
101
  }
88
102
 
103
+ /** The clickable command suggestions for the room the player is CURRENTLY
104
+ * in — a thin, testable wrapper over adventure.mjs's own `roomAffordances`
105
+ * (the exact same data take/open/talk/examine already check), so a pill can
106
+ * never promise an action one of those verbs would then refuse. Each
107
+ * returned string ("go north", "take lamp", "unlock cabinet", ...) is
108
+ * already a complete, submittable command in this world's own imperative
109
+ * grammar — the page inserts one into the chat input verbatim on click,
110
+ * never reformats it. Pure; recomputed fresh on every redraw, so a pill for
111
+ * a room the player has left, or an object already taken, never lingers. */
112
+ export function pillsForRoom(rows, state, here) {
113
+ return roomAffordances(rows, state, here);
114
+ }
115
+
89
116
  /** A short caption for `here`, built ONLY from the rows worldDigestRows
90
117
  * itself already produces (the exact same view the chat reply's own digest
91
118
  * reads) — every row already reads as a plain sentence
@@ -132,6 +159,8 @@ ${THEME_TOKENS_CSS}
132
159
  h1 { font-size: 1.4rem; margin: .3rem 0 .9rem; text-wrap: balance; }
133
160
  button { font: inherit; color: inherit; background: none; cursor: pointer; }
134
161
  button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
162
+ .stage { display: grid; grid-template-columns: minmax(0, 1fr) 260px; gap: 1rem; align-items: start; }
163
+ @media (max-width: 760px) { .stage { grid-template-columns: 1fr; } }
135
164
  .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
165
  .sprite-row { display: flex; flex-wrap: wrap; gap: .6rem; align-items: flex-end; }
137
166
  .sprite { width: 44px; height: 44px; }
@@ -143,6 +172,23 @@ ${THEME_TOKENS_CSS}
143
172
  .sprite[data-cls="room"] { color: var(--muted); }
144
173
  .sprite-label { font-family: ${MONO_STACK}; font-size: .62rem; text-align: center; color: var(--muted); margin-top: .15rem; }
145
174
  .caption { background: var(--card); border: 1px solid var(--line); padding: .6rem .75rem; font-size: .9rem; }
175
+ .side { display: flex; flex-direction: column; gap: .8rem; min-width: 0; }
176
+ .chat { background: var(--card); border: 1px solid var(--line); padding: .6rem .75rem; }
177
+ .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; }
178
+ .chatlog { display: flex; flex-direction: column; gap: .4rem; max-height: 320px; overflow-y: auto; margin-bottom: .5rem; }
179
+ .chatlog .u { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); }
180
+ .chatlog .u::before { content: "tmct> "; color: var(--taught); }
181
+ .chatlog .a { font-size: .88rem; line-height: 1.4; white-space: pre-wrap; }
182
+ .chatlog .t { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); font-style: italic; }
183
+ .chatlog .t::before { content: "\\2022 "; }
184
+ .pills { display: flex; flex-wrap: wrap; gap: .35rem; margin-bottom: .5rem; }
185
+ .pills:empty { display: none; margin-bottom: 0; }
186
+ .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; }
187
+ .pill:hover { border-color: var(--taught); }
188
+ .chatask { display: flex; align-items: center; gap: .5rem; border-top: 1px solid var(--line); padding-top: .5rem; }
189
+ .chatask .prompt { color: var(--taught); font-size: .78rem; font-family: ${MONO_STACK}; }
190
+ .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; }
191
+ .chatask input:disabled { opacity: .5; }
146
192
  .controls-row { display: flex; align-items: center; gap: .6rem; margin-top: 1rem; flex-wrap: wrap; }
147
193
  .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
194
  .controls-row button:hover:not(:disabled) { border-color: var(--taught); }
@@ -150,8 +196,9 @@ ${THEME_TOKENS_CSS}
150
196
  .controls-row .turn { margin-left: auto; font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); font-variant-numeric: tabular-nums; }
151
197
  .goal-line { font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); margin-top: .5rem; }
152
198
  .status { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); margin-top: .3rem; }
153
- body.preview .controls-row, body.preview .status { display: none; }
199
+ body.preview .side, body.preview .controls-row, body.preview .status { display: none; }
154
200
  body.preview main { padding: 0; max-width: none; }
201
+ body.preview .stage { display: block; }
155
202
  body.preview .eyebrow, body.preview h1 { display: none; }
156
203
  </style>
157
204
  </head>
@@ -159,8 +206,21 @@ ${THEME_TOKENS_CSS}
159
206
  <main>
160
207
  <div class="eyebrow">tmct &middot; the adventure</div>
161
208
  <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>
209
+ <div class="stage">
210
+ <div class="room-frame" id="roomFrame">
211
+ <div class="sprite-row" id="spriteRow"></div>
212
+ </div>
213
+ <aside class="side" aria-label="The adventure's log and chat">
214
+ <div class="chat">
215
+ <h2>what's happened, and what you can do</h2>
216
+ <div class="chatlog" id="chatlog" aria-live="polite"></div>
217
+ <div class="pills" id="pills"></div>
218
+ <form class="chatask" id="chatform">
219
+ <span class="prompt mono">tmct&gt;</span>
220
+ <input id="chatq" type="text" placeholder="go north" aria-label="Type a command, or ask a question" disabled>
221
+ </form>
222
+ </div>
223
+ </aside>
164
224
  </div>
165
225
  <div class="caption" id="caption"></div>
166
226
  <div class="goal-line" id="goalLine"></div>
@@ -192,6 +252,10 @@ const ADVENTURE = ${pageData};
192
252
  const resetBtn = el("resetBtn");
193
253
  const playBtn = el("playBtn");
194
254
  const stepBtn = el("stepBtn");
255
+ const chatlogEl = el("chatlog");
256
+ const pillsEl = el("pills");
257
+ const chatformEl = el("chatform");
258
+ const chatqEl = el("chatq");
195
259
 
196
260
  const params = new URLSearchParams(location.search);
197
261
  const preview = params.get("preview") === "1";
@@ -208,6 +272,37 @@ const ADVENTURE = ${pageData};
208
272
  return lines.length ? lines.join(" ") : "Nothing more about the " + here + " is written down yet.";
209
273
  }
210
274
 
275
+ // ---- chat/event log — every manual exchange AND every auto-play tick's
276
+ // own narration append here, in order, so it reads as one continuous
277
+ // history rather than a line overwritten every tick.
278
+ function addChatLine(cls, html) {
279
+ const d = document.createElement("div");
280
+ d.className = cls; d.innerHTML = html;
281
+ chatlogEl.appendChild(d); chatlogEl.scrollTop = chatlogEl.scrollHeight;
282
+ }
283
+
284
+ // ---- contextual pills — refreshed every redraw from the CURRENT room's
285
+ // own roomAffordances-derived list (mirroring pillsForRoom against
286
+ // tmctAdventure.roomAffordances, the same way captionFor above mirrors
287
+ // roomCaptionText against tmctAdventure.worldDigestRows), so a pill for a
288
+ // room the player has left, or an object already taken, never lingers.
289
+ // Clicking one inserts its exact command text into the input and focuses
290
+ // it; it never auto-submits, so free typing still works and a clicked
291
+ // suggestion can still be edited first.
292
+ function pillsFor(rows, state, here) {
293
+ return tmctAdventure.roomAffordances(rows, state, here);
294
+ }
295
+ function renderPills(rows, state, here) {
296
+ const actions = pillsFor(rows, state, here);
297
+ pillsEl.innerHTML = actions.map((a) => '<button type="button" class="pill">' + esc(a) + "</button>").join("");
298
+ }
299
+ pillsEl.addEventListener("click", (e) => {
300
+ const btn = e.target.closest(".pill");
301
+ if (!btn || !chatqEl) return;
302
+ chatqEl.value = btn.textContent;
303
+ chatqEl.focus();
304
+ });
305
+
211
306
  function redraw(snap) {
212
307
  const objects = roomSceneObjects(snap.rows, snap.state, snap.here);
213
308
  const sprites = [{ subject: "you", spriteClass: "adventurer" }, ...objects];
@@ -218,34 +313,66 @@ const ADVENTURE = ${pageData};
218
313
  }).join("");
219
314
  captionEl.textContent = captionFor(snap.rows, snap.state, snap.here);
220
315
  turnLabelEl.textContent = "turn: " + snap.turn;
316
+ renderPills(snap.rows, snap.state, snap.here);
317
+ }
318
+
319
+ // ---- serialize every engine-touching call: the ticker and the chat dock
320
+ // share one in-memory store, and an overlapping tick()/turn() pair could
321
+ // race against the same @turnN write.
322
+ let lock = Promise.resolve();
323
+ function withLock(fn) {
324
+ const run = lock.then(fn, fn);
325
+ lock = run.catch(() => {});
326
+ return run;
221
327
  }
222
328
 
329
+ chatformEl.addEventListener("submit", (e) => {
330
+ e.preventDefault();
331
+ const q = chatqEl.value.trim();
332
+ if (!q || !session) return;
333
+ chatqEl.value = "";
334
+ // A manual command lands mid-tick otherwise — pause first, and stay
335
+ // paused until the visitor presses play again.
336
+ ticker.pause();
337
+ addChatLine("u", esc(q));
338
+ withLock(async () => {
339
+ const result = await session.turn(q);
340
+ addChatLine("a", esc(result.answer).replace(/\\n/g, "<br>"));
341
+ const snap = await session.snapshot();
342
+ redraw(snap);
343
+ });
344
+ });
345
+
223
346
  async function boot() {
224
347
  session = await tmctAdventure.createAdventureSession(ADVENTURE.world);
225
348
  lastTicks = 0;
226
349
  const snap = await session.snapshot();
227
350
  redraw(snap);
228
351
  goalLineEl.textContent = "";
352
+ chatlogEl.innerHTML = "";
229
353
  statusEl.textContent = ADVENTURE.world.opening || "";
354
+ addChatLine("t", esc(ADVENTURE.world.opening || "the adventure begins."));
355
+ chatqEl.disabled = false;
230
356
  resetBtn.disabled = false; playBtn.disabled = false; stepBtn.disabled = false;
231
357
  }
232
358
 
233
359
  const ticker = createTicker({
234
- onTick: async () => {
360
+ onTick: () => withLock(async () => {
235
361
  const result = await session.autoplayTick();
236
362
  lastTicks += 1;
237
363
  const snap = await session.snapshot();
238
364
  redraw(snap);
239
365
  goalLineEl.textContent = result.goal || "";
366
+ addChatLine("t", esc(result.goal || ""));
240
367
  if (result.done || result.stalled) ticker.pause();
241
- },
368
+ }),
242
369
  onRender: (state) => {
243
370
  playBtn.textContent = state.playing ? "\\u23f8 pause" : "\\u25b6 play";
244
371
  playBtn.disabled = state.animating;
245
372
  stepBtn.disabled = state.animating || state.playing;
246
373
  resetBtn.disabled = state.animating;
247
374
  },
248
- onReset: () => boot(),
375
+ onReset: () => withLock(boot),
249
376
  hasNext: () => !preview || lastTicks < ADVENTURE.previewMaxTicks,
250
377
  waitMs: ADVENTURE.tickWaitMs,
251
378
  });
@@ -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);