@polycode-projects/the-mechanical-code-talker 4.0.1 → 4.1.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.
Files changed (65) hide show
  1. package/README.md +2 -1
  2. package/corpus/sprites/src/sprite-facts.jsonl +375 -8
  3. package/package.json +1 -1
  4. package/src/adapters/memory/core.mjs +20 -0
  5. package/src/domain/ask-vocab.mjs +71 -0
  6. package/src/domain/ask.mjs +168 -0
  7. package/src/domain/game-config.mjs +11 -0
  8. package/src/domain/mud-facts.mjs +15 -0
  9. package/src/domain/router/drive.mjs +35 -9
  10. package/src/domain/router/registry.mjs +24 -4
  11. package/src/domain/router/resolver.mjs +102 -40
  12. package/src/domain/scene-compose.mjs +117 -0
  13. package/src/domain/spider-fly-world.mjs +36 -0
  14. package/src/domain/sprite-facts.mjs +0 -0
  15. package/src/domain/sprite-request.mjs +156 -0
  16. package/src/domain/sprite-templates.mjs +161 -14
  17. package/src/services/adventure-editor.mjs +8 -14
  18. package/src/services/adventure-viz.mjs +119 -150
  19. package/src/services/adventure.mjs +97 -35
  20. package/src/services/chat-page-viz.mjs +64 -48
  21. package/src/services/chat.mjs +102 -34
  22. package/src/services/code-explorer-viz.mjs +52 -50
  23. package/src/services/ingest-viz.mjs +32 -74
  24. package/src/services/ledger-viz.mjs +87 -70
  25. package/src/services/memory-panel-viz.mjs +38 -0
  26. package/src/services/mud-editor.mjs +10 -15
  27. package/src/services/mud-turn.mjs +6 -6
  28. package/src/services/mud-viz.mjs +119 -225
  29. package/src/services/p2p-room.mjs +90 -23
  30. package/src/services/plan-pddl.mjs +3 -1
  31. package/src/services/plan-viz.mjs +13 -12
  32. package/src/services/research-viz.mjs +25 -67
  33. package/src/services/spider-fly-turn.mjs +14 -22
  34. package/src/services/spider-fly-viz.mjs +97 -136
  35. package/src/services/spider-fly.mjs +69 -11
  36. package/src/services/sprite-catalog-viz.mjs +274 -224
  37. package/src/services/viz-boot.mjs +71 -0
  38. package/src/services/viz-room-graph.mjs +203 -0
  39. package/src/services/viz-theme.mjs +75 -1
  40. package/src/services/viz-ticker.mjs +22 -0
  41. package/src/surfaces/web/adventure-browser-entry.mjs +62 -47
  42. package/src/surfaces/web/chat-browser-entry.mjs +51 -107
  43. package/src/surfaces/web/code-explorer-browser-entry.mjs +192 -35
  44. package/src/surfaces/web/engine-surface.mjs +82 -0
  45. package/src/surfaces/web/ingest-browser-entry.mjs +16 -17
  46. package/src/surfaces/web/ledger-browser-entry.mjs +24 -56
  47. package/src/surfaces/web/memory-ask-browser-entry.mjs +55 -13
  48. package/src/surfaces/web/memory-ask-browser.bundle.js +128 -125
  49. package/src/surfaces/web/memory-stats.mjs +11 -0
  50. package/src/surfaces/web/mud-browser-entry.mjs +70 -49
  51. package/src/surfaces/web/plan-browser-entry.mjs +39 -50
  52. package/src/surfaces/web/research-browser-entry.mjs +48 -46
  53. package/src/surfaces/web/spider-fly-browser-entry.mjs +76 -40
  54. package/src/surfaces/web/sprites-browser-entry.mjs +28 -32
  55. package/src/surfaces/web/tmct-surface.mjs +147 -0
  56. package/src/surfaces/web/turn-session.mjs +124 -0
  57. package/src/tools/definitions.mjs +30 -0
  58. package/src/tools/handlers/index.mjs +6 -3
  59. package/src/tools/handlers/kit.mjs +19 -2
  60. package/src/tools/handlers/tmct-ask.mjs +11 -6
  61. package/src/tools/handlers/tmct-ingest.mjs +5 -1
  62. package/src/tools/handlers/tmct-related.mjs +4 -4
  63. package/src/tools/handlers/tmct-sprite.mjs +147 -0
  64. package/src/tools/memory-fallthrough.mjs +9 -2
  65. package/src/tools/server.mjs +37 -6
@@ -11,15 +11,20 @@
11
11
  // grammar doesn't carry, and a rescue path when the NL parse selects an out-of-set
12
12
  // capability).
13
13
  //
