@polycode-projects/the-mechanical-code-talker 4.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +178 -9
- package/corpus/reference/index.json.gz +0 -0
- package/corpus/reference/manifest.json +8 -8
- package/corpus/reference/shards/ref-00.jsonl.gz +0 -0
- package/corpus/sprites/src/sprite-facts.jsonl +401 -0
- package/corpus/worlds/index.json.gz +0 -0
- package/corpus/worlds/manifest.json +49 -9
- package/corpus/worlds/shards/greyvale-museum.jsonl.gz +0 -0
- package/corpus/worlds/shards/lantern-cottage.jsonl.gz +0 -0
- package/corpus/worlds/shards/mud-hollow.jsonl.gz +0 -0
- package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
- package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
- package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
- package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
- package/corpus/worlds/src/mud-hollow.jsonl +82 -0
- package/corpus/worlds/src/mud-warren.jsonl +124 -0
- package/corpus/worlds/src/spider-fly.jsonl +1 -1
- package/data/sprites/animal-icon.toml +11 -5
- package/data/sprites/book-icon.toml +9 -5
- package/data/sprites/cabinet-icon.toml +10 -5
- package/data/sprites/cellar-icon.toml +16 -6
- package/data/sprites/container-icon.toml +7 -3
- package/data/sprites/desk-icon.toml +8 -5
- package/data/sprites/dog-icon.toml +8 -5
- package/data/sprites/dog-with-colour-icon.toml +13 -12
- package/data/sprites/drawing-room-icon.toml +14 -5
- package/data/sprites/egg-icon.toml +6 -3
- package/data/sprites/fly-icon.toml +10 -5
- package/data/sprites/furniture-icon.toml +5 -3
- package/data/sprites/garden-icon.toml +10 -3
- package/data/sprites/key-icon.toml +3 -1
- package/data/sprites/kitchen-icon.toml +15 -6
- package/data/sprites/lamp-icon.toml +9 -4
- package/data/sprites/letter-icon.toml +7 -4
- package/data/sprites/library-icon.toml +14 -3
- package/data/sprites/pan-icon.toml +7 -3
- package/data/sprites/person-icon.toml +6 -2
- package/data/sprites/poodle-icon.toml +1 -1
- package/data/sprites/portable-icon.toml +6 -4
- package/data/sprites/portrait-icon.toml +7 -4
- package/data/sprites/room-icon.toml +11 -2
- package/data/sprites/spider-icon.toml +9 -3
- package/data/sprites/study-icon.toml +10 -2
- package/package.json +5 -4
- package/src/adapters/memory/core.mjs +20 -0
- package/src/domain/ask-vocab.mjs +71 -0
- package/src/domain/ask.mjs +168 -0
- package/src/domain/game-config.mjs +11 -0
- package/src/domain/grammar/ace.mjs +40 -4
- package/src/domain/grammar/lexicon-core.json +2 -1
- package/src/domain/grammar/lexicon.mjs +18 -0
- package/src/domain/mud-facts.mjs +15 -0
- package/src/domain/reference-pack.mjs +31 -7
- package/src/domain/router/drive.mjs +35 -9
- package/src/domain/router/registry.mjs +24 -4
- package/src/domain/router/resolver.mjs +102 -40
- package/src/domain/scene-compose.mjs +117 -0
- package/src/domain/spider-fly-world.mjs +37 -1
- package/src/domain/sprite-facts.mjs +0 -0
- package/src/domain/sprite-request.mjs +156 -0
- package/src/domain/sprite-templates.mjs +169 -20
- package/src/services/adventure-editor.mjs +8 -14
- package/src/services/adventure-viz.mjs +209 -157
- package/src/services/adventure.mjs +526 -391
- package/src/services/chat-page-viz.mjs +69 -25
- package/src/services/chat.mjs +200 -51
- package/src/services/code-explorer-viz.mjs +102 -62
- package/src/services/extract-facts.mjs +4 -7
- package/src/services/ingest-viz.mjs +68 -82
- package/src/services/ledger-viz.mjs +136 -67
- package/src/services/memory-panel-viz.mjs +62 -0
- package/src/services/mud-editor.mjs +10 -15
- package/src/services/mud-turn.mjs +6 -6
- package/src/services/mud-viz.mjs +1016 -208
- package/src/services/p2p-room.mjs +90 -23
- package/src/services/plan-pddl.mjs +3 -1
- package/src/services/plan-viz.mjs +123 -64
- package/src/services/research-viz.mjs +160 -108
- package/src/services/spider-fly-turn.mjs +15 -23
- package/src/services/spider-fly-viz.mjs +146 -161
- package/src/services/spider-fly.mjs +69 -11
- package/src/services/sprite-catalog-viz.mjs +414 -240
- package/src/services/viz-boot.mjs +71 -0
- package/src/services/viz-room-graph.mjs +203 -0
- package/src/services/viz-theme.mjs +90 -1
- package/src/services/viz-ticker.mjs +22 -0
- package/src/surfaces/web/adventure-browser-entry.mjs +49 -33
- package/src/surfaces/web/chat-browser-entry.mjs +30 -105
- package/src/surfaces/web/code-explorer-browser-entry.mjs +168 -24
- package/src/surfaces/web/ingest-browser-entry.mjs +3 -13
- package/src/surfaces/web/ledger-browser-entry.mjs +7 -47
- package/src/surfaces/web/memory-ask-browser.bundle.js +127 -124
- package/src/surfaces/web/memory-stats.mjs +11 -0
- package/src/surfaces/web/mud-browser-entry.mjs +71 -29
- package/src/surfaces/web/plan-browser-entry.mjs +22 -40
- package/src/surfaces/web/research-browser-entry.mjs +26 -41
- package/src/surfaces/web/spider-fly-browser-entry.mjs +45 -28
- package/src/surfaces/web/sprites-browser-entry.mjs +14 -27
- package/src/surfaces/web/turn-session.mjs +120 -0
- package/src/tools/definitions.mjs +30 -0
- package/src/tools/handlers/index.mjs +6 -3
- package/src/tools/handlers/kit.mjs +19 -2
- package/src/tools/handlers/tmct-ask.mjs +11 -6
- package/src/tools/handlers/tmct-ingest.mjs +5 -1
- package/src/tools/handlers/tmct-related.mjs +4 -4
- package/src/tools/handlers/tmct-sprite.mjs +147 -0
- package/src/tools/memory-fallthrough.mjs +9 -2
- package/src/tools/server.mjs +25 -1
|
@@ -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
|
+
}
|
|
@@ -122,7 +122,7 @@ export const SEED_TAXONOMY = Object.freeze([
|
|
|
122
122
|
]);
|
|
123
123
|
|
|
124
124
|
export const WORLD_OPENING =
|
|
125
|
-
"a spider waits in its web; a fly drifts in from the edge of the board. Neither is yours to move
|
|
125
|
+
"a spider waits in its web; a fly drifts in from the edge of the board. Neither is yours to move. Watch, or address one by name in chat.";
|
|
126
126
|
|
|
127
127
|
/** Every fact row the shipped world source carries: cell typing, grid
|
|
128
128
|
* adjacency (mgx:has-exit-<direction>), the web block (mgx:in-web) and the
|
|
@@ -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.tmctSpiderFly.
|
|
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
|
+
}
|
|
@@ -21,12 +21,41 @@
|
|
|
21
21
|
// substitution (e.g. `black = "#22201d"`). A value with no entry in that
|
|
22
22
|
// map is not a match for this template at all — it falls through to a
|
|
23
23
|
// less specific one, never a guessed/invented substitution.
|
|
24
|
-
// - a fully-specific hand-authored VARIANT — `
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
24
|
+
// - a fully-specific hand-authored VARIANT — carries the same `classes`
|
|
25
|
+
// as the class it specializes, plus a `[match]` table (`property`,
|
|
26
|
+
// `value`) naming the exact fact it requires, so it outranks the
|
|
27
|
+
// parameterized template when both would otherwise apply. The filename
|
|
28
|
+
// is free-form and reads `{class}-{what it shows}` in practice
|
|
29
|
+
// (`portrait-round.toml` for mgx:hasProperty = round,
|
|
30
|
+
// `bear-facing-left.toml` for mgx:faces = left) — the `[match]` table,
|
|
31
|
+
// never the name, is what selects it.
|
|
32
|
+
//
|
|
33
|
+
// A variant can require MORE THAN ONE fact at once, written as repeated
|
|
34
|
+
// `[[match]]` tables (the array-of-tables idiom data/templates/
|
|
35
|
+
// grammar-rules.toml's own `[[rule]]` already uses) instead of a single
|
|
36
|
+
// `[match]` one. Every entry must hold for the variant to be selected, so
|
|
37
|
+
// `bear-facing-left-moving.toml` asks for mgx:faces = left AND
|
|
38
|
+
// mgx:pose = moving and draws the left profile mid-stride. The two spellings
|
|
39
|
+
// are one constraint or many, never a different meaning: a lone `[match]`
|
|
40
|
+
// table is exactly a one-entry list, which is why every file authored before
|
|
41
|
+
// the plural spelling existed still resolves byte-for-byte as it did.
|
|
42
|
+
//
|
|
43
|
+
// Where two satisfied variants overlap, the one requiring MORE facts wins —
|
|
44
|
+
// an instance taught both the facing and the pose gets the combined art, and
|
|
45
|
+
// the same instance taught only the facing gets the plain profile, because
|
|
46
|
+
// the combined variant's second constraint no longer holds. Specificity is
|
|
47
|
+
// counted from the constraints, never read off the filename, so adding a
|
|
48
|
+
// file can't silently reorder what an existing instance resolves to.
|
|
49
|
+
//
|
|
50
|
+
// The two kinds compose rather than exclude each other: a VARIANT may also
|
|
51
|
+
// declare its own `[parameters.*]` tables, and once its `[match]` selects
|
|
52
|
+
// it those parameters fill from the instance's OTHER facts (the facing pair
|
|
53
|
+
// files each carry `[face]` + `[parameters.emotion]`, so a bear taught both
|
|
54
|
+
// mgx:faces = left and mgx:feels = happy renders the left profile wearing
|
|
55
|
+
// the happy face). A satisfied `[match]` is the instance naming this art
|
|
56
|
+
// directly, so a variant is never allowed to fall through the way an
|
|
57
|
+
// unfilled parameterized template is — any placeholder no fact filled is
|
|
58
|
+
// dropped instead, which keeps the returned markup complete.
|
|
30
59
|
//
|
|
31
60
|
// A parameterized template's `[parameters.<name>]` table comes in two
|
|
32
61
|
// shapes, picked by which of `placeholder`/`placeholders` it declares:
|
|
@@ -57,9 +86,10 @@
|
|
|
57
86
|
//
|
|
58
87
|
// Specificity order, checked at EACH term of the class's ancestor chain
|
|
59
88
|
// (nearest first, sprite-map.mjs's own classAncestorChain) before moving to
|
|
60
|
-
// the next ancestor:
|
|
61
|
-
//
|
|
62
|
-
//
|
|
89
|
+
// the next ancestor: the satisfied fully-specific variant requiring the most
|
|
90
|
+
// facts (filled from its own [parameters.*] if it declares any) > a
|
|
91
|
+
// parameterized template filled with an observed matching value > a plain
|
|
92
|
+
// class template > (repeat at the next ancestor) > the
|
|
63
93
|
// existing flat spriteRegistry entry for that same term (so a class not yet
|
|
64
94
|
// migrated to its own template keeps resolving exactly as it did before this
|
|
65
95
|
// module existed) > once the chain is exhausted, the same three-step check
|
|
@@ -68,14 +98,77 @@
|
|
|
68
98
|
import { classAncestorChain } from "./sprite-map.mjs";
|
|
69
99
|
import { namespaceSvgIds } from "./svg-instance-ids.mjs";
|
|
70
100
|
|
|
101
|
+
/** The predicate a variant matches on to pick a facing angle. */
|
|
102
|
+
export const FACING_PROPERTY = "mgx:faces";
|
|
103
|
+
|
|
104
|
+
/** The predicate a variant matches on to pick a pose. */
|
|
105
|
+
export const POSE_PROPERTY = "mgx:pose";
|
|
106
|
+
|
|
107
|
+
/** The facing angles a variant's [match] may require of mgx:faces — a
|
|
108
|
+
* turntable read as five steps, of which four are named. Centre is the
|
|
109
|
+
* ABSENT fact, never a word here: a figure with nothing on record about
|
|
110
|
+
* which way it faces already renders its own front-facing art, so spending
|
|
111
|
+
* a value on that would give one picture two names and let an instance ask
|
|
112
|
+
* for the front view two different ways. */
|
|
113
|
+
export const FACING_VALUES = Object.freeze(["left", "half-left", "half-right", "right"]);
|
|
114
|
+
|
|
115
|
+
/** The poses a variant's [match] may require of mgx:pose. At rest is the
|
|
116
|
+
* ABSENT fact, the same reasoning centre-facing gets above: a figure with no
|
|
117
|
+
* pose on record is standing still, and that is what every plain template
|
|
118
|
+
* already draws. "moving" is the one intermediate frame between two rests —
|
|
119
|
+
* the read a walk cycle needs and the only pose that has to exist before
|
|
120
|
+
* movement can be animated at all. */
|
|
121
|
+
export const POSE_VALUES = Object.freeze(["moving"]);
|
|
122
|
+
|
|
71
123
|
/** Every template in `templates` whose `classes` list names `term`. */
|
|
72
124
|
function templatesForClass(term, templates) {
|
|
73
125
|
return (templates || []).filter((t) => Array.isArray(t?.classes) && t.classes.includes(term));
|
|
74
126
|
}
|
|
75
127
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
128
|
+
/** One template's `[match]`/`[[match]]` tables as a flat list of raw entries,
|
|
129
|
+
* whichever of the two spellings it used — the shape every reader wants
|
|
130
|
+
* before it decides anything, so no caller re-answers "did I get a table or
|
|
131
|
+
* a list of them" for itself. */
|
|
132
|
+
function matchEntries(template) {
|
|
133
|
+
const match = template?.match;
|
|
134
|
+
if (!match) return [];
|
|
135
|
+
return Array.isArray(match) ? match : [match];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Every `{property, value}` fact one template's `[match]` requires, all of
|
|
140
|
+
* which must hold for the variant to apply. A single `[match]` table yields
|
|
141
|
+
* one constraint, repeated `[[match]]` tables one each, and a template with
|
|
142
|
+
* no `[match]` at all yields none. An entry missing either half is dropped
|
|
143
|
+
* rather than treated as a wildcard — a half-written constraint must never
|
|
144
|
+
* widen what a variant claims to match (spriteTemplateProblems reports it as
|
|
145
|
+
* the authoring mistake it is). Pure.
|
|
146
|
+
*/
|
|
147
|
+
export function matchConstraints(template) {
|
|
148
|
+
return matchEntries(template).filter((c) => c?.property && c.value !== undefined);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function matchSatisfied(constraints, propertyFacts) {
|
|
152
|
+
if (!constraints.length) return false;
|
|
153
|
+
return constraints.every((c) => (propertyFacts || []).some((f) => f.predicate === c.property && f.object === c.value));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The satisfied `[match]` variant among `candidates` requiring the MOST
|
|
157
|
+
* facts, or null when none is satisfied — so a combined facing-and-pose
|
|
158
|
+
* variant outranks the facing-only one it shares an angle with, and drops
|
|
159
|
+
* back to it the moment the instance stops carrying the pose. A tie keeps
|
|
160
|
+
* the earliest candidate, which is the load order the caller handed in. */
|
|
161
|
+
function bestSatisfiedVariant(candidates, propertyFacts) {
|
|
162
|
+
let best = null;
|
|
163
|
+
let bestCount = 0;
|
|
164
|
+
for (const t of candidates) {
|
|
165
|
+
const constraints = matchConstraints(t);
|
|
166
|
+
if (constraints.length <= bestCount) continue;
|
|
167
|
+
if (!matchSatisfied(constraints, propertyFacts)) continue;
|
|
168
|
+
best = t;
|
|
169
|
+
bestCount = constraints.length;
|
|
170
|
+
}
|
|
171
|
+
return best;
|
|
79
172
|
}
|
|
80
173
|
|
|
81
174
|
/** Substitute one matched `[parameters.*.values]` entry into `svg`: a plain
|
|
@@ -123,15 +216,47 @@ function parameterizedFillAll(template, propertyFacts) {
|
|
|
123
216
|
return filledCount > 0 ? svg : null;
|
|
124
217
|
}
|
|
125
218
|
|
|
219
|
+
/** Every placeholder token a template's own `[parameters.*]` tables name,
|
|
220
|
+
* across both shapes — the single `placeholder` and every token in a
|
|
221
|
+
* `placeholders` table. */
|
|
222
|
+
function declaredPlaceholderTokens(template) {
|
|
223
|
+
const tokens = [];
|
|
224
|
+
for (const param of Object.values(template.parameters || {})) {
|
|
225
|
+
if (param?.placeholder) tokens.push(param.placeholder);
|
|
226
|
+
for (const token of Object.values(param?.placeholders || {})) tokens.push(token);
|
|
227
|
+
}
|
|
228
|
+
return tokens;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** `svg` with every token `template` declares that no observed fact filled
|
|
232
|
+
* dropped outright. Only the [match]-selected path wants this: a satisfied
|
|
233
|
+
* [match] means the instance asked for THIS art by name, so the variant
|
|
234
|
+
* can't fall through to a less specific template the way an unfilled
|
|
235
|
+
* parameterized template does, and dropping the leftover token is what
|
|
236
|
+
* keeps the returned markup complete rather than shipping a literal
|
|
237
|
+
* "{{FACE}}" into the page. */
|
|
238
|
+
function withUnfilledPlaceholdersDropped(template, svg) {
|
|
239
|
+
let out = svg;
|
|
240
|
+
for (const token of declaredPlaceholderTokens(template)) out = out.split(token).join("");
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
126
244
|
/** Resolve ONE class term (no ancestor walk here — the caller repeats this
|
|
127
245
|
* at every level of the chain) against the template set, in specificity
|
|
128
246
|
* order: fully-specific match variant > parameterized template filled with
|
|
129
|
-
* an observed value > plain class template.
|
|
130
|
-
*
|
|
247
|
+
* an observed value > plain class template. Among satisfied match variants
|
|
248
|
+
* the most demanding one wins (bestSatisfiedVariant). A match variant that
|
|
249
|
+
* declares its own `[parameters.*]` is filled from them first, so a facing
|
|
250
|
+
* profile also carrying `[face]`/`[parameters.emotion]` renders the mood the
|
|
251
|
+
* instance's own mgx:feels fact names. Returns the SVG string, or null when
|
|
252
|
+
* nothing at this level matches. */
|
|
131
253
|
function resolveAtTerm(term, propertyFacts, templates) {
|
|
132
254
|
const candidates = templatesForClass(term, templates);
|
|
133
|
-
const matched = candidates
|
|
134
|
-
if (matched) return matched.svg;
|
|
255
|
+
const matched = bestSatisfiedVariant(candidates, propertyFacts);
|
|
256
|
+
if (matched && !matched.parameters) return matched.svg;
|
|
257
|
+
if (matched) {
|
|
258
|
+
return withUnfilledPlaceholdersDropped(matched, parameterizedFillAll(matched, propertyFacts) || matched.svg);
|
|
259
|
+
}
|
|
135
260
|
for (const t of candidates) {
|
|
136
261
|
if (t.match || !t.parameters) continue;
|
|
137
262
|
const filled = parameterizedFillAll(t, propertyFacts);
|
|
@@ -185,12 +310,22 @@ export function resolveSpriteAsset(className, factRows, propertyFacts, templates
|
|
|
185
310
|
* names a `property` and exactly one of `placeholder`/`placeholders` (every
|
|
186
311
|
* token named appears in `svg`), its `values` map is non-empty and every
|
|
187
312
|
* entry matches the shape its own `placeholder`/`placeholders` choice
|
|
188
|
-
* expects,
|
|
313
|
+
* expects, every `[match]`/`[[match]]` entry names both `property` and
|
|
314
|
+
* `value`, no two entries demand different values of the SAME property (a
|
|
315
|
+
* constraint set no instance can ever satisfy is dead art, not a stricter
|
|
316
|
+
* variant), a constraint on one of the two CLOSED axes names a value that
|
|
317
|
+
* axis actually has (mgx:faces one of FACING_VALUES, mgx:pose one of
|
|
318
|
+
* POSE_VALUES — every other predicate stays open, since a variant may match
|
|
319
|
+
* on any fact at all, mgx:hasProperty included), and
|
|
189
320
|
* `[face]`/`[parameters.emotion]` are always declared TOGETHER — a face
|
|
190
321
|
* anchor with nothing to select it, or an emotion parameter with nowhere to
|
|
191
322
|
* position its face fragment, is a real authoring mistake either way
|
|
192
323
|
* (sprite-expressions.mjs's own header explains why the face fragment
|
|
193
|
-
* needs the pairing).
|
|
324
|
+
* needs the pairing). Every check reads the tables a template actually
|
|
325
|
+
* declares and none of them cares whether a `[match]` sits alongside, so a
|
|
326
|
+
* facing variant carrying `[match]` + `[face]` + `[parameters.emotion]` is
|
|
327
|
+
* held to exactly the same pairing and placeholder rules as a plain
|
|
328
|
+
* `*-with-emotion.toml` file. */
|
|
194
329
|
export function spriteTemplateProblems(template) {
|
|
195
330
|
const problems = [];
|
|
196
331
|
const t = template || {};
|
|
@@ -231,8 +366,22 @@ export function spriteTemplateProblems(template) {
|
|
|
231
366
|
}
|
|
232
367
|
}
|
|
233
368
|
}
|
|
234
|
-
|
|
235
|
-
|
|
369
|
+
const entries = matchEntries(t);
|
|
370
|
+
for (const entry of entries) {
|
|
371
|
+
if (!entry?.property || entry?.value === undefined) problems.push("match is missing property or value");
|
|
372
|
+
}
|
|
373
|
+
const requiredBy = new Map();
|
|
374
|
+
for (const { property, value } of matchConstraints(t)) {
|
|
375
|
+
if (requiredBy.has(property) && requiredBy.get(property) !== value) {
|
|
376
|
+
problems.push(`match requires ${property} to be both "${requiredBy.get(property)}" and "${value}" — no instance can satisfy that`);
|
|
377
|
+
}
|
|
378
|
+
requiredBy.set(property, value);
|
|
379
|
+
if (property === FACING_PROPERTY && !FACING_VALUES.includes(value)) {
|
|
380
|
+
problems.push(`match requires ${FACING_PROPERTY} "${value}", which is not one of the turntable's angles (${FACING_VALUES.join(", ")}; the centre view is the absent fact)`);
|
|
381
|
+
}
|
|
382
|
+
if (property === POSE_PROPERTY && !POSE_VALUES.includes(value)) {
|
|
383
|
+
problems.push(`match requires ${POSE_PROPERTY} "${value}", which is not one of the known poses (${POSE_VALUES.join(", ")}; at rest is the absent fact)`);
|
|
384
|
+
}
|
|
236
385
|
}
|
|
237
386
|
if (t.face && !t.parameters?.emotion) {
|
|
238
387
|
problems.push("face is declared without parameters.emotion — a face anchor with nothing to select it is dead data");
|
|
@@ -14,10 +14,13 @@
|
|
|
14
14
|
// mgx:acts-toward, mgx:is-objective) — an editor has to show and change
|
|
15
15
|
// exactly the facts a player is never told.
|
|
16
16
|
//
|
|
17
|
-
// No imports: every export here is .toString()-splice-safe,
|
|
18
|
-
// discipline adventure-viz.mjs's own render-glue functions hold (see
|
|
19
|
-
// module's header) — this module's functions get spliced directly into
|
|
20
|
-
// adventure page's inline script the same way.
|
|
17
|
+
// No imports of its own logic: every export here is .toString()-splice-safe,
|
|
18
|
+
// the same discipline adventure-viz.mjs's own render-glue functions hold (see
|
|
19
|
+
// that module's header) — this module's functions get spliced directly into
|
|
20
|
+
// the adventure page's inline script the same way. The one exception,
|
|
21
|
+
// wordBeforeCursor, re-exports viz-theme.mjs's own shared copy (byte-identical
|
|
22
|
+
// to what used to live here, and to mud-editor.mjs's own copy) rather than
|
|
23
|
+
// keep a third copy of the same regex.
|
|
21
24
|
//
|
|
22
25
|
// Two predicate families get different sync strategies, on purpose:
|
|
23
26
|
// - PLACEMENT/OPENNESS (mgx:currently-in/located-in/fixed-in/stands-
|
|
@@ -357,13 +360,4 @@ export function planWorldEditorSync(rows, state, triples) {
|
|
|
357
360
|
|
|
358
361
|
// ---- cursor-driven suggestions ---------------------------------------------
|
|
359
362
|
|
|
360
|
-
|
|
361
|
-
* digits/hyphens, the same token shape this vocabulary's own terms use
|
|
362
|
-
* (kebab-case room names like "drawing-room"). Empty string when the
|
|
363
|
-
* cursor sits after whitespace/punctuation with no word directly behind
|
|
364
|
-
* it. Pure. */
|
|
365
|
-
export function wordBeforeCursor(text, cursorPos) {
|
|
366
|
-
const head = String(text || "").slice(0, cursorPos);
|
|
367
|
-
const m = head.match(/[A-Za-z][A-Za-z0-9-]*$/);
|
|
368
|
-
return m ? m[0].toLowerCase() : "";
|
|
369
|
-
}
|
|
363
|
+
export { wordBeforeCursor } from "./viz-theme.mjs";
|