@polycode-projects/the-mechanical-code-talker 2.7.11 → 2.7.13

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.
@@ -0,0 +1,262 @@
1
+ // adventure-viz.mjs — the adventure's own full-screen/home-page hero
2
+ // (PLAN_GAMES_UPLIFT_V2.md Part B), styled directly after
3
+ // spider-fly-viz.mjs's own self-contained page-builder: one inlined <style>
4
+ // importing viz-theme.mjs's shared tokens, behaviour as an inlined IIFE, the
5
+ // shared `createTicker` primitive spliced in via `.toString()` exactly the
6
+ // way that page splices its own render-glue helpers.
7
+ //
8
+ // Where this page's data-loading DIVERGES from spider-fly's, on purpose:
9
+ // spider-fly-world.mjs is itself a plain, dependency-free JS module (no I/O),
10
+ // so its browser entry can call it directly to bootstrap a board. Ashcombe
11
+ // Hall's canonical definition is a JSONL corpus source
12
+ // (corpus/worlds/src/ashcombe-hall.jsonl), read through a Node fs/gzip
13
+ // provider the browser cannot run. Rather than hand-duplicating the world as
14
+ // a second, hardcoded JS copy (exactly the kind of drift this project's
15
+ // worlds-pack build step exists to prevent), the real facts+rules are read
16
+ // ONCE at build time (scripts/build-demo-site.mjs, the same Node path
17
+ // test/services/adventure.test.mjs's own loadShippedWorldInto uses) and
18
+ // embedded into this page as plain JSON — the same posture ledger.html
19
+ // already takes with its own precomputed memory payload, just applied to a
20
+ // second kind of build-time data.
21
+ //
22
+ // Two pure, `.toString()`-splice-safe pieces are exported as real functions
23
+ // (not raw inline-script text) so they can be pinned directly by tests, the
24
+ // same discipline spider-fly-viz.mjs holds classOfAgentId/
25
+ // threadCellsForSpiderPlan to: `spriteClassForObject` (an object's sprite
26
+ // class, from its own rdf:type or mgx:is-container fact — NOT from
27
+ // adventure.mjs's private isContainer/isTyped, which this module cannot
28
+ // import without duplicating adventure.mjs's own closed vocabulary reading)
29
+ // and `roomSceneObjects` (every subject actually visible in a room, mirrored
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).
37
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
38
+ import { createTicker } from "./viz-ticker.mjs";
39
+ import { worldDigestRows } from "./adventure.mjs";
40
+
41
+ const DEFAULT_TITLE = "tmct — the adventure";
42
+ const PREVIEW_MAX_TICKS = 30;
43
+ const TICK_WAIT_MS = 900;
44
+
45
+ /** An object's sprite class: `container` when it carries mgx:is-container
46
+ * (a distinct icon from plain furniture, since Ashcombe's own cabinet and
47
+ * portrait are typed "furniture" but read more clearly as a container on
48
+ * screen), else its own rdf:type object, else the generic "portable"
49
+ * fallback for anything a world places with no type fact at all. Pure,
50
+ * self-contained — no reference to adventure.mjs's own private isContainer/
51
+ * isTyped, since those aren't exported. */
52
+ export function spriteClassForObject(rows, subject) {
53
+ const isContainer = (rows || []).some(
54
+ (r) => r.subject === subject && r.predicate === "mgx:is-container" && r.object === "true",
55
+ );
56
+ if (isContainer) return "container";
57
+ const typeRow = (rows || []).find((r) => r.subject === subject && r.predicate === "rdf:type");
58
+ return typeRow ? typeRow.object : "portable";
59
+ }
60
+
61
+ /** Every subject actually visible in `here`, sorted, each with its sprite
62
+ * class — mirroring adventure.mjs's own private `visibleRoomOf` walk (one
63
+ * containment hop through an OPEN container) so this can never draw a
64
+ * hidden or carried object the text digest wouldn't also mention. `player`
65
+ * is excluded; the caller draws the player's own adventurer sprite
66
+ * separately. Pure. */
67
+ export function roomSceneObjects(rows, state, here) {
68
+ const isTypedRoom = (subject) =>
69
+ (rows || []).some((r) => r.subject === subject && r.predicate === "rdf:type" && r.object === "room");
70
+ const visibleRoomOf = (subject) => {
71
+ const place = state.placements.get(subject);
72
+ if (!place || place.predicate === "mgx:hidden-in") return null;
73
+ if (place.predicate === "mgx:currently-in" || isTypedRoom(place.object)) return place.object;
74
+ const holder = place.object;
75
+ if (holder === "player") return null;
76
+ if (!state.openness.get(holder)?.open) return null;
77
+ const holderPlace = state.placements.get(holder);
78
+ return holderPlace && holderPlace.predicate !== "mgx:hidden-in" ? holderPlace.object : null;
79
+ };
80
+ const out = [];
81
+ for (const subject of [...state.placements.keys()].sort()) {
82
+ if (subject === "player") continue;
83
+ if (visibleRoomOf(subject) !== here) continue;
84
+ out.push({ subject, spriteClass: spriteClassForObject(rows, subject) });
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /** A short caption for `here`, built ONLY from the rows worldDigestRows
90
+ * itself already produces (the exact same view the chat reply's own digest
91
+ * reads) — every row already reads as a plain sentence
92
+ * (`${subject} ${predicate} ${object}.`), so this never invents a phrase
93
+ * the text digest doesn't already carry. Filters to rows about the room
94
+ * itself (its own exits) or about something placed IN it — the same
95
+ * "visible here" boundary `roomSceneObjects` draws from. The player's own
96
+ * "is in the" row is excluded: the room frame already IS the current room,
97
+ * so restating "you are here" is redundant, never informative. */
98
+ export function roomCaptionText(rows, state, here) {
99
+ const hereCased = here.charAt(0).toUpperCase() + here.slice(1);
100
+ const lines = worldDigestRows(rows, state)
101
+ .filter((row) => row.subject !== "Player" && (row.object === here || row.subject === hereCased))
102
+ .map((row) => `${row.subject} ${row.predicate} ${row.object}.`);
103
+ return lines.length ? lines.join(" ") : `Nothing more about the ${here} is written down yet.`;
104
+ }
105
+
106
+ /** The self-contained adventure page. Pure given `worldPayload` (the build
107
+ * step's own read of the real Ashcombe Hall world — `{ facts, rules,
108
+ * opening }`), the same "byte-identical for identical input" invariant
109
+ * every other viz page in this project holds. `?preview=1` switches into
110
+ * the small, auto-playing, non-interactive mode the home page's hero iframe
111
+ * embeds, matching spider-fly.html's own dual-purpose file. */
112
+ export function renderAdventureHtml({ title = DEFAULT_TITLE, worldPayload = { facts: [], rules: [], opening: "" } } = {}) {
113
+ const pageData = embedJson({
114
+ world: worldPayload,
115
+ previewMaxTicks: PREVIEW_MAX_TICKS,
116
+ tickWaitMs: TICK_WAIT_MS,
117
+ });
118
+
119
+ return `<!doctype html>
120
+ <html lang="en">
121
+ <head>
122
+ <meta charset="utf-8">
123
+ <meta name="viewport" content="width=device-width, initial-scale=1">
124
+ <title>${escapeHtml(title)}</title>
125
+ <style>
126
+ ${THEME_TOKENS_CSS}
127
+ html { background: var(--bg); }
128
+ body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; }
129
+ .mono { font-family: ${MONO_STACK}; }
130
+ main { max-width: 860px; margin: 0 auto; padding: 1.4rem 1.2rem 2.2rem; }
131
+ .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
132
+ h1 { font-size: 1.4rem; margin: .3rem 0 .9rem; text-wrap: balance; }
133
+ button { font: inherit; color: inherit; background: none; cursor: pointer; }
134
+ button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
135
+ .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
+ .sprite-row { display: flex; flex-wrap: wrap; gap: .6rem; align-items: flex-end; }
137
+ .sprite { width: 44px; height: 44px; }
138
+ .sprite svg { width: 100%; height: 100%; display: block; }
139
+ .sprite[data-cls="adventurer"] { color: var(--taught); }
140
+ .sprite[data-cls="person"] { color: var(--corpus); }
141
+ .sprite[data-cls="container"], .sprite[data-cls="furniture"] { color: var(--entail); }
142
+ .sprite[data-cls="portable"] { color: var(--alert); }
143
+ .sprite[data-cls="room"] { color: var(--muted); }
144
+ .sprite-label { font-family: ${MONO_STACK}; font-size: .62rem; text-align: center; color: var(--muted); margin-top: .15rem; }
145
+ .caption { background: var(--card); border: 1px solid var(--line); padding: .6rem .75rem; font-size: .9rem; }
146
+ .controls-row { display: flex; align-items: center; gap: .6rem; margin-top: 1rem; flex-wrap: wrap; }
147
+ .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
+ .controls-row button:hover:not(:disabled) { border-color: var(--taught); }
149
+ .controls-row button:disabled { opacity: .4; cursor: default; }
150
+ .controls-row .turn { margin-left: auto; font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); font-variant-numeric: tabular-nums; }
151
+ .goal-line { font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); margin-top: .5rem; }
152
+ .status { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); margin-top: .3rem; }
153
+ body.preview .controls-row, body.preview .status { display: none; }
154
+ body.preview main { padding: 0; max-width: none; }
155
+ body.preview .eyebrow, body.preview h1 { display: none; }
156
+ </style>
157
+ </head>
158
+ <body>
159
+ <main>
160
+ <div class="eyebrow">tmct &middot; the adventure</div>
161
+ <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>
164
+ </div>
165
+ <div class="caption" id="caption"></div>
166
+ <div class="goal-line" id="goalLine"></div>
167
+ <div class="controls-row">
168
+ <button id="resetBtn" type="button" disabled>reset</button>
169
+ <button id="playBtn" type="button" disabled>&#9654; play</button>
170
+ <button id="stepBtn" type="button" disabled>step</button>
171
+ <span class="turn mono" id="turnLabel">turn: 0</span>
172
+ </div>
173
+ <div class="status" id="status">loading the engine&hellip;</div>
174
+ </main>
175
+ <script>
176
+ const ADVENTURE = ${pageData};
177
+ </script>
178
+ <script src="./adventure-browser.bundle.js"></script>
179
+ <script>
180
+ (function () {
181
+ "use strict";
182
+ const createTicker = ${createTicker.toString()};
183
+ const spriteClassForObject = ${spriteClassForObject.toString()};
184
+ const roomSceneObjects = ${roomSceneObjects.toString()};
185
+ const esc = ${escapeHtml.toString()};
186
+ const el = (id) => document.getElementById(id);
187
+ const spriteRow = el("spriteRow");
188
+ const captionEl = el("caption");
189
+ const goalLineEl = el("goalLine");
190
+ const statusEl = el("status");
191
+ const turnLabelEl = el("turnLabel");
192
+ const resetBtn = el("resetBtn");
193
+ const playBtn = el("playBtn");
194
+ const stepBtn = el("stepBtn");
195
+
196
+ const params = new URLSearchParams(location.search);
197
+ const preview = params.get("preview") === "1";
198
+ document.body.classList.toggle("preview", preview);
199
+
200
+ let session = null;
201
+ let lastTicks = 0;
202
+
203
+ function captionFor(rows, state, here) {
204
+ const hereCased = here.charAt(0).toUpperCase() + here.slice(1);
205
+ const lines = tmctAdventure.worldDigestRows(rows, state)
206
+ .filter((row) => row.subject !== "Player" && (row.object === here || row.subject === hereCased))
207
+ .map((row) => row.subject + " " + row.predicate + " " + row.object + ".");
208
+ return lines.length ? lines.join(" ") : "Nothing more about the " + here + " is written down yet.";
209
+ }
210
+
211
+ function redraw(snap) {
212
+ const objects = roomSceneObjects(snap.rows, snap.state, snap.here);
213
+ const sprites = [{ subject: "you", spriteClass: "adventurer" }, ...objects];
214
+ spriteRow.innerHTML = sprites.map((s) => {
215
+ const svg = tmctAdventure.resolveSpriteForClass(s.spriteClass, [], tmctAdventure.SPRITE_REGISTRY);
216
+ return '<div><div class="sprite" data-cls="' + esc(s.spriteClass) + '">' + svg + '</div>'
217
+ + '<div class="sprite-label">' + esc(s.subject) + "</div></div>";
218
+ }).join("");
219
+ captionEl.textContent = captionFor(snap.rows, snap.state, snap.here);
220
+ turnLabelEl.textContent = "turn: " + snap.turn;
221
+ }
222
+
223
+ async function boot() {
224
+ session = await tmctAdventure.createAdventureSession(ADVENTURE.world);
225
+ lastTicks = 0;
226
+ const snap = await session.snapshot();
227
+ redraw(snap);
228
+ goalLineEl.textContent = "";
229
+ statusEl.textContent = ADVENTURE.world.opening || "";
230
+ resetBtn.disabled = false; playBtn.disabled = false; stepBtn.disabled = false;
231
+ }
232
+
233
+ const ticker = createTicker({
234
+ onTick: async () => {
235
+ const result = await session.autoplayTick();
236
+ lastTicks += 1;
237
+ const snap = await session.snapshot();
238
+ redraw(snap);
239
+ goalLineEl.textContent = result.goal || "";
240
+ if (result.done || result.stalled) ticker.pause();
241
+ },
242
+ onRender: (state) => {
243
+ playBtn.textContent = state.playing ? "\\u23f8 pause" : "\\u25b6 play";
244
+ playBtn.disabled = state.animating;
245
+ stepBtn.disabled = state.animating || state.playing;
246
+ resetBtn.disabled = state.animating;
247
+ },
248
+ onReset: () => boot(),
249
+ hasNext: () => !preview || lastTicks < ADVENTURE.previewMaxTicks,
250
+ waitMs: ADVENTURE.tickWaitMs,
251
+ });
252
+ playBtn.addEventListener("click", () => ticker.play());
253
+ stepBtn.addEventListener("click", () => ticker.stepOnce());
254
+ resetBtn.addEventListener("click", () => ticker.reset());
255
+
256
+ boot().then(() => { if (preview) ticker.play(); });
257
+ })();
258
+ </script>
259
+ </body>
260
+ </html>
261
+ `;
262
+ }
@@ -12,6 +12,7 @@ import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
12
12
  import { parseImperative } from "../domain/grammar/ace.mjs";
13
13
  import { createCompletionsGraphAdapter } from "../domain/completions/graph-adapter.mjs";
14
14
  import { actionFamilies } from "../domain/router/taught.mjs";
15
+ import { compileDomain, precondHolds, roleBinding } from "../domain/domain.mjs";
15
16
  import { getWorldsPackProvider } from "../adapters/corpus/worlds-pack.mjs";
16
17
  import { appendFacts, appendRule, loadMemory, normFactTerm, readFactRows, readRuleRows } from "../adapters/memory/core.mjs";
17
18
  import { COMPLETIONS_STORE, generateCompletion } from "./completions.mjs";
@@ -78,7 +79,12 @@ async function openAdventure(opening, { planHolder, memoryDir, sessionId, env, c
78
79
  world = names[0];
79
80
  } else if (!names.includes(world)) {
80
81
  // An unknown "play X" is not necessarily an adventure ask at all ("play
81
- // chess") fall through to the ordinary lanes rather than claim it.
82
+ // spider" is spider-fly's own opener, not a broken adventure request)
83
+ // fall through so a sibling game's opener keeps first refusal.
84
+ // unclaimedAdventureOpening below is chat's LAST-RESORT check: once
85
+ // every "play X" lane (this one included) has had its turn and none
86
+ // claimed the line, THAT'S when an unrecognized name gets named and
87
+ // declined honestly, never silently.
82
88
  return null;
83
89
  }
84
90
 
@@ -125,6 +131,31 @@ async function openAdventure(opening, { planHolder, memoryDir, sessionId, env, c
125
131
  };
126
132
  }
127
133
 
134
+ /** chat's LAST-RESORT check for a named opener no lane claimed — "play
135
+ * atlantis" when the pack has worlds but none is called that. Called AFTER
136
+ * every other "play X"-shaped lane (spider-fly's own opener among them) has
137
+ * already had its chance, so this never steals a name a sibling game
138
+ * recognizes as its own (openAdventure's own fallthrough above stays
139
+ * silent for exactly that reason). Only once nothing else wanted the line
140
+ * does it get named and declined, rather than answered with an unrelated
141
+ * generic non-answer. Null when the line isn't a named opener at all, or
142
+ * the pack has no worlds (openAdventure's own first pass already gave that
143
+ * case its honest missingPackAnswer, before any lane got a turn), or the
144
+ * name IS one of the pack's — never re-decides a real hit. */
145
+ export async function unclaimedAdventureOpening(line, { env }) {
146
+ const opening = matchAdventureOpening(line);
147
+ if (!opening?.world) return null;
148
+ const provider = getWorldsPackProvider(env);
149
+ let names = null;
150
+ try { names = await provider.list(); } catch { names = null; }
151
+ if (!names || !names.length || names.includes(opening.world)) return null;
152
+ return {
153
+ text: `I don't know a world called "${spokenNameOf(opening.world)}" — the pack has: ${names.map(spokenNameOf).join(", ")}.`,
154
+ lane: "game-inform",
155
+ note: `ADVENTURE — last-resort opening decline: "${opening.world}" names no world in the pack (has: ${names.join(", ")}), and no other lane claimed the line either`,
156
+ };
157
+ }
158
+
128
159
  /** A resumed game's current room: non-null only when earlier @turnN
129
160
  * snapshots exist (the world was already played in this store), folded the
130
161
  * same way every other reader folds them. A fresh world returns null and
@@ -264,8 +295,10 @@ const affordanceSuffix = (actions) => (actions.length ? ` You can: ${actions.joi
264
295
 
265
296
  /** The effect predicate a family writes (its action-effect row's slot, with
266
297
  * the mgx: prefix rule readers re-attach). Null when the family carries no
267
- * effect row — the open/unlock/close families, whose datatype state writes
268
- * have no shipped effect shape yet and ride the container logic below. */
298
+ * effect row — unlock's family stays signature-only (its instrument match
299
+ * needs a third, externally-supplied binding no shipped rule shape covers)
300
+ * and rides the hand-written logic below; go/take/drop/give/open/close all
301
+ * carry real effect rows and never hit this null. */
269
302
  function familyEffectPredicate(family) {
270
303
  const effect = (family || []).find((r) => r.kind === "action-effect");
271
304
  if (!effect?.slots?.predicate) return null;
@@ -449,6 +482,24 @@ function containerStatusPhrase(object, { state }) {
449
482
  : `the ${object} is open. It's empty.`;
450
483
  }
451
484
 
485
+ /** `object`'s own datatype facts (its placement predicate, its open/closed
486
+ * flag), as the tiny {subject,predicate,object} row set a "fact-value"
487
+ * precond needs — read from the already-folded CURRENT truth (state.
488
+ * placements/state.openness), never raw @turnN rows, so a superseded
489
+ * snapshot can never look current. domain.mjs's own stateFromFacts can't
490
+ * serve this: it keys "current" off an @stepN suffix (this world writes
491
+ * @turnN) and restricts rows to individuals typed into some action's
492
+ * SUBJECT class, which "furniture" (the container's own class) never is —
493
+ * a container's own facts about itself would silently vanish through it. */
494
+ function containerDatatypeState(state, object) {
495
+ const rows = [];
496
+ const place = state.placements.get(object);
497
+ if (place) rows.push({ subject: object, predicate: place.predicate, object: place.object });
498
+ const openness = state.openness.get(object);
499
+ if (openness) rows.push({ subject: object, predicate: OPEN_PREDICATE, object: openness.open ? "true" : "false" });
500
+ return rows;
501
+ }
502
+
452
503
  async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
453
504
  const memory = await loadMemory(memoryDir);
454
505
  const rows = readFactRows(memory);
@@ -512,7 +563,8 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
512
563
  );
513
564
  }
514
565
 
515
- const families = actionFamilies(readRuleRows(memory));
566
+ const ruleRows = readRuleRows(memory);
567
+ const families = actionFamilies(ruleRows);
516
568
  const family = families.get(cmd.verb);
517
569
  if (!family) {
518
570
  return answer(
@@ -619,9 +671,15 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
619
671
  );
620
672
  }
621
673
 
622
- // open / unlock / close — the container verbs. Their families are
623
- // signature-only (no shipped rule shape for a datatype effect yet), so the
624
- // state writes are the closed container vocabulary below.
674
+ // open / unlock / close — the container verbs. presence and container-ness
675
+ // stay hand-checked here (visibility gating, not a state precondition);
676
+ // unlock's instrument match stays fully hand-written below it too — it
677
+ // needs a third, externally-supplied binding beyond subject/target, which
678
+ // this retrofit does not attempt. open/close's lock-state and open/closed
679
+ // checks, and their mgx:is-open write, are now taught "fact-value"
680
+ // precond/effect rows consulted through domain.mjs below; only the
681
+ // hidden-contents reveal (a variable-arity effect over a discovered set)
682
+ // stays hand-written JS, since no shipped rule shape covers that either.
625
683
  const presentHere = place && place.predicate !== "mgx:hidden-in" && place.predicate !== "mgx:currently-in" && place.object === here;
626
684
  if (!presentHere) {
627
685
  return answer(`I don't see a ${object} here.`, noteFor(`${cmd.verb} — ${object} isn't in the ${here}; declined`), { miss: true });
@@ -629,40 +687,50 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
629
687
  if (!isContainer(rows, object)) {
630
688
  return answer(`the ${object} doesn't open.`, noteFor(`${cmd.verb} — no mgx:is-container fact on ${object}; declined by name`), { miss: true });
631
689
  }
632
- const open = !!state.openness.get(object)?.open;
633
690
 
634
- if (cmd.verb === "open") {
635
- if (place.predicate === "mgx:stands-locked-in") {
636
- return answer(`the ${object} is locked.`, noteFor(`open ${object} stands locked; precondition declined by name`), { miss: true });
691
+ if (cmd.verb === "open" || cmd.verb === "close") {
692
+ const domain = compileDomain(rows, ruleRows);
693
+ const taughtAction = domain.actions.find((a) => a.name === cmd.verb);
694
+ const effect = taughtAction?.effects.find((e) => e.predicate === OPEN_PREDICATE);
695
+ if (!effect) {
696
+ return answer(
697
+ `this world doesn't teach how ${cmd.verb === "open" ? "opening" : "closing"} changes the ${object}.`,
698
+ noteFor(`${cmd.verb} — the taught "${cmd.verb}" family carries no ${OPEN_PREDICATE} effect; honest decline`),
699
+ { miss: true },
700
+ );
637
701
  }
638
- if (open) {
639
- return answer(`the ${object} is already open.`, noteFor("open — already open; declined"), { miss: true });
702
+ const factState = containerDatatypeState(state, object);
703
+ const failed = taughtAction.preconds.find((p) => !precondHolds(p, "player", object, factState, domain));
704
+ if (failed) {
705
+ const text = failed.predicate === "mgx:stands-locked-in"
706
+ ? `the ${object} is locked.`
707
+ : cmd.verb === "open" ? `the ${object} is already open.` : `the ${object} isn't open.`;
708
+ return answer(text, noteFor(`${cmd.verb} — the taught "${cmd.verb}" family's ${failed.predicate} precondition declined by name`), { miss: true });
640
709
  }
641
- const revealed = [...state.placements]
642
- .filter(([, p]) => p.predicate === "mgx:hidden-in" && p.object === object)
643
- .map(([thing]) => thing)
644
- .sort();
645
- return commit(
646
- [
647
- { subject: `${object}@turn${k}`, predicate: "mgx:is-open", object: "true" },
648
- ...revealed.map((thing) => ({ subject: `${thing}@turn${k}`, predicate: "mgx:located-in", object })),
649
- ],
650
- revealed.length
651
- ? `you open the ${object} — inside: the ${revealed.join(", the ")}.`
652
- : `you open the ${object}. It's empty.`,
653
- `open — ${object} opens${revealed.length ? `, revealing ${revealed.join(", ")}` : ""}`,
654
- `open the ${object}`,
655
- );
656
- }
657
-
658
- if (cmd.verb === "close") {
659
- if (!open) {
660
- return answer(`the ${object} isn't open.`, noteFor("close — not open; declined"), { miss: true });
710
+ const effSubject = roleBinding(effect.subjectRole, "player", object, domain);
711
+ const writeIsOpen = { subject: `${effSubject}@turn${k}`, predicate: effect.predicate, object: effect.value };
712
+
713
+ if (cmd.verb === "open") {
714
+ const revealed = [...state.placements]
715
+ .filter(([, p]) => p.predicate === "mgx:hidden-in" && p.object === object)
716
+ .map(([thing]) => thing)
717
+ .sort();
718
+ return commit(
719
+ [
720
+ writeIsOpen,
721
+ ...revealed.map((thing) => ({ subject: `${thing}@turn${k}`, predicate: "mgx:located-in", object })),
722
+ ],
723
+ revealed.length
724
+ ? `you open the ${object} — inside: the ${revealed.join(", the ")}.`
725
+ : `you open the ${object}. It's empty.`,
726
+ `open — ${object} opens${revealed.length ? `, revealing ${revealed.join(", ")}` : ""} via the taught "open" family's effect`,
727
+ `open the ${object}`,
728
+ );
661
729
  }
662
730
  return commit(
663
- [{ subject: `${object}@turn${k}`, predicate: "mgx:is-open", object: "false" }],
731
+ [writeIsOpen],
664
732
  `you close the ${object}.`,
665
- `close — ${object} closes`,
733
+ `close — ${object} closes via the taught "close" family's effect`,
666
734
  `close the ${object}`,
667
735
  );
668
736
  }
@@ -52,7 +52,7 @@ import { CHILD_PACK_NAME, childProvenanceTag } from "../domain/child-pack.mjs";
52
52
  import { getChildPackProvider } from "../adapters/corpus/child-pack.mjs";
53
53
  import { dialogueActForLane } from "../domain/dialogue-acts.mjs";
54
54
  import { relatedForTerm } from "../domain/skos-view.mjs";
55
- import { adventureTurn } from "./adventure.mjs";
55
+ import { adventureTurn, unclaimedAdventureOpening } from "./adventure.mjs";
56
56
  import { spiderFlyTurn } from "./spider-fly-turn.mjs";
57
57
 
58
58
  // Composition: the chat surface supplies the domain parser's default lemma/POS
@@ -12646,6 +12646,24 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
12646
12646
  }
12647
12647
  }
12648
12648
 
12649
+ // A "play X" naming no world EITHER game lane above claimed — last resort,
12650
+ // checked only once the adventure lane's own fallthrough and spider-fly's
12651
+ // own opener have both passed on the line, so this never outguesses a
12652
+ // sibling game's own recognized phrasing. Honest by name ("I don't know a
12653
+ // world called…") rather than the generic non-answer an unclaimed opener
12654
+ // fell into before.
12655
+ {
12656
+ const unclaimed = await unclaimedAdventureOpening(workingLine, { env });
12657
+ if (unclaimed) {
12658
+ note(trace, `lane: ${unclaimed.note}`);
12659
+ const result = plainTurn(workingLine, unclaimed.text, { via: "game", miss: true, focus });
12660
+ result.lane = unclaimed.lane;
12661
+ const rec = withLast(result, "play the adventure");
12662
+ rec.planState = planHolder.state;
12663
+ return rec;
12664
+ }
12665
+ }
12666
+
12649
12667
  // Conversational layer next (greetings, thanks, help, bye, why/say-more) — these
12650
12668
  // resolve no entity and carry their own preserved `last`. Bypasses withLast (a
12651
12669
  // conversational turn is never finish()'d / never becomes a new `last`), so the
@@ -33,6 +33,7 @@
33
33
  import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
34
34
  import { createTicker } from "./viz-ticker.mjs";
35
35
  import { GRID_SIZE, WEB_HOME, WEB_RADIUS, isInWebBlock, cellId } from "../domain/spider-fly-world.mjs";
36
+ import { FLY_INITIAL_MASS, SPIDER_INITIAL_MASS } from "./spider-fly.mjs";
36
37
 
37
38
  const CELL_PX = 44;
38
39
  const BOARD_PX = CELL_PX * GRID_SIZE;
@@ -111,6 +112,8 @@ export function renderSpiderFlyHtml({ title = DEFAULT_TITLE } = {}) {
111
112
  cellPx: CELL_PX,
112
113
  previewMaxTurns: PREVIEW_MAX_TURNS,
113
114
  tickWaitMs: TICK_WAIT_MS,
115
+ maxFlyMass: FLY_INITIAL_MASS,
116
+ maxSpiderMass: SPIDER_INITIAL_MASS,
114
117
  });
115
118
 
116
119
  return `<!doctype html>
@@ -154,6 +157,9 @@ ${THEME_TOKENS_CSS}
154
157
  .hud-id { font-family: ${MONO_STACK}; font-size: .74rem; }
155
158
  .hud-id.spider { color: var(--taught); } .hud-id.fly { color: var(--fly); } .hud-id.egg { color: var(--muted); }
156
159
  .hud-goal { font-size: .85rem; }
160
+ .mass-track { height: 4px; margin-top: .3rem; background: var(--line); border-radius: 2px; overflow: hidden; }
161
+ .mass-fill { height: 100%; background: var(--taught); }
162
+ .mass-fill.fly { background: var(--fly); }
157
163
  .hud-empty { color: var(--muted); font-size: .85rem; }
158
164
  .chatlog { display: flex; flex-direction: column; gap: .4rem; max-height: 220px; overflow-y: auto; margin-bottom: .5rem; }
159
165
  .chatlog:empty { display: none; margin-bottom: 0; }
@@ -264,6 +270,7 @@ const SPIDERFLY = ${gridData};
264
270
  // ---- state shared across redraws --------------------------------------
265
271
  let session = null;
266
272
  let lastAgents = {};
273
+ let lastActiveWebs = [];
267
274
  let lastTurn = 0;
268
275
  const goalById = {};
269
276
  const spriteEls = {};
@@ -324,13 +331,21 @@ const SPIDERFLY = ${gridData};
324
331
  lastAgents = agents;
325
332
  }
326
333
 
334
+ function massBarHtml(cls, mass) {
335
+ const maxMass = cls === "spider" ? SPIDERFLY.maxSpiderMass : cls === "fly" ? SPIDERFLY.maxFlyMass : null;
336
+ if (typeof mass !== "number" || !maxMass) return "";
337
+ const pct = Math.max(0, Math.min(100, (mass / maxMass) * 100));
338
+ return '<div class="mass-track"><div class="mass-fill ' + esc(cls) + '" style="width:' + pct + '%"></div></div>';
339
+ }
340
+
327
341
  function renderHud() {
328
342
  const ids = Object.keys(lastAgents).sort();
329
343
  if (!ids.length) { hudEl.innerHTML = '<div class="hud-empty">no agents on the board.</div>'; return; }
330
344
  hudEl.innerHTML = ids.map((id) => {
331
345
  const cls = classOfAgentId(id);
332
346
  return '<div class="hud-row"><span class="hud-id ' + esc(cls) + '">' + esc(id) + '</span>'
333
- + '<span class="hud-goal">' + esc(goalById[id] || "watching\\u2026") + "</span></div>";
347
+ + '<span class="hud-goal">' + esc(goalById[id] || "watching\\u2026") + "</span>"
348
+ + massBarHtml(cls, lastAgents[id].mass) + "</div>";
334
349
  }).join("");
335
350
  }
336
351
 
@@ -340,7 +355,7 @@ const SPIDERFLY = ${gridData};
340
355
  directionDelta: tmctSpiderFly.DIRECTION_DELTA,
341
356
  };
342
357
 
343
- function drawBoard(agents) {
358
+ function drawBoard(agents, activeWebs) {
344
359
  const w = SPIDERFLY.boardPx, h = SPIDERFLY.boardPx;
345
360
  boardCtx.clearRect(0, 0, w, h);
346
361
  boardCtx.fillStyle = cssVar("--taught-soft") || "rgba(46,125,79,.12)";
@@ -348,6 +363,20 @@ const SPIDERFLY = ${gridData};
348
363
  const p = tmctSpiderFly.parseCellId(wc);
349
364
  boardCtx.fillRect((p.x - 1) * cellSize, (p.y - 1) * cellSize, cellSize, cellSize);
350
365
  }
366
+ // A spider-built dynamic web is a distinct color from the always-on
367
+ // static home zone above, plus a dashed outline — same concept
368
+ // (hasActiveWebAt), visually two different things on the board.
369
+ boardCtx.fillStyle = cssVar("--alert-soft") || "rgba(176,80,63,.12)";
370
+ boardCtx.strokeStyle = cssVar("--alert") || "#B0503F";
371
+ boardCtx.lineWidth = 1;
372
+ boardCtx.setLineDash([3, 2]);
373
+ for (const web of activeWebs || []) {
374
+ const p = tmctSpiderFly.parseCellId(web.cell);
375
+ if (!p) continue;
376
+ boardCtx.fillRect((p.x - 1) * cellSize, (p.y - 1) * cellSize, cellSize, cellSize);
377
+ boardCtx.strokeRect((p.x - 1) * cellSize + 0.5, (p.y - 1) * cellSize + 0.5, cellSize - 1, cellSize - 1);
378
+ }
379
+ boardCtx.setLineDash([]);
351
380
  boardCtx.strokeStyle = cssVar("--line") || "#DDD9D0";
352
381
  boardCtx.lineWidth = 1;
353
382
  for (let i = 0; i <= SPIDERFLY.gridSize; i += 1) {
@@ -406,12 +435,13 @@ const SPIDERFLY = ${gridData};
406
435
  });
407
436
  boardFrame.addEventListener("mouseleave", () => { threadTip.style.display = "none"; });
408
437
 
409
- function redraw(agents, turn) {
438
+ function redraw(agents, turn, activeWebs) {
410
439
  applyAgents(agents);
411
440
  renderHud();
412
441
  lastTurn = turn;
442
+ lastActiveWebs = activeWebs || [];
413
443
  turnLabelEl.textContent = "turn: " + turn;
414
- drawBoard(agents);
444
+ drawBoard(agents, lastActiveWebs);
415
445
  drawPov();
416
446
  }
417
447
 
@@ -431,7 +461,7 @@ const SPIDERFLY = ${gridData};
431
461
  const result = await session.turn(q);
432
462
  addChatLine("a", esc(result.answer).replace(/\\n/g, "<br>"));
433
463
  const snap = await session.snapshot();
434
- redraw(snap.agents, snap.turn);
464
+ redraw(snap.agents, snap.turn, snap.activeWebs);
435
465
  });
436
466
  });
437
467
 
@@ -447,7 +477,7 @@ const SPIDERFLY = ${gridData};
447
477
 
448
478
  async function boot() {
449
479
  session = await tmctSpiderFly.createSpiderFlySession();
450
- redraw(session.initial.agents, session.initial.turn);
480
+ redraw(session.initial.agents, session.initial.turn, session.initial.activeWebs);
451
481
  statusEl.textContent = session.opening;
452
482
  chatqEl.disabled = false;
453
483
  resetBtn.disabled = false; playBtn.disabled = false; stepBtn.disabled = false;
@@ -457,7 +487,7 @@ const SPIDERFLY = ${gridData};
457
487
  const ticker = createTicker({
458
488
  onTick: () => withLock(async () => {
459
489
  const result = await session.tick();
460
- redraw(result.agents, result.turn);
490
+ redraw(result.agents, result.turn, result.activeWebs);
461
491
  }),
462
492
  onRender: (state) => {
463
493
  playBtn.textContent = state.playing ? "\\u23f8 pause" : "\\u25b6 play";
@@ -479,10 +509,10 @@ const SPIDERFLY = ${gridData};
479
509
 
480
510
  boot().then(() => { if (preview) ticker.play(); });
481
511
 
482
- new MutationObserver(() => { drawBoard(lastAgents); drawPov(); })
512
+ new MutationObserver(() => { drawBoard(lastAgents, lastActiveWebs); drawPov(); })
483
513
  .observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
484
514
  if (window.matchMedia) {
485
- window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { drawBoard(lastAgents); drawPov(); });
515
+ window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { drawBoard(lastAgents, lastActiveWebs); drawPov(); });
486
516
  }
487
517
  })();
488
518
  </script>