14
- // Entity binding is delegated to `resolveObject` (ask.mjs); an ambiguous or no-match term
15
- // is an honest refuse, never a guess.
14
+ // Entity binding is delegated to `resolveObject` (ask.mjs) for code-graph slots, and to
15
+ // `resolveMemoryTerm` (below) for a slot whose registry kind is KINDS.MemoryTerm — the
16
+ // conversational-memory graph's own binding oracle. An ambiguous or no-match term is an
17
+ // honest refuse, never a guess, on both paths.
16
18
 
17
19
  import { parseQuery } from "../ask.mjs";
18
20
  import { SUPERLATIVE_EXTREMES } from "../ask-vocab.mjs";
19
21
  import { buildSkosConceptView } from "../skos-view.mjs";
22
+ import { normFactTerm } from "../hash.mjs";
20
23
  import { edgesOfKind } from "../codegraph.mjs";
24
+ import { EXPRESSION_PALETTE } from "../sprite-expressions.mjs";
25
+ import { sizeScaleFor } from "../sprite-size.mjs";
21
26
  import {
22
- capabilities, capabilityByName, preconditionsOf, effectsOf, PRECOND,
27
+ capabilities, capabilityByName, parametersOf, preconditionsOf, effectsOf, PRECOND, KINDS, MEMORY_KINDS,
23
28
  } from "./registry.mjs";
24
29
  import { hallucinationsIn } from "./call-validator.mjs";
25
30
 
