@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 +23 -0
- package/package.json +1 -1
- 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/services/adventure-viz.mjs +140 -13
- 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 +53 -0
- package/src/services/spider-fly.mjs +68 -35
- package/src/surfaces/web/adventure-browser-entry.mjs +56 -19
- package/src/surfaces/web/memory-ask-browser.bundle.js +42 -0
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.
|
|
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 =
|
|
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).
|
|
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;
|
|
@@ -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 · the adventure</div>
|
|
161
208
|
<h1>A room, drawn from exactly what the text already says is there</h1>
|
|
162
|
-
<div class="
|
|
163
|
-
<div class="
|
|
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></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
|
-
|
|
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);
|