@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.
- package/README.md +23 -0
- package/data/sprites/adventurer.toml +5 -0
- package/data/sprites/animal.toml +6 -0
- package/data/sprites/butler.toml +8 -0
- package/data/sprites/cabinet.toml +5 -0
- package/data/sprites/container.toml +5 -0
- package/data/sprites/cook.toml +7 -0
- package/data/sprites/desk.toml +4 -0
- package/data/sprites/dog-with-colour.toml +20 -0
- package/data/sprites/dog.toml +5 -0
- package/data/sprites/egg.toml +4 -0
- package/data/sprites/fly.toml +5 -0
- package/data/sprites/furniture.toml +5 -0
- package/data/sprites/gardener.toml +4 -0
- package/data/sprites/housekeeper.toml +6 -0
- package/data/sprites/key.toml +5 -0
- package/data/sprites/lamp.toml +4 -0
- package/data/sprites/letter.toml +5 -0
- package/data/sprites/person.toml +4 -0
- package/data/sprites/poodle.toml +5 -0
- package/data/sprites/portable.toml +6 -0
- package/data/sprites/portrait.toml +5 -0
- package/data/sprites/room.toml +5 -0
- package/data/sprites/spider.toml +5 -0
- package/package.json +1 -1
- package/src/adapters/corpus/sprite-template-files.mjs +52 -0
- package/src/adapters/toml-config.mjs +8 -0
- package/src/domain/game-config.mjs +90 -0
- package/src/domain/spider-fly-world.mjs +5 -2
- package/src/domain/sprite-map.mjs +25 -10
- package/src/domain/sprite-templates.mjs +131 -0
- package/src/services/adventure-viz.mjs +201 -18
- package/src/services/adventure.mjs +45 -3
- package/src/services/chat-session.mjs +9 -1
- package/src/services/chat.mjs +49 -27
- package/src/services/spider-fly-turn.mjs +11 -10
- package/src/services/spider-fly-viz.mjs +73 -8
- package/src/services/spider-fly.mjs +56 -36
- package/src/surfaces/web/adventure-browser-entry.mjs +57 -19
- package/src/surfaces/web/memory-ask-browser.bundle.js +42 -0
- 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).
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
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.
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
|
|
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 · the adventure</div>
|
|
161
245
|
<h1>A room, drawn from exactly what the text already says is there</h1>
|
|
162
|
-
<div class="
|
|
163
|
-
<div class="
|
|
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></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 =
|
|
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
|
-
|
|
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
|
-
|
|
697
|
-
|
|
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);
|
package/src/services/chat.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
12455
|
-
* { problem } naming why the stated range is
|
|
12456
|
-
|
|
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:
|
|
12459
|
-
const tokens = (m[3] !== undefined ? [
|
|
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:
|
|
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) >
|
|
12467
|
-
return { problem: `that range is too big for a fair game — keep both bounds within ${
|
|
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
|
-
|
|
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}`);
|