@polycode-projects/the-mechanical-code-talker 2.7.26 → 2.8.1

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,395 @@
1
+ // sprite-catalog-viz.mjs — `public/sprites.html` (PLAN_GAMES_UPLIFT_V3.md
2
+ // Part C.2's "Sprite library" link-card item): every class the sprite
3
+ // library actually resolves a sprite for, at both tiers (data/sprites/
4
+ // *-icon.toml, 44px; data/sprites-large/*.toml, 400px), grouped for
5
+ // browsing, each swatch carrying its own real ontology mapping — the class
6
+ // name, its resolved template, and the rdfs:subClassOf ancestor chain the
7
+ // engine would actually walk to reach it — computed through the SAME
8
+ // resolver code the product itself runs (src/domain/sprite-map.mjs's
9
+ // classAncestorChain, src/domain/sprite-templates.mjs's resolveSpriteAsset),
10
+ // never a hand-simulated stand-in.
11
+ //
12
+ // Three pure/impure-separated pieces, mirroring ledger-viz.mjs's own
13
+ // computeLedgerData / computeLedgerDataFromPayload / renderLedgerHtml split:
14
+ // - loadSpriteOntologyFactRows() — I/O: the real ancestor-fact source
15
+ // - buildSpriteCatalogEntries(...) — pure derivation over templates+facts
16
+ // - renderSpriteCatalogHtml(...) — pure string builder
17
+ //
18
+ // ---- Ancestor facts: real, not invented ----
19
+ // classAncestorChain needs a flat {subject, predicate:"rdfs:subClassOf",
20
+ // object} row set to walk. Two REAL, already-committed sources are combined:
21
+ // - the spider-and-fly world's own SEED_TAXONOMY (src/domain/
22
+ // spider-fly-world.mjs) — poodle/dog/animal, spider/arachnid/animal,
23
+ // fly/insect/animal — the exact worked example sprite-map.mjs's own
24
+ // header names.
25
+ // - corpus/wordnet/wordnet-xl.jsonl (23,805 rows), the SAME opt-in
26
+ // "wordnet-xl" corpus extension the product itself ships (src/services/
27
+ // extensions.mjs), converted to rdfs:subClassOf facts through the
28
+ // existing src/adapters/corpus/conceptnet.mjs loader (loadSlice/loadMap/
29
+ // toFacts) — no bespoke parsing invented for this page.
30
+ // corpus/wordnet/wordnet-full.jsonl (192k rows, every WordNet sense
31
+ // unfiltered) was tried and rejected: with no word-sense disambiguation its
32
+ // hypernym graph conflates a word's every sense onto one node, so a class as
33
+ // ordinary as "poodle" walks into 3000+ unrelated ancestors and the walk
34
+ // itself takes minutes. wordnet-xl's own "prioritized subset" curation
35
+ // avoids most of that; what's left is capped for DISPLAY (see
36
+ // MAX_CHAIN_DISPLAY below) rather than hidden — still the real chain,
37
+ // just not printed to its full, occasionally very long, length.
38
+ //
39
+ // Every class catalogued here already carries its own template (that's
40
+ // what put it in the catalog), so live sprite resolution always stops at
41
+ // the chain's own first link — the fuller ancestor chain this page prints
42
+ // is real ancestry ON RECORD in the corpus, not a claim that resolution
43
+ // walks that far for THESE classes (it would, for an unregistered subtype
44
+ // like "sheepdog" — sprite-map.mjs's own worked example — which is exactly
45
+ // why the mechanism exists, just not exercised by any class shown here).
46
+
47
+ import { classAncestorChain, SPRITE_REGISTRY } from "../domain/sprite-map.mjs";
48
+ import { resolveSpriteAsset } from "../domain/sprite-templates.mjs";
49
+ import { MATERIAL_PALETTE } from "../domain/sprite-materials.mjs";
50
+ import { SEED_TAXONOMY } from "../domain/spider-fly-world.mjs";
51
+ import { loadSlice, loadMap, toFacts, WORDNET_DIR } from "../adapters/corpus/conceptnet.mjs";
52
+ import { join } from "node:path";
53
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
54
+
55
+ const DEFAULT_TITLE = "tmct — the sprite library";
56
+ const MAX_CHAIN_DISPLAY = 6;
57
+
58
+ /** The real rdfs:subClassOf fact rows this catalog's ancestor chains walk —
59
+ * see this module's own header for why these two sources and not a third.
60
+ * I/O (reads corpus/wordnet/wordnet-xl.jsonl + its relation map); never
61
+ * called from renderSpriteCatalogHtml itself, which stays pure. */
62
+ export async function loadSpriteOntologyFactRows() {
63
+ const seedRows = SEED_TAXONOMY.map(([subject, object]) => ({ subject, predicate: "rdfs:subClassOf", object }));
64
+ const assertions = await loadSlice(join(WORDNET_DIR, "wordnet-xl.jsonl"));
65
+ const map = await loadMap();
66
+ const wordnetRows = toFacts(assertions, map, "corpus:wordnet-xl")
67
+ .filter((f) => f.predicate === "rdfs:subClassOf")
68
+ .map((f) => ({ subject: f.subject, predicate: f.predicate, object: f.object }));
69
+ return [...seedRows, ...wordnetRows];
70
+ }
71
+
72
+ // ---- grouping (presentation only — every class still resolves through the
73
+ // real resolver above; this only decides which section of the page a
74
+ // class's card lands in) ----
75
+
76
+ // The icon tier's own two world families (sprite-map.mjs's header): the
77
+ // spider-and-fly board's creatures share the flat SPRITE_REGISTRY with the
78
+ // adventure world's own props. The creatures fold into "physical objects,
79
+ // creatures & places" below with every other animal; what's left after
80
+ // removing them (and "person", generic to both worlds) is Ashcombe Hall's
81
+ // own unique cast and furniture — never shared with another game.
82
+ const SPIDER_FLY_CREATURE_CLASSES = Object.freeze(["spider", "fly", "egg", "poodle", "dog", "animal"]);
83
+
84
+ // A curated closed list (the SOURCE_PRIOR/SPRITE_REGISTRY flat-table idiom
85
+ // this project already uses elsewhere) of every data/sprites-large/ class
86
+ // that reads as a person, a family relation, a social role/occupation, or a
87
+ // collective of people. A class named neither here nor an icon-tier
88
+ // adventure prop nor detected as an emoji-fallback class (both below) falls
89
+ // through to "physical objects, creatures & places" by default, so a future
90
+ // added class is never left uncategorized — it just lands in the generic
91
+ // bucket rather than vanishing.
92
+ export const PERSON_ROLE_CLASSES = Object.freeze([
93
+ "person", "human", "adult", "baby", "child", "boy", "girl", "man", "woman",
94
+ "mother", "father", "parent", "grandfather", "grandmother", "brother", "sister",
95
+ "son", "daughter", "husband", "wife", "family", "friend", "neighbor", "stranger",
96
+ "guest", "visitor", "customer", "employee", "boss", "manager", "leader",
97
+ "president", "king", "queen", "judge", "lawyer", "priest", "doctor", "nurse",
98
+ "teacher", "student", "engineer", "artist", "writer", "farmer", "driver",
99
+ "soldier", "officer", "servant", "worker", "volunteer", "citizen", "resident",
100
+ "champion", "crowd", "audience", "team",
101
+ ]);
102
+
103
+ export const GROUP_ADVENTURE = "adventure";
104
+ export const GROUP_PERSON = "person";
105
+ export const GROUP_OBJECT = "object";
106
+ export const GROUP_EMOJI = "emoji";
107
+
108
+ export const CATALOG_GROUPS = Object.freeze([
109
+ Object.freeze({ id: GROUP_ADVENTURE, label: "Ashcombe Hall's own adventure props", note: "the icon tier's own named cast and furniture — a dedicated 44px sprite exists for each" }),
110
+ Object.freeze({ id: GROUP_PERSON, label: "Person roles" }),
111
+ Object.freeze({ id: GROUP_OBJECT, label: "Physical objects, creatures & places" }),
112
+ Object.freeze({ id: GROUP_EMOJI, label: "Emotions & events", note: "abstract concepts with no honest single physical picture — rendered as the ubiquitous emoji instead" }),
113
+ ]);
114
+
115
+ /** Which catalog section `cls` belongs in. Pure. `isIconTierClass`/`isEmoji`
116
+ * are handed in rather than recomputed here so this stays a one-line
117
+ * decision over already-known facts about the class. */
118
+ export function groupForClass(cls, { isIconTierClass, isEmoji }) {
119
+ if (isEmoji) return GROUP_EMOJI;
120
+ if (isIconTierClass && !SPIDER_FLY_CREATURE_CLASSES.includes(cls) && cls !== "person") return GROUP_ADVENTURE;
121
+ if (PERSON_ROLE_CLASSES.includes(cls)) return GROUP_PERSON;
122
+ return GROUP_OBJECT;
123
+ }
124
+
125
+ function templatesForClass(cls, templates) {
126
+ return (templates || []).filter((t) => Array.isArray(t?.classes) && t.classes.includes(cls));
127
+ }
128
+
129
+ /** The MATERIAL_PALETTE treatment key whose {light,base,dark} triple exactly
130
+ * matches `value` — the reverse of sprite-materials.mjs's own
131
+ * expandMaterialReferences, so a swatch can show e.g. "gold -> metal
132
+ * treatment" (sprite-materials.mjs's own header: "gold and metal both read
133
+ * as the same warm shiny-metal treatment"). null for a plain single-
134
+ * placeholder colour value (a string, not an object) or a one-off hand-
135
+ * authored triple that matches no shared treatment. Pure. */
136
+ export function paletteTreatmentFor(value) {
137
+ if (!value || typeof value !== "object") return null;
138
+ for (const [name, triple] of Object.entries(MATERIAL_PALETTE)) {
139
+ if (triple.light === value.light && triple.base === value.base && triple.dark === value.dark) return name;
140
+ }
141
+ return null;
142
+ }
143
+
144
+ /** Every real value a template's own [parameters.*] declares, as
145
+ * {paramName, property, rawValue, treatment} rows — one row per value key,
146
+ * read directly off the template's own data, never invented. Pure. */
147
+ export function parameterVariantsFor(template) {
148
+ const out = [];
149
+ for (const [paramName, param] of Object.entries(template?.parameters || {})) {
150
+ for (const [rawValue, value] of Object.entries(param?.values || {})) {
151
+ out.push({ paramName, property: param.property, rawValue, treatment: paletteTreatmentFor(value) });
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+
157
+ /** Swatches for one tier's template set: a plain swatch ONLY when a real
158
+ * `{class}.toml`-shaped plain template exists (no [parameters]/[match] at
159
+ * all) — never a synthesized "plain" label for a material-only class,
160
+ * since with zero taught facts that class's real resolved output is the
161
+ * generic root-fallback shape, not its own silhouette (this module's own
162
+ * header names the 19 large-tier classes this applies to). Every
163
+ * [parameters.*] value and every [match] variant renders through the real
164
+ * resolveSpriteAsset with the exact property fact it declares. A class
165
+ * with templates but no plain among them also gets one extra swatch,
166
+ * labeled `fallback: true`, showing exactly what the real resolver returns
167
+ * for that class with NO taught fact — honest engine behaviour, never
168
+ * hidden, just never mislabeled "plain". Pure given `templates` (already
169
+ * loaded) and `registry` (SPRITE_REGISTRY). */
170
+ export function tierSwatchesFor(cls, templates, registry, tier) {
171
+ const forClass = templatesForClass(cls, templates);
172
+ if (!forClass.length) return [];
173
+ const plain = forClass.find((t) => !t.parameters && !t.match);
174
+ const swatches = [];
175
+ if (plain) {
176
+ swatches.push({
177
+ tier, label: "plain", kind: "plain",
178
+ svg: resolveSpriteAsset(cls, [], [], templates, registry, { instanceKey: `${cls}-${tier}-plain` }),
179
+ });
180
+ }
181
+ for (const t of forClass) {
182
+ if (t.match) {
183
+ const propertyFacts = [{ predicate: t.match.property, object: t.match.value }];
184
+ swatches.push({
185
+ tier, label: t.match.value, kind: "variant", property: t.match.property,
186
+ svg: resolveSpriteAsset(cls, [], propertyFacts, templates, registry, { instanceKey: `${cls}-${tier}-match-${t.match.value}` }),
187
+ });
188
+ }
189
+ for (const v of parameterVariantsFor(t)) {
190
+ const propertyFacts = [{ predicate: v.property, object: v.rawValue }];
191
+ swatches.push({
192
+ tier, label: v.rawValue, kind: "material", property: v.property, treatment: v.treatment,
193
+ svg: resolveSpriteAsset(cls, [], propertyFacts, templates, registry, { instanceKey: `${cls}-${tier}-${v.paramName}-${v.rawValue}` }),
194
+ });
195
+ }
196
+ }
197
+ if (!plain && swatches.length) {
198
+ swatches.push({
199
+ tier, label: "no material taught", kind: "fallback", fallback: true,
200
+ svg: resolveSpriteAsset(cls, [], [], templates, registry, { instanceKey: `${cls}-${tier}-fallback` }),
201
+ });
202
+ }
203
+ return swatches;
204
+ }
205
+
206
+ /** The full catalog: one entry per class the icon and/or large tier
207
+ * actually carries a template for, each with its real ancestor chain and
208
+ * every real tier/material swatch. Pure given the three loaded inputs.
209
+ * `factRows` defaults to `[]` (every chain then reads as just the class's
210
+ * own name — an honest, if less illustrative, chain rather than a crash)
211
+ * so a caller that hasn't loaded the ontology facts yet still gets a
212
+ * working catalog. */
213
+ export function buildSpriteCatalogEntries({ iconTemplates = [], largeTemplates = [], factRows = [] } = {}) {
214
+ const iconClasses = new Set(iconTemplates.flatMap((t) => t?.classes || []));
215
+ const largeClasses = new Set(largeTemplates.flatMap((t) => t?.classes || []));
216
+ const allClasses = [...new Set([...iconClasses, ...largeClasses])].sort();
217
+
218
+ return allClasses.map((cls) => {
219
+ const chain = classAncestorChain(cls, factRows);
220
+ const iconSwatches = tierSwatchesFor(cls, iconTemplates, SPRITE_REGISTRY, "icon");
221
+ const largeSwatches = tierSwatchesFor(cls, largeTemplates, SPRITE_REGISTRY, "large");
222
+ const isEmoji = [...iconSwatches, ...largeSwatches].some((s) => s.svg.includes("<text"));
223
+ const group = groupForClass(cls, { isIconTierClass: iconClasses.has(cls), isEmoji });
224
+ return { className: cls, group, chain, iconSwatches, largeSwatches };
225
+ });
226
+ }
227
+
228
+ // ---- rendering ----
229
+
230
+ function chainHtml(chain) {
231
+ const shown = chain.slice(0, MAX_CHAIN_DISPLAY);
232
+ const rest = chain.length - shown.length;
233
+ const links = shown
234
+ .map((term, i) => `<span class="chain-link${i === 0 ? " own" : ""}">${escapeHtml(term)}</span>`)
235
+ .join('<span class="chain-arrow">&rsaquo;</span>');
236
+ const more = rest > 0 ? `<span class="chain-more">+${rest} more on record</span>` : "";
237
+ return `<div class="chain">${links}${more}</div>`;
238
+ }
239
+
240
+ function swatchHtml(s) {
241
+ const parts = [`<span class="swatch-label">${escapeHtml(s.label)}</span>`];
242
+ if (s.treatment) parts.push(`<span class="swatch-treat">&rarr; ${escapeHtml(s.treatment)} treatment</span>`);
243
+ const title = s.property ? `${s.property} = ${s.label}` : s.label;
244
+ const cls = ["swatch", s.tier, s.kind].filter(Boolean).join(" ");
245
+ return `<div class="${cls}" title="${escapeHtml(title)}"><div class="swatch-img">${s.svg}</div><div class="swatch-caption">${parts.join("")}</div></div>`;
246
+ }
247
+
248
+ function tierRowHtml(tierName, swatches) {
249
+ if (!swatches.length) return "";
250
+ return `<div class="tier-row" data-tier="${tierName}">
251
+ <span class="tier-label">${tierName === "icon" ? "icon &middot; 44px" : "sprite &middot; 400px"}</span>
252
+ <div class="swatches">${swatches.map(swatchHtml).join("")}</div>
253
+ </div>`;
254
+ }
255
+
256
+ function cardHtml(entry) {
257
+ return `<article class="card" data-cls="${escapeHtml(entry.className)}" data-group="${escapeHtml(entry.group)}">
258
+ <h3 class="card-name">${escapeHtml(entry.className)}</h3>
259
+ ${chainHtml(entry.chain)}
260
+ ${tierRowHtml("icon", entry.iconSwatches)}
261
+ ${tierRowHtml("large", entry.largeSwatches)}
262
+ </article>`;
263
+ }
264
+
265
+ function sectionHtml(group, entries) {
266
+ const rows = entries.filter((e) => e.group === group.id);
267
+ if (!rows.length) return "";
268
+ const note = group.note ? `<p class="section-note">${escapeHtml(group.note)}</p>` : "";
269
+ return `<section class="group" id="g-${group.id}" aria-label="${escapeHtml(group.label)}">
270
+ <h2>${escapeHtml(group.label)} <span class="count">${rows.length}</span></h2>
271
+ ${note}
272
+ <div class="cards">${rows.map(cardHtml).join("")}</div>
273
+ </section>`;
274
+ }
275
+
276
+ /** The self-contained sprite-catalog page. Pure given `iconTemplates`
277
+ * (readSpriteTemplateFiles' own output), `largeTemplates`
278
+ * (readSpriteLargeTemplateFiles' own output) and `factRows`
279
+ * (loadSpriteOntologyFactRows' own output) — the same "byte-identical for
280
+ * identical input" invariant every other viz page in this project holds.
281
+ * All three default to `[]` so a caller mid-migration (no ontology facts
282
+ * loaded yet, say) still gets a page that renders, just with plainer
283
+ * ancestor chains. */
284
+ export function renderSpriteCatalogHtml({ title = DEFAULT_TITLE, iconTemplates = [], largeTemplates = [], factRows = [] } = {}) {
285
+ const entries = buildSpriteCatalogEntries({ iconTemplates, largeTemplates, factRows });
286
+ const totalSwatches = entries.reduce((n, e) => n + e.iconSwatches.length + e.largeSwatches.length, 0);
287
+ const pageData = embedJson({ classCount: entries.length, swatchCount: totalSwatches });
288
+ const navHtml = CATALOG_GROUPS
289
+ .map((g) => `<a class="jump" href="#g-${g.id}">${escapeHtml(g.label)} <span class="count">${entries.filter((e) => e.group === g.id).length}</span></a>`)
290
+ .join("");
291
+
292
+ return `<!doctype html>
293
+ <html lang="en">
294
+ <head>
295
+ <meta charset="utf-8">
296
+ <meta name="viewport" content="width=device-width, initial-scale=1">
297
+ <title>${escapeHtml(title)}</title>
298
+ <style>
299
+ ${THEME_TOKENS_CSS}
300
+ html { background: var(--bg); }
301
+ body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; }
302
+ .mono { font-family: ${MONO_STACK}; }
303
+ main { max-width: 1180px; margin: 0 auto; padding: 1.4rem 1.2rem 3rem; }
304
+ .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
305
+ h1 { font-size: 1.4rem; margin: .3rem 0 .6rem; text-wrap: balance; }
306
+ .intro { max-width: 74ch; color: var(--muted); font-size: .92rem; }
307
+ .topbar { position: sticky; top: 0; z-index: 2; background: var(--bg); display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .9rem; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: .6rem 0; margin: 1rem 0 1.2rem; }
308
+ .jump { font-family: ${MONO_STACK}; font-size: .72rem; padding: .18rem .55rem; border: 1px solid var(--line); border-radius: 99px; background: var(--card); color: var(--ink); text-decoration: none; }
309
+ .jump:hover { border-color: var(--taught); }
310
+ .jump .count { color: var(--muted); }
311
+ .filter { margin-left: auto; display: flex; align-items: center; gap: .4rem; }
312
+ .filter input { font-family: ${MONO_STACK}; font-size: .8rem; background: var(--card); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: .32rem .6rem; width: 200px; }
313
+ .filter .n { font-family: ${MONO_STACK}; font-size: .7rem; color: var(--muted); white-space: nowrap; }
314
+ .group { margin: 2rem 0; content-visibility: auto; contain-intrinsic-size: 800px; }
315
+ .group h2 { font-size: 1.05rem; margin: 0 0 .2rem; display: flex; align-items: baseline; gap: .5rem; }
316
+ .group h2 .count { font-family: ${MONO_STACK}; font-size: .72rem; color: var(--muted); font-weight: 400; }
317
+ .section-note { color: var(--muted); font-size: .82rem; margin: 0 0 .8rem; max-width: 68ch; }
318
+ .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: .7rem; }
319
+ .card { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: .6rem .7rem .7rem; content-visibility: auto; contain-intrinsic-size: 220px; }
320
+ .card[hidden] { display: none; }
321
+ .card-name { font-size: .92rem; margin: 0 0 .3rem; font-weight: 600; }
322
+ .chain { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); margin-bottom: .5rem; display: flex; flex-wrap: wrap; align-items: center; gap: .15rem; }
323
+ .chain-link { padding: .04rem .35rem; border: 1px solid var(--line); border-radius: 99px; }
324
+ .chain-link.own { border-color: var(--taught); color: var(--taught); }
325
+ .chain-arrow { color: var(--muted); opacity: .6; }
326
+ .chain-more { font-style: italic; opacity: .75; }
327
+ .tier-row { margin-top: .4rem; }
328
+ .tier-row:first-of-type { margin-top: 0; }
329
+ .tier-label { font-family: ${MONO_STACK}; font-size: .62rem; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
330
+ .swatches { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .25rem; }
331
+ .swatch { width: 64px; text-align: center; }
332
+ .swatch.large .swatch-img { width: 64px; height: 64px; }
333
+ .swatch.icon .swatch-img { width: 34px; height: 34px; margin: 0 auto; }
334
+ .swatch-img svg { width: 100%; height: 100%; display: block; }
335
+ .swatch.fallback { opacity: .55; }
336
+ .swatch.fallback .swatch-img { outline: 1px dashed var(--line); outline-offset: 2px; }
337
+ .swatch-caption { font-family: ${MONO_STACK}; font-size: .58rem; color: var(--muted); line-height: 1.25; margin-top: .15rem; word-break: break-word; }
338
+ .swatch-treat { display: block; opacity: .8; }
339
+ footer.page { max-width: 74ch; margin: 2.5rem 0 0; padding-top: 1rem; border-top: 1px solid var(--line); font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); }
340
+ @media (prefers-reduced-motion: no-preference) { .jump, .swatch { transition: border-color .12s ease, opacity .12s ease; } }
341
+ </style>
342
+ </head>
343
+ <body>
344
+ <main>
345
+ <div class="eyebrow">tmct &middot; the sprite library</div>
346
+ <h1>Every shape the sprite library can draw, and why</h1>
347
+ <p class="intro">Two tiers share one resolver: a 44px icon set for the live games, a 400px
348
+ gradient-shaded set for a closer look. Each card below shows a class's real
349
+ <span class="mono">rdfs:subClassOf</span> ancestor chain and every swatch the real resolver
350
+ (<span class="mono">resolveSpriteAsset</span>) actually returns for it &mdash; a material-bearing
351
+ class shows its real taught-material variants, never an invented one. Every class shown here
352
+ already carries its own template, so live resolution always stops at the chain's own first
353
+ link (marked); the fuller chain is real ancestry on record in this catalog's corpus slice, kept
354
+ for context.</p>
355
+ <div class="topbar">
356
+ <nav aria-label="Jump to group">${navHtml}</nav>
357
+ <div class="filter">
358
+ <input id="q" type="text" placeholder="filter by class or group&hellip;" aria-label="Filter the catalog">
359
+ <span class="n mono" id="qcount"></span>
360
+ </div>
361
+ </div>
362
+ ${CATALOG_GROUPS.map((g) => sectionHtml(g, entries)).join("")}
363
+ <footer class="page">${entries.length} classes &middot; ${totalSwatches} swatches &middot; icon tier 44px, sprite tier 400px</footer>
364
+ </main>
365
+ <script>
366
+ const SPRITE_CATALOG = ${pageData};
367
+ </script>
368
+ <script>
369
+ (function () {
370
+ "use strict";
371
+ const q = document.getElementById("q");
372
+ const qcount = document.getElementById("qcount");
373
+ const cards = Array.from(document.querySelectorAll(".card"));
374
+ function apply() {
375
+ const needle = q.value.trim().toLowerCase();
376
+ let shown = 0;
377
+ for (const card of cards) {
378
+ const hit = !needle || card.dataset.cls.includes(needle) || card.dataset.group.includes(needle);
379
+ card.hidden = !hit;
380
+ if (hit) shown += 1;
381
+ }
382
+ for (const section of document.querySelectorAll(".group")) {
383
+ const anyShown = section.querySelectorAll(".card:not([hidden])").length > 0;
384
+ section.style.display = anyShown ? "" : "none";
385
+ }
386
+ qcount.textContent = needle ? shown + " / " + cards.length : "";
387
+ }
388
+ q.addEventListener("input", apply);
389
+ apply();
390
+ })();
391
+ </script>
392
+ </body>
393
+ </html>
394
+ `;
395
+ }
@@ -36,7 +36,7 @@ import {
36
36
  import { parseEntities } from "../../domain/codegraph.mjs";
37
37
  import { loadLexicon } from "../../domain/grammar/lexicon.mjs";
38
38
  import { foldWorldState, worldDigestRows, roomAffordances } from "../../services/adventure.mjs";
39
- import { runAdventureAutoplayTick } from "../../services/adventure-autoplay.mjs";
39
+ import { runAdventureAutoplayTick, exposedFacts } from "../../services/adventure-autoplay.mjs";
40
40
  import { resolveSpriteForClass, SPRITE_REGISTRY } from "../../domain/sprite-map.mjs";
41
41
  import { resolveSpriteAsset } from "../../domain/sprite-templates.mjs";
42
42
 
@@ -59,10 +59,26 @@ export async function createAdventureSession(worldPayload) {
59
59
 
60
60
  const planHolder = { state: { adventure: { world: worldPayload.name } } };
61
61
  const sessionId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
62
- let exposedRoomIds = new Set();
62
+ // visitedRoomIds is the ONE exposure set both `turn()` and `autoplayTick()`
63
+ // grow — a deliberate merge, not two separate histories. Both entry points
64
+ // move the SAME player through the SAME shared memoryDir: `here` is always
65
+ // read fresh off the live fact store (state.placements.get("player")), not
66
+ // off whichever path last ran, so by the time either call finishes, the
67
+ // room the player is standing in is genuinely known to a visitor looking
68
+ // at the page regardless of which control moved them there. Keeping two
69
+ // separate sets would make the map/goal panels UNDER-report a room a
70
+ // visitor plainly just walked autoplay through (or vice versa) — a false
71
+ // conservatism, not real honesty, since nothing about the OTHER path's
72
+ // moves is hidden from this same session. runAdventureAutoplayTick's own
73
+ // returned `exposedRoomIds` already folds forward whatever it was handed,
74
+ // so feeding it this merged set on every tick, and folding its result back
75
+ // into the same variable, is enough: manual moves feed autoplay's own
76
+ // reasoning, autoplay's moves feed the panels, with no separate
77
+ // bookkeeping either way.
78
+ let visitedRoomIds = new Set();
63
79
  const openingRows = readFactRows(await loadMemory(memoryDir));
64
80
  const openingHere = foldWorldState(openingRows).placements.get("player")?.object ?? null;
65
- if (openingHere) exposedRoomIds = new Set([openingHere]);
81
+ if (openingHere) visitedRoomIds = new Set([openingHere]);
66
82
 
67
83
  const graph = parseEntities({ individuals: [], objectProperties: [] });
68
84
  const lexicon = loadLexicon();
@@ -78,16 +94,18 @@ export async function createAdventureSession(worldPayload) {
78
94
  * `{ turn, goal, plan, done, stalled }` unmodified. */
79
95
  async autoplayTick() {
80
96
  const result = await runAdventureAutoplayTick(memoryDir, {
81
- exposedRoomIds, planHolder, sessionId, env: {},
97
+ exposedRoomIds: visitedRoomIds, planHolder, sessionId, env: {},
82
98
  });
83
- exposedRoomIds = result.exposedRoomIds;
99
+ visitedRoomIds = result.exposedRoomIds;
84
100
  return result;
85
101
  },
86
102
 
87
103
  /** One dispatched chat turn — the SAME runTurn the CLI and every other
88
104
  * viz page's own chat dock run, over this session's own memoryDir. A
89
105
  * throwing runTurn must never kill the session — the page has no other
90
- * chance to show this turn's answer. */
106
+ * chance to show this turn's answer. Grows `visitedRoomIds` with the
107
+ * player's post-turn room (a no-op add when the command didn't move
108
+ * anyone) — the manual-play half of the merged exposure set. */
91
109
  async turn(line) {
92
110
  let result;
93
111
  try {
@@ -102,25 +120,33 @@ export async function createAdventureSession(worldPayload) {
102
120
  focus = result.focus;
103
121
  last = result.last;
104
122
  if ("planState" in result) planHolder.state = result.planState;
123
+ const here = foldWorldState(readFactRows(await loadMemory(memoryDir))).placements.get("player")?.object ?? null;
124
+ if (here) visitedRoomIds.add(here);
105
125
  return { answer: result.answer, end: Boolean(result.end), record: result.record ?? null, plan: result.plan ?? null };
106
126
  },
107
127
 
108
128
  /** A read-only fold of the current room — no engine advance — for the
109
129
  * page's own redraw after boot, after every tick, and after every
110
- * manual chat turn. */
130
+ * manual chat turn. `visitedRoomIds` travels as a plain array (the same
131
+ * merged exposure set `turn()`/`autoplayTick()` both grow) so the
132
+ * carrying/map/goal panels can read it without holding a live
133
+ * reference into this closure's own Set. */
111
134
  async snapshot() {
112
135
  const rows = readFactRows(await loadMemory(memoryDir));
113
136
  const state = foldWorldState(rows);
114
137
  const here = state.placements.get("player")?.object ?? null;
115
- return { rows, state, here, turn: state.turnCount };
138
+ return { rows, state, here, turn: state.turnCount, visitedRoomIds: [...visitedRoomIds] };
116
139
  },
117
140
  };
118
141
  }
119
142
 
120
143
  // Re-exported so the page's own rendering script (adventure-viz.mjs) never
121
- // has to duplicate sprite resolution, the digest reader, or the room
122
- // affordances the chat dock's own pills read from the same posture
123
- // spider-fly-browser-entry.mjs's own globalThis.tmctSpiderFly re-export takes.
144
+ // has to duplicate sprite resolution, the digest reader, the room
145
+ // affordances the chat dock's own pills read from, or (foldWorldState,
146
+ // exposedFacts) the exposure-filtered fold the goal-status panel mirrors —
147
+ // the same posture spider-fly-browser-entry.mjs's own
148
+ // globalThis.tmctSpiderFly re-export takes.
124
149
  globalThis.tmctAdventure = {
125
- createAdventureSession, resolveSpriteForClass, SPRITE_REGISTRY, resolveSpriteAsset, worldDigestRows, roomAffordances,
150
+ createAdventureSession, resolveSpriteForClass, SPRITE_REGISTRY, resolveSpriteAsset,
151
+ worldDigestRows, roomAffordances, foldWorldState, exposedFacts,
126
152
  };