@@ -64,6 +69,25 @@ export const UNMAPPED_KINDS = Object.freeze({
64
69
  // via resolveMemoryTerm, the memory-graph sibling of resolveObject's code-graph binding.
65
70
  export const NOT_NL_REACHABLE = Object.freeze({});
66
71
 
72
+ /** The property predicate sprite-size.mjs reads a size word off. Used to PROBE
73
+ * its own closed scale table instead of restating it here: a word is a size
74
+ * word exactly when it moves the scale away from 1. */
75
+ const SIZE_FACT_PREDICATE = "mgx:hasProperty";
76
+
77
+ /** The sprite frame's two OPTIONAL slots, read straight out of the request.
78
+ * Both vocabularies are the real ones — sprite-expressions.mjs's own palette
79
+ * and sprite-size.mjs's own scale table (probed, not copied) — so a word
80
+ * either module gains is a word this frame gains. A request naming neither
81
+ * fills neither, and the sprite resolves plain. */
82
+ function spriteSlots(request) {
83
+ const slots = {};
84
+ for (const word of String(request).toLowerCase().match(/[a-z]+/g) || []) {
85
+ if (!slots.expression && Object.hasOwn(EXPRESSION_PALETTE, word)) slots.expression = word;
86
+ if (!slots.size && sizeScaleFor([{ predicate: SIZE_FACT_PREDICATE, object: word }]) !== 1) slots.size = word;
87
+ }
88
+ return slots;
89
+ }
90
+
67
91
  // ---- imperative intent FRAMES (fills what the relational grammar and command register
68
92
  // both miss). regex -> { topic, arg | noArg }. Ordered: first match wins.
69
93
  export const FRAMES = Object.freeze([
@@ -86,10 +110,19 @@ export const FRAMES = Object.freeze([
86
110
  { re: /\bsignature\b/i, topic: "signature", arg: "symbol" },
87
111
  // tmct_related: the SKOS synonym/related-concept surface over the memory graph
88
112
  // — "another word for X" / "a synonym for X" / "synonyms of X" / "what's
89
- // related to X". `arg: "term"` is the one memory-graph-bound slot in this
90
- // table (see resolveMemoryTerm below); every other frame's arg binds against
91
- // the code graph via ctx.resolve.
113
+ // related to X". Its `term` slot is declared KINDS.MemoryTerm in the registry,
114
+ // so resolveOne binds it through resolveMemoryTerm (below) rather than the
115
+ // code graph's resolveObject — the registry kind, not this table, decides.
92
116
  { re: /\bsynonyms?\b|\banother\s+word\s+for\b|\brelated\s+(?:words?|concepts?|to)\b/i, topic: "related", arg: "term" },
117
+ // tmct_sprite: the sprite surface over the memory graph's own world facts —
118
+ // "the large sprite for a happy spider", "what does a hungry fly look like",
119
+ // "show me the spider icon". Its `class` slot is the second KINDS.MemoryTerm
120
+ // one, so it binds the same way tmct_related's `term` does. `slots` is what
121
+ // makes this frame different: it fills the two OPTIONAL args itself.
122
+ {
123
+ re: /\bsprite\b|\bicon\b|\bavatar\b|\bpicture\s+of\b|\bwhat\s+does\s+.+\s+look\s+like\b/i,
124
+ topic: "sprite", arg: "class", slots: spriteSlots,
125
+ },
93
126
  { re: /\bdescribe\b|\bexplain\b|what\s+is\b|tell\s+me\s+about\b|definition\s+of\b/i, topic: "description", arg: "symbol" },
94
127
  { re: /\bsearch\b|\bfind\b|look\s+for\b/i, topic: "matches", arg: "query" },
95
128
  ]);
@@ -132,13 +165,17 @@ const STOP = new Set([
132
165
  "untested", "blast", "radius", "change", "changes", "changing", "reach", "reaches", "affect", "affects",
133
166
  "explain", "edge", "edges", "graph", "outgoing", "site", "sites", "invoke", "invokes", "run", "runs", "execute", "executes",
134
167
  "word", "words", "another", "synonym", "synonyms", "related", "relate", "relates", "like", "concept", "concepts",
168
+ "sprite", "sprites", "icon", "icons", "avatar", "avatars", "picture", "pictures", "image", "images", "looks",
135
169
  ]);
136
170
 
137
171
  /** Pull one entity token from a request (imperative-frame slot-filling). Prefer a
138
- * path/dotted/CamelCase token, else the last non-stopword identifier. "" if none. */
139
- export function extractEntity(request) {
172
+ * path/dotted/CamelCase token, else the last non-stopword identifier. "" if none.
173
+ * `alreadyBound` holds the words a frame's own slot-filler has already claimed
174
+ * (the sprite frame's expression/size), so they can't also be read as the entity. */
175
+ export function extractEntity(request, alreadyBound = null) {
140
176
  const tokens = String(request).match(/[A-Za-z_][A-Za-z0-9_./-]*/g) || [];
141
- const pool = tokens.filter((t) => !STOP.has(t.toLowerCase()));
177
+ const claimed = alreadyBound || new Set();
178
+ const pool = tokens.filter((t) => !STOP.has(t.toLowerCase()) && !claimed.has(t.toLowerCase()));
142
179
  const strong = pool.filter((t) => /[./]/.test(t) || /^[A-Z][a-z]/.test(t) || /\.[a-z]+$/.test(t));
143
180
  const pick = strong.length ? strong : pool;
144
181
  return pick.length ? pick[pick.length - 1] : "";
@@ -187,13 +224,14 @@ export function mapFrame(request) {
187
224
  const cap = backwardChain(f.topic);
188
225
  if (!cap) continue;
189
226
  if (f.noArg) return { name: cap.name, noArg: true, topic: f.topic, source: "frame", why: [`imperative frame => goal (knows ${f.topic})`, `backward-chain => ${cap.name}`] };
190
- const term = f.arg === "query" ? searchQuery(request) : extractEntity(request);
191
- // memoryTerm: this slot binds against the memory graph's SKOS concept view
192
- // (resolveMemoryTerm below), not the code graph resolveObject resolves
193
- // every other frame's arg against "term" is the one param kind this is
194
- // true for (tmct_related's own memory-facts-gated param).
227
+ // A frame's own OPTIONAL slots, filled from the request's vocabulary before
228
+ // the entity is read, so a word a slot claimed can't also be read as the entity.
229
+ const slots = f.slots ? f.slots(request) : null;
230
+ const term = f.arg === "query"
231
+ ? searchQuery(request)
232
+ : extractEntity(request, slots ? new Set(Object.values(slots)) : null);
195
233
  return {
196
- name: cap.name, arg: f.arg, term, topic: f.topic, source: "frame", memoryTerm: f.arg === "term",
234
+ name: cap.name, arg: f.arg, term, topic: f.topic, source: "frame", slots,
197
235
  why: [`imperative frame => goal (knows ${f.topic} ?${f.arg})`, `backward-chain => ${cap.name}`],
198
236
  };
199
237
  }
@@ -215,24 +253,41 @@ export function commandCapability(request, declaredNames, selectTool) {
215
253
  return { name: sel.name, input, source: "command", why: [`command register: "${String(request).trim().split(/\s+/)[0]}" => ${sel.name}`] };
216
254
  }
217
255
 
218
- /** Resolve a term against the memory graph's SKOS concept view
219
- * (skos-view.mjs's buildSkosConceptView) the memory-graph sibling of
220
- * resolveObject, for a param whose precondition is memory-facts rather than
221
- * a code-graph `resolves`. Same `{ match, ambiguous }` shape resolveObject
222
- * returns, so resolveOne's generic binding step treats both the same way:
223
- * `match.label` is the RAW queried term (not the concept's canonicalised
224
- * prefLabel), because tmct_related's own lookup (relatedForTerm) re-derives
225
- * the concept from whatever term it's given the bound call should carry
226
- * what the user actually asked about. No code-graph fallback: a term the
227
- * store holds no synonym/related facts for is an honest miss, never a guess.
228
- * `rows` is a loadMemory+readFactRows payload the same trust-bearing rows
229
- * skosRelatedAnswer (chat.mjs) and tmct_related (the tool handler) read. */
256
+ /** Resolve a term against the conversational-memory graph the memory-graph
257
+ * sibling of resolveObject, for a slot whose registry kind is KINDS.MemoryTerm.
258
+ * Two tiers, tried in order, both EXACT after normFactTerm (no fuzzy tier — a
259
+ * near-miss is a miss, never a guess):
260
+ * 1. "memory-concept" the SKOS concept view (skos-view.mjs) mints a
261
+ * concept for the term, i.e. the store holds synonym/related facts for it;
262
+ * 2. "memory-fact-term" the term appears as the subject or object of any
263
+ * stored fact row (a world-fact term: a room, a game agent, a taught
264
+ * individual).
265
+ * Same `{ match, ambiguous }` shape resolveObject returns, so resolveOne's
266
+ * generic binding step treats both oracles the same way: `match.label` is the
267
+ * RAW queried term (not a canonicalised prefLabel), because a memory tool's
268
+ * own lookup (e.g. relatedForTerm) re-derives its rows from whatever term it's
269
+ * given — the bound call should carry what the user actually asked about. No
270
+ * code-graph fallback: a term the store holds no facts for at all is an honest
271
+ * miss. `rows` is a loadMemory+readFactRows payload — the same trust-bearing
272
+ * rows skosRelatedAnswer (chat.mjs) and the memory tool handlers read. */
230
273
  export function resolveMemoryTerm(rows, term) {
231
274
  const t = String(term || "").trim();
232
275
  if (!t) return { match: null };
233
276
  const view = buildSkosConceptView(rows);
234
- if (!view.conceptIdForTerm(t)) return { match: null };
235
- return { match: { label: t, class: "skos:Concept" }, ambiguous: false, tier: "memory-concept" };
277
+ if (view.conceptIdForTerm(t)) return { match: { label: t, class: "skos:Concept" }, ambiguous: false, tier: "memory-concept" };
278
+ const n = normFactTerm(t);
279
+ if (n && rows.some((r) => normFactTerm(r.subject) === n || normFactTerm(r.object) === n)) {
280
+ return { match: { label: t, class: KINDS.MemoryTerm }, ambiguous: false, tier: "memory-fact-term" };
281
+ }
282
+ return { match: null };
283
+ }
284
+
285
+ /** True when capability `capName`'s slot with arg key `arg` is declared a
286
+ * memory-graph kind (KINDS.MemoryTerm) — the registry-driven switch between
287
+ * the two binding oracles: resolveMemoryTerm for memory slots, the code
288
+ * graph's resolveObject for everything else. */
289
+ export function isMemoryTermSlot(capName, arg) {
290
+ return parametersOf(capName).some((p) => p.arg === arg && MEMORY_KINDS.includes(p.kind));
236
291
  }
237
292
 
238
293
  // ---- the full single-call resolver (async — binds + grounds) -----------------
@@ -328,27 +383,29 @@ export async function resolveOne(request, declaredNames, ctx, { execute = true }
328
383
  if (!declared.has(pick.name)) return REFUSE(`selected ${pick.name} but it is not in the declared toolset`);
329
384
 
330
385
  // A command pick already carries a bound input; an NL/frame pick carries a raw term
331
- // we bind via resolveObject (code graph) or, for a memoryTerm slot, resolveMemoryTerm
332
- // (the memory graph's SKOS concept view) the two binding oracles never mix on one pick.
386
+ // we bind via resolveObject (code graph) or, for a KINDS.MemoryTerm slot (the
387
+ // registry's own declaration, read through isMemoryTermSlot), resolveMemoryTerm
388
+ // the two binding oracles never mix on one pick.
333
389
  let input = pick.input ? { ...pick.input } : {};
334
390
  let resolved = null;
335
391
  if (!pick.input && !pick.noArg) {
336
392
  const term = String(pick.term || "").trim();
337
393
  if (!term) return REFUSE(`the ${pick.topic} intent named no entity to bind`);
338
- const r = pick.memoryTerm
394
+ const memoryBound = isMemoryTermSlot(pick.name, pick.arg);
395
+ const r = memoryBound
339
396
  ? (ctx.resolveMemoryTerm ? await ctx.resolveMemoryTerm(term) : { match: null })
340
397
  : (ctx.resolve ? ctx.resolve(term) : { match: { label: term }, ambiguous: false });
341
398
  if (!r || !r.match) {
342
- return REFUSE(pick.memoryTerm
343
- ? `"${term}" has no synonym/related facts in the memory graph (honest miss)`
399
+ return REFUSE(memoryBound
400
+ ? `the memory graph holds no facts mentioning "${term}" (honest miss)`
344
401
  : `"${term}" does not resolve to any graph entity (honest miss)`);
345
402
  }
346
403
  // A tied read: either resolveObject's own score-tie (r.ambiguous), or a same-tier
347
404
  // candidate the graph's own `tests` edge ties to the match (a source module and
348
405
  // its test module — a grain neither side's raw score alone reveals as tied).
349
- // resolveMemoryTerm's SKOS concept view has no code-graph tests-edge notion, so the
350
- // sibling tie-check only applies on the code-graph resolution path.
351
- const sibling = (!pick.memoryTerm && !r.ambiguous)
406
+ // resolveMemoryTerm's exact-match tiers have no code-graph tests-edge notion, so
407
+ // the sibling tie-check only applies on the code-graph resolution path.
408
+ const sibling = (!memoryBound && !r.ambiguous)
352
409
  ? (r.candidates || []).find((c) => testModuleTie(ctx.graph, r.match, c))
353
410
  : null;
354
411
  if (r.ambiguous || sibling) {
@@ -360,10 +417,15 @@ export async function resolveOne(request, declaredNames, ctx, { execute = true }
360
417
  return REFUSE(`"${term}" is ambiguous (${pool.map((m) => m.label).join(", ")}) — narrow it`, extra);
361
418
  }
362
419
  resolved = r.match;
363
- input = { [pick.arg]: r.match.label };
364
- why = [...why, pick.memoryTerm
365
- ? `resolveMemoryTerm: "${term}" mints a memory-graph SKOS concept (tier ${r.tier})`
420
+ // The frame's own optional slots ride alongside the bound entity — declared
421
+ // params of the same capability, so hallucinationsIn still validates them.
422
+ input = { [pick.arg]: r.match.label, ...(pick.slots || {}) };
423
+ why = [...why, memoryBound
424
+ ? `resolveMemoryTerm: "${term}" binds in the memory graph (${r.match.class || "?"}, tier ${r.tier})`
366
425
  : `resolveObject: "${term}" => ${r.match.label} (${r.match.class || "?"}, tier ${r.tier})`];
426
+ if (pick.slots && Object.keys(pick.slots).length) {
427
+ why = [...why, `frame slots bound from the request's own vocabulary: ${Object.entries(pick.slots).map(([k, v]) => `${k}=${v}`).join(", ")}`];
428
+ }
367
429
  }
368
430
 
369
431
  const call = { name: pick.name, input };
@@ -0,0 +1,117 @@
1
+ // scene-compose.mjs — the sprite catalog's "there is a…" box: which real
2
+ // catalog classes a free-typed sentence names, and with which of that class's
3
+ // own material labels.
4
+ //
5
+ // Naming a class is the engine's own job. This module segments the sentence
6
+ // into candidate spans and hands each one to ask.mjs's resolveObject — the same
7
+ // resolver the chat lanes use — over a graph whose individuals are the caller's
8
+ // real classes. So the caller owns where a name might start and stop, and
9
+ // nothing else: casing, leading articles and grain words come from the
10
+ // resolver's own tiers, and a span carrying a word the index has no reading for
11
+ // declines there rather than resolving past it.
12
+ //
13
+ // It lives in domain/ rather than beside the page that draws the scene because
14
+ // the page's browser bundle needs it: routing through the real resolver means
15
+ // the parser can't be spliced into the page as self-contained text any more.
16
+
17
+ import { resolveObject } from "./ask.mjs";
18
+ import { parseEntities } from "./codegraph.mjs";
19
+
20
+ /** `text`'s lowercase word runs, the unit class names are matched against —
21
+ * punctuation never fuses two real words into one token nor splits one real
22
+ * word into two. */
23
+ export function tokenizeSceneText(text) {
24
+ const tokens = [];
25
+ const re = /[A-Za-z]+/g;
26
+ let m;
27
+ while ((m = re.exec(String(text ?? "")))) tokens.push({ word: m[0].toLowerCase() });
28
+ return tokens;
29
+ }
30
+
31
+ const sceneClassGraphCache = new WeakMap();
32
+
33
+ /** The `classIndex`'s own class names as a resolvable graph, one individual per
34
+ * class, plus the longest class name's word count (the widest span worth
35
+ * offering the resolver). Cached per index object: a page builds its index once
36
+ * at load and then composes on every keystroke. */
37
+ function sceneClassGraph(classIndex) {
38
+ const cached = sceneClassGraphCache.get(classIndex);
39
+ if (cached) return cached;
40
+ const names = Object.keys(classIndex).filter((name) => String(name).trim());
41
+ const built = {
42
+ graph: parseEntities({
43
+ individuals: names.map((name) => ({ id: `sprite-class:${name}`, label: name, class: "Class" })),
44
+ objectProperties: [],
45
+ }),
46
+ longestClassWordCount: names.reduce((max, name) => Math.max(max, name.trim().split(/\s+/).length), 0),
47
+ };
48
+ sceneClassGraphCache.set(classIndex, built);
49
+ return built;
50
+ }
51
+
52
+ /** The one resolver tier the composer draws on. Composing paints a picture, and
53
+ * painting the wrong sprite is a silent lie the visitor can't audit, so only an
54
+ * exact-grade resolution (resolveObject's tier 1, which its own leading-article
55
+ * and grain-word retries reach too) earns a swatch. The containment and fuzzy
56
+ * tiers below it read "wood" as the food class and "glass" as grass — right for
57
+ * a question the engine answers in words and cites, wrong for a picture drawn
58
+ * without comment. */
59
+ const SCENE_EXACT_TIER = 1;
60
+
61
+ /** The catalog class named by the longest still-unclaimed token span starting at
62
+ * `start`, as `{className, wordCount}`, or null when the resolver grounds none
63
+ * of them. Spans are offered widest-first, so a multi-word class name ("body of
64
+ * water") always wins the position it starts at over a shorter class that would
65
+ * otherwise fragment it. An ambiguous resolution is a miss: a span that reads as
66
+ * several real classes names none of them. */
67
+ function resolveSpanToClass(graph, classIndex, tokens, used, start, longestClassWordCount) {
68
+ const widest = Math.min(longestClassWordCount, tokens.length - start);
69
+ for (let wordCount = widest; wordCount >= 1; wordCount -= 1) {
70
+ let free = true;
71
+ for (let k = 0; k < wordCount && free; k += 1) free = !used[start + k];
72
+ if (!free) continue;
73
+ const span = tokens.slice(start, start + wordCount).map((t) => t.word).join(" ");
74
+ const resolved = resolveObject(graph, span);
75
+ const label = resolved?.match?.label;
76
+ if (!label || resolved.ambiguous || resolved.tier !== SCENE_EXACT_TIER) continue;
77
+ if (Object.prototype.hasOwnProperty.call(classIndex, label)) return { className: label, wordCount };
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /** Every real catalog class the free-typed `text` names, in the order each first
83
+ * appears, paired with the real material label (one of that SAME class's own
84
+ * swatch labels — never another class's, never a fabricated one) immediately
85
+ * preceding it, or `null`. `classIndex` is `{className: {materials}}` with
86
+ * `materials` keyed by lowercase label (sprites.html's own client-side
87
+ * `buildClassIndexFromDom` output, or an equivalent test fixture) — a class name
88
+ * absent from `classIndex` can never match, and a modifier word that isn't one
89
+ * of ITS matched class's own material labels is silently dropped rather than
90
+ * guessed at, the same honest-miss posture an unrecognized class name gets (an
91
+ * unmatched word, e.g. "red" before a lamp with no red material, is never an
92
+ * error, just silently not drawn). Pure. */
93
+ export function extractSceneItems(text, classIndex) {
94
+ const index = classIndex || {};
95
+ const { graph, longestClassWordCount } = sceneClassGraph(index);
96
+ if (!longestClassWordCount) return [];
97
+ const tokens = tokenizeSceneText(text);
98
+ const used = new Array(tokens.length).fill(false);
99
+ const items = [];
100
+ for (let i = 0; i < tokens.length; i += 1) {
101
+ if (used[i]) continue;
102
+ const hit = resolveSpanToClass(graph, index, tokens, used, i, longestClassWordCount);
103
+ if (!hit) continue;
104
+ let materialLabel = null;
105
+ if (i > 0 && !used[i - 1]) {
106
+ const materials = index[hit.className]?.materials || {};
107
+ const prevWord = tokens[i - 1].word;
108
+ if (Object.prototype.hasOwnProperty.call(materials, prevWord)) {
109
+ materialLabel = prevWord;
110
+ used[i - 1] = true;
111
+ }
112
+ }
113
+ for (let k = 0; k < hit.wordCount; k += 1) used[i + k] = true;
114
+ items.push({ className: hit.className, materialLabel });
115
+ }
116
+ return items;
117
+ }
@@ -207,6 +207,42 @@ export function worldMetaRow() {
207
207
  return { world: WORLD_NAME, kind: "meta", opening: WORLD_OPENING };
208
208
  }
209
209
 
210
+ /** The class an agent id names — "spider-2" -> "spider", "fly-10" -> "fly",
211
+ * "egg-1" -> "egg" — the one regex every caller that needs an individual's
212
+ * kind from its id string shares. Self-contained (no outer refs),
213
+ * `.toString()`-splice safe. */
214
+ export function agentKindOf(id) {
215
+ return String(id).replace(/-\d+$/, "");
216
+ }
217
+
218
+ /** Every live id of `kind` among `agents`, sorted. `agents` is either a
219
+ * plain `{ id: ... }` roster (runSpiderFlyTick's/foldSpiderFlyState's own
220
+ * agents map, already excluding anything dead) or a `Map` keyed by id
221
+ * (foldSpiderFlyState's own `state.placements`, which still carries a dead
222
+ * individual's last-known cell) — pass the matching `state.removed` Set as
223
+ * `removed` in the Map case to exclude those; omit it for a plain roster
224
+ * that has no such concept. Pure. */
225
+ export function liveIdsOfKind(agents, kind, removed = null) {
226
+ const re = new RegExp(`^${kind}-\\d+$`);
227
+ const ids = agents instanceof Map ? [...agents.keys()] : Object.keys(agents || {});
228
+ return ids.filter((id) => re.test(id) && !(removed && removed.has(id))).sort();
229
+ }
230
+
231
+ /** True for a web individual's own id ("web-3") — a spider-built web is
232
+ * placed via mgx:currently-in exactly like a live agent, but it is never
233
+ * one. */
234
+ export function isWebIndividualId(id) {
235
+ return /^web-\d+$/.test(id);
236
+ }
237
+
238
+ /** Whether `id` belongs on a rendered agent roster built from `state`
239
+ * (foldSpiderFlyState's own shape): live (not in `state.removed`) and not a
240
+ * web individual. The one world rule a browser-side snapshot needs to skip
241
+ * the same two things every renderer of `state.placements` must skip. */
242
+ export function isLiveRenderableAgent(id, state) {
243
+ return !state.removed.has(id) && !isWebIndividualId(id);
244
+ }
245
+
210
246
  /** A minimal, inert rule-row family, so scripts/build-worlds-pack.mjs's
211
247
  * shared validator ("every world needs at least one rule row") passes.
212
248
  * src/services/spider-fly.mjs never reads these back: grid movement is
Binary file
@@ -0,0 +1,156 @@
1
+ // sprite-request.mjs — one sprite request ("the large sprite for a happy
2
+ // spider") resolved to markup PLUS the chain that found it. This is the pure
3
+ // core the tmct_sprite tool handler wraps and the spider-and-fly page splices,
4
+ // so the page and the tool answer the same question with the same code instead
5
+ // of two hand-kept call sites drifting apart.
6
+ //
7
+ // Every collaborator is INJECTED rather than imported — sprite-templates.mjs's
8
+ // resolveSpriteAsset, sprite-map.mjs's classAncestorChain, sprite-size.mjs's
9
+ // sizeScaleFor, sprite-expressions.mjs's EXPRESSION_PALETTE. Same reason
10
+ // spider-fly-viz.mjs's threadCellsForSpiderPlan takes its grid geometry as an
11
+ // argument: a function with no module-scope references survives `.toString()`
12
+ // splicing into a page script, and the browser already holds its own copies of
13
+ // those primitives on window.tmct.
14
+ //
15
+ // The resolution CHAIN is derived by OBSERVATION, never by re-implementing
16
+ // sprite-templates.mjs's specificity order. At each term of the ancestor chain
17
+ // the same resolver is asked to resolve that ONE term against an empty
18
+ // registry, so "did this level match" is answered by the real resolver rather
19
+ // than by a copy of its rules that can drift out of step with it. Whether a
20
+ // requested expression was actually honoured is observed the same way: resolve
21
+ // once with the mgx:feels fact and once without, and compare.
22
+ //
23
+ // The two optional slots carry two different senses:
24
+ // - `expression` becomes an `mgx:feels` fact, the parameter every
25
+ // *-with-emotion template selects on.
26
+ // - `size` becomes an `mgx:hasProperty` fact and resolves to a numeric render
27
+ // SCALE (sprite-size.mjs's own closed scale table). It is the taught size of
28
+ // the thing being drawn, not a choice of template tier — the caller picks
29
+ // the tier by which template set it hands in.
30
+ //
31
+ // Nothing here refuses. A caller that needs a miss wall (the tool does) reads
32
+ // `fellBackToRoot` / `expressionApplied` / `sizeKnown` / `expressionKnown` and
33
+ // decides; a caller that wants today's fall-through-to-the-root-sprite
34
+ // behaviour (the page does) just reads `svg`.
35
+
36
+ /**
37
+ * Resolve `request` (`{ class, expression?, size? }`) against a template set.
38
+ *
39
+ * `deps` carries the injected collaborators and the state to resolve against:
40
+ * `resolveSpriteAsset` (required), `templates`, `spriteRegistry`, `factRows`
41
+ * (the taxonomy rows the ancestor walk reads), `rootFallback`, `instanceKey`,
42
+ * and — for the reported chain and vocabulary checks — `classAncestorChain`,
43
+ * `sizeScaleFor` and `expressionPalette`. Omit any of the last three and the
44
+ * fields they feed come back `null` rather than guessed.
45
+ *
46
+ * Returns `{ class, expression, size, svg, scale, sizeKnown, expressionKnown,
47
+ * expressionApplied, rootFallback, fellBackToRoot, chain, matched }`. Pure.
48
+ */
49
+ export function resolveSpriteRequest(request, deps) {
50
+ const FEELS_PREDICATE = "mgx:feels";
51
+ const PROPERTY_PREDICATE = "mgx:hasProperty";
52
+
53
+ const className = String((request && request.class) || "").trim();
54
+ const expression = String((request && request.expression) || "").trim();
55
+ const size = String((request && request.size) || "").trim();
56
+
57
+ const options = deps || {};
58
+ const resolveAsset = options.resolveSpriteAsset;
59
+ if (typeof resolveAsset !== "function") {
60
+ throw new TypeError("resolveSpriteRequest needs deps.resolveSpriteAsset");
61
+ }
62
+ const templates = options.templates || [];
63
+ const registry = options.spriteRegistry || {};
64
+ const factRows = options.factRows || [];
65
+ const rootFallback = options.rootFallback || "animal";
66
+ const walkAncestors = options.classAncestorChain;
67
+ const scaleFor = options.sizeScaleFor;
68
+ const palette = options.expressionPalette;
69
+
70
+ const propertyFacts = [];
71
+ if (expression) propertyFacts.push({ predicate: FEELS_PREDICATE, object: expression });
72
+ if (size) propertyFacts.push({ predicate: PROPERTY_PREDICATE, object: size });
73
+
74
+ const assetOptions = { rootFallback };
75
+ if (options.instanceKey) assetOptions.instanceKey = options.instanceKey;
76
+ const svg = resolveAsset(className, factRows, propertyFacts, templates, registry, assetOptions);
77
+
78
+ // One resolve of a SINGLE term against an empty registry and its own root:
79
+ // the real resolver's answer to "does this level of the chain match", with no
80
+ // ancestor walk and no fall-through of its own.
81
+ const templateAt = (term) => resolveAsset(term, [], propertyFacts, templates, {}, { rootFallback: term });
82
+ const carriedByRegistry = (term) => Object.prototype.hasOwnProperty.call(registry, term);
83
+
84
+ // The template that produced `hit` at `term`: a fully-specific variant and a
85
+ // plain class template both hand their `svg` through untouched, so equality
86
+ // finds them; a parameterized template's substitutions change the string, and
87
+ // it is the only remaining candidate shape resolveAtTerm can have used.
88
+ const templateBehind = (term, hit) => {
89
+ const candidates = templates.filter((t) => t && Array.isArray(t.classes) && t.classes.indexOf(term) >= 0);
90
+ const authored = candidates.find((t) => t.svg === hit);
91
+ const template = authored || candidates.find((t) => !t.match && t.parameters) || null;
92
+ if (!template) return null;
93
+ return {
94
+ classes: template.classes.slice(),
95
+ parameters: Object.keys(template.parameters || {}).sort(),
96
+ match: template.match || null,
97
+ };
98
+ };
99
+
100
+ let chain = null;
101
+ let matched = null;
102
+ if (typeof walkAncestors === "function") {
103
+ chain = [];
104
+ for (const term of walkAncestors(className, factRows)) {
105
+ const hit = templateAt(term);
106
+ chain.push({ term, template: Boolean(hit), registry: carriedByRegistry(term) });
107
+ if (hit) {
108
+ matched = { term, via: "template", hops: chain.length - 1, root: false, template: templateBehind(term, hit) };
109
+ break;
110
+ }
111
+ if (carriedByRegistry(term)) {
112
+ matched = { term, via: "registry", hops: chain.length - 1, root: false, template: null };
113
+ break;
114
+ }
115
+ }
116
+ if (!matched) {
117
+ const rootHit = templateAt(rootFallback);
118
+ chain.push({ term: rootFallback, template: Boolean(rootHit), registry: carriedByRegistry(rootFallback), root: true });
119
+ matched = {
120
+ term: rootFallback,
121
+ via: rootHit ? "template" : "registry",
122
+ hops: chain.length - 1,
123
+ root: true,
124
+ template: rootHit ? templateBehind(rootFallback, rootHit) : null,
125
+ };
126
+ }
127
+ }
128
+
129
+ // Compared without the instance-id namespacing, which rewrites gradient ids
130
+ // and would make two otherwise identical resolutions look different.
131
+ const plainSvg = options.instanceKey
132
+ ? resolveAsset(className, factRows, propertyFacts, templates, registry, { rootFallback })
133
+ : svg;
134
+ const withoutExpression = expression
135
+ ? resolveAsset(className, factRows, propertyFacts.filter((f) => f.predicate !== FEELS_PREDICATE), templates, registry, { rootFallback })
136
+ : null;
137
+
138
+ return {
139
+ class: className,
140
+ expression: expression || null,
141
+ size: size || null,
142
+ svg,
143
+ scale: typeof scaleFor === "function" ? scaleFor(propertyFacts) : 1,
144
+ sizeKnown: size && typeof scaleFor === "function"
145
+ ? scaleFor([{ predicate: PROPERTY_PREDICATE, object: size }]) !== 1
146
+ : null,
147
+ expressionKnown: expression && palette
148
+ ? Object.prototype.hasOwnProperty.call(palette, expression)
149
+ : null,
150
+ expressionApplied: expression ? plainSvg !== withoutExpression : null,
151
+ rootFallback,
152
+ fellBackToRoot: matched ? Boolean(matched.root) : null,
153
+ chain,
154
+ matched,
155
+ };
156
+ }