@polycode-projects/the-mechanical-code-talker 2.8.0 → 2.8.3
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 +30 -16
- package/corpus/tier2/generate.mjs +1 -0
- package/corpus/tier2/human.jsonl +1 -0
- package/corpus/tier2/manifest.json +3 -3
- package/package.json +2 -1
- package/src/adapters/corpus/sprite-large-template-files.mjs +6 -2
- package/src/domain/game-config.mjs +10 -4
- package/src/domain/hanoi-lesson.mjs +53 -0
- package/src/domain/spider-fly-world.mjs +16 -0
- package/src/domain/sprite-expressions.mjs +120 -0
- package/src/domain/sprite-templates.mjs +31 -9
- package/src/services/adventure-editor.mjs +361 -0
- package/src/services/adventure-viz.mjs +422 -51
- package/src/services/chat-page-viz.mjs +398 -0
- package/src/services/plan-pddl.mjs +245 -0
- package/src/services/plan-viz.mjs +324 -67
- package/src/services/spider-fly-turn.mjs +120 -3
- package/src/services/spider-fly-viz.mjs +341 -22
- package/src/services/spider-fly.mjs +337 -143
- package/src/services/sprite-catalog-viz.mjs +395 -0
- package/src/surfaces/web/adventure-browser-entry.mjs +34 -3
- package/src/surfaces/web/memory-ask-browser.bundle.js +10 -4
- package/src/surfaces/web/plan-browser-entry.mjs +114 -0
- package/src/surfaces/web/spider-fly-browser-entry.mjs +33 -1
package/README.md
CHANGED
|
@@ -120,10 +120,11 @@ tmct> /exit
|
|
|
120
120
|
|
|
121
121
|
**[Try it live in your browser →](https://polycode-projects.gitlab.io/the-mechanical-code-talker/)**
|
|
122
122
|
is a real, interactive chat demo running client-side. Your browser runs the
|
|
123
|
-
actual query engine
|
|
124
|
-
|
|
125
|
-
drill by clicking the terms inside them,
|
|
126
|
-
|
|
123
|
+
actual query engine, no server, no install. The page opens with a live chat
|
|
124
|
+
you can talk to directly, then embeds the **memory ledger** (every fact as a
|
|
125
|
+
readable sentence, drill by clicking the terms inside them), a Towers-of-Hanoi
|
|
126
|
+
plan replayed move by move, and the two live games above — spider-fly and the
|
|
127
|
+
text adventure — each with a link to open it full-screen.
|
|
127
128
|
|
|
128
129
|
From a clone, two build scripts regenerate that demo so you can check it
|
|
129
130
|
offline before it deploys. The example graph, the ledger page, and the
|
|
@@ -183,7 +184,7 @@ resolves to a real graph traversal or declines cleanly:
|
|
|
183
184
|
(*because/although/while*), conditionals, and false-premise flags ("why
|
|
184
185
|
does X still import Y" when it no longer does).
|
|
185
186
|
|
|
186
|
-
The full catalog with measured coverage lives in `CAPABILITIES_2.
|
|
187
|
+
The full catalog with measured coverage lives in `CAPABILITIES_2.7.12.md` and
|
|
187
188
|
the `BENCHMARK_*.md` reports.
|
|
188
189
|
|
|
189
190
|
**Response finishing.** Before an answer prints, it is segmented into typed
|
|
@@ -371,7 +372,7 @@ the plan as a self-contained animated page (see "Two more surfaces" above).
|
|
|
371
372
|
|
|
372
373
|
## Play a game with it
|
|
373
374
|
|
|
374
|
-
|
|
375
|
+
Three games run inside an ordinary chat session, no setup.
|
|
375
376
|
|
|
376
377
|
**Guess the number.** Say `I'm thinking of a number between 1 and 100` and
|
|
377
378
|
tmct guesses by narrowing an interval — answer `higher`, `lower`, or
|
|
@@ -393,6 +394,19 @@ a blocked action declines by name, and one of the household moves on its own
|
|
|
393
394
|
schedule whether you are there to see it or not. The full worked mystery is
|
|
394
395
|
pinned step by step in `test/corpus/games/adventure.jsonl`.
|
|
395
396
|
|
|
397
|
+
**Two agents, planning against each other.** Say `play spider and fly` (or
|
|
398
|
+
`watch the spider and the fly`) and tmct runs both sides itself — neither is
|
|
399
|
+
player-controlled. A spider hunts a fly across a 10×10 web; each side only
|
|
400
|
+
believes what it can currently see (`vision_radius`, tunable), a fly wanders
|
|
401
|
+
when nothing threatens it and evades when something does, a spider avoids
|
|
402
|
+
other spiders, chases what it believes it sees, and builds a web when it
|
|
403
|
+
holds position. Mass is real: both sides waste away each turn they don't
|
|
404
|
+
eat, and a spider gains exactly the mass of what it catches. You can address
|
|
405
|
+
either side directly (`@spider the fly is east`) to feed it a belief — true
|
|
406
|
+
or false — and watch a wrong assertion mislead it for as long as the real
|
|
407
|
+
target stays out of sight. `tmct.toml`'s `[games.spider-fly]` table tunes
|
|
408
|
+
every rate; the full mechanic is pinned in `test/corpus/games/spider-fly.jsonl`.
|
|
409
|
+
|
|
396
410
|
## Learning on a miss
|
|
397
411
|
|
|
398
412
|
A question tmct cannot ground is still an honest miss — but on the cleanest
|
|
@@ -1035,18 +1049,18 @@ service. The LLM agent stays outside tmct, as the no-LLM ethos requires.
|
|
|
1035
1049
|
|
|
1036
1050
|
## Measuring it
|
|
1037
1051
|
|
|
1038
|
-
What the 2.
|
|
1039
|
-
and carries, in the same row, the caveat that changes what it means.
|
|
1040
|
-
tables, judge scores, and transcripts are in the linked write-ups.
|
|
1052
|
+
What the 2.7.11/2.7.12 cycle measured, on 2026-07-19. Each figure links to its
|
|
1053
|
+
method and carries, in the same row, the caveat that changes what it means.
|
|
1054
|
+
The full tables, judge scores, and transcripts are in the linked write-ups.
|
|
1041
1055
|
|
|
1042
|
-
| What it does | Result (2.
|
|
1056
|
+
| What it does | Result (2.7.12) | Read the number with this | Method |
|
|
1043
1057
|
|---|---|---|---|
|
|
1044
|
-
| Multi-hop entailment |
|
|
1045
|
-
| Tool-call planning |
|
|
1046
|
-
| Groundedness | Every answer carries a source, and an empty graph reports itself empty
|
|
1047
|
-
| Abstention (the honest miss) | 0% fabrication across
|
|
1048
|
-
| Determinism | Byte-identical on rerun
|
|
1049
|
-
| Dialogue robustness (
|
|
1058
|
+
| Multi-hop entailment | 379/379 chat cases and 100/100 kernel cases, 0% fabrication, all bands pass | The case set is unchanged from 2.6.0 (same templates, same counts) — the one real move this cycle is INF-4's ceiling-graded count dropping 35→30, five cases that now pass as genuine capability instead of against the declared honest-miss floor. | `BENCHMARK_INFERENCE_2.7.12.md` |
|
|
1059
|
+
| Tool-call planning | 68/68 cases, 100% plan-completion, 100% result-completion, 0% hallucination, every rung A0→C2 | Goal driver. 2.6.0 gated at TOOL-7 (62/66, 94%) — this cycle's router uplift (a guarded RECOVER step, a tied-candidate composer) cleared it, a real capability move, not a ruler change. | `BENCHMARK_AGENT_2.7.12.md` |
|
|
1060
|
+
| Groundedness | Every answer carries a source, and an empty graph reports itself empty. Judge-scored mean 1.809/2 over 138 cases, 5 hard fails, 136/138 tier-1. | Judged (`claude-haiku-4-5-20251001`, `judge-prompt-v2`) at N=1. The judge prompt moved v1→v2 since 2.6.0, so this is a measurement, not a clean lever comparison against the prior cycle. The judge runs in the offline eval harness, never in the product. | `BENCHMARK_CEFR_ENGLISH_2.7.12.md` |
|
|
1061
|
+
| Abstention (the honest miss) | 0% fabrication across 479 inference rows (379 chat + 100 kernel) and 0% hallucination across 272 agent rows | Structural, not a tuned threshold. tmct abstains because nothing matched, so the rows test a property of a no-model design rather than a score. | `BENCHMARK_INFERENCE_2.7.12.md`, `BENCHMARK_AGENT_2.7.12.md` |
|
|
1062
|
+
| Determinism | Byte-identical on rerun — a 379-case `--replay` clean across 2 runs, no LLM, no network, $0 per turn | A property of the no-model pipeline. | `BENCHMARK_INFERENCE_2.7.12.md` |
|
|
1063
|
+
| Dialogue robustness (persona sweep) | A 6-persona sweep (textbook logician, casual newcomer, new developer, adversarial sceptic, returning user, planning user) fixed 25 of the prior cycle's 29 routed findings (21 clean, 4 with a residual noted); 4 remain broken, 2 in a shape distinct from the original complaint | Free exploration across all six personas surfaced roughly 60 fresh findings beyond the ratchet check — the single highest-signal pattern: tmct's own suggested repair text was itself frequently broken when followed verbatim (since fixed, see `HANDOVER.md`). | `BENCHMARK_CONVERSATION_2.7.11.md` |
|
|
1050
1064
|
|
|
1051
1065
|
Three offline benchmark rigs live in a clone (they are not in the npm
|
|
1052
1066
|
package). Each replays a committed case set through the real product and
|
|
@@ -665,6 +665,7 @@ export const CORPUSES = {
|
|
|
665
665
|
["hope", "/r/IsA", "emotion"], ["hope", "/r/CapableOf", "inspire_action"],
|
|
666
666
|
["surprise", "/r/IsA", "emotion"], ["surprise", "/r/CapableOf", "cause_astonishment"],
|
|
667
667
|
["pride", "/r/IsA", "emotion"], ["pride", "/r/CapableOf", "bring_satisfaction"],
|
|
668
|
+
["sad", "/r/IsA", "emotion"],
|
|
668
669
|
["name", "/r/UsedFor", "identifying_a_person"], ["name", "/r/PartOf", "identity"],
|
|
669
670
|
["song", "/r/HasA", "word"], ["song", "/r/UsedFor", "entertaining"],
|
|
670
671
|
["music", "/r/HasA", "song"], ["music", "/r/CapableOf", "bring_joy"],
|
package/corpus/tier2/human.jsonl
CHANGED
|
@@ -622,6 +622,7 @@
|
|
|
622
622
|
{"start":"/c/en/surprise","rel":"/r/CapableOf","end":"/c/en/cause_astonishment","weight":1,"surfaceText":"[[surprise]] CapableOf [[cause astonishment]]"}
|
|
623
623
|
{"start":"/c/en/pride","rel":"/r/IsA","end":"/c/en/emotion","weight":1,"surfaceText":"[[pride]] IsA [[emotion]]"}
|
|
624
624
|
{"start":"/c/en/pride","rel":"/r/CapableOf","end":"/c/en/bring_satisfaction","weight":1,"surfaceText":"[[pride]] CapableOf [[bring satisfaction]]"}
|
|
625
|
+
{"start":"/c/en/sad","rel":"/r/IsA","end":"/c/en/emotion","weight":1,"surfaceText":"[[sad]] IsA [[emotion]]"}
|
|
625
626
|
{"start":"/c/en/name","rel":"/r/UsedFor","end":"/c/en/identifying_a_person","weight":1,"surfaceText":"[[name]] UsedFor [[identifying a person]]"}
|
|
626
627
|
{"start":"/c/en/name","rel":"/r/PartOf","end":"/c/en/identity","weight":1,"surfaceText":"[[name]] PartOf [[identity]]"}
|
|
627
628
|
{"start":"/c/en/song","rel":"/r/HasA","end":"/c/en/word","weight":1,"surfaceText":"[[song]] HasA [[word]]"}
|
|
@@ -67,9 +67,9 @@
|
|
|
67
67
|
"tool": "corpus/tier2/generate.mjs"
|
|
68
68
|
},
|
|
69
69
|
"file": "human.jsonl",
|
|
70
|
-
"facts":
|
|
71
|
-
"bytes":
|
|
72
|
-
"sha256": "
|
|
70
|
+
"facts": 665,
|
|
71
|
+
"bytes": 81540,
|
|
72
|
+
"sha256": "7710c88c4c96a73fbbae19fe1989b0be40fed5ed40205016e90cc221bcb79607",
|
|
73
73
|
"license": "MPL-2.0"
|
|
74
74
|
},
|
|
75
75
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
|
|
@@ -137,6 +137,7 @@
|
|
|
137
137
|
"build:ask-bundle": "node scripts/build-ask-bundle.mjs",
|
|
138
138
|
"build:chat-bundle": "node scripts/build-chat-bundle.mjs",
|
|
139
139
|
"build:spider-fly-bundle": "node scripts/build-spider-fly-bundle.mjs",
|
|
140
|
+
"build:plan-bundle": "node scripts/build-plan-bundle.mjs",
|
|
140
141
|
"build:chat-seed": "node scripts/build-chat-seed.mjs",
|
|
141
142
|
"build:demo-graph": "node scripts/build-demo-graph.mjs",
|
|
142
143
|
"build:demo-pack": "node scripts/build-demo-pack.mjs",
|
|
@@ -23,6 +23,7 @@ import { fileURLToPath } from "node:url";
|
|
|
23
23
|
import { join, dirname } from "node:path";
|
|
24
24
|
import { parse as parseToml } from "smol-toml";
|
|
25
25
|
import { expandMaterialReferences } from "../../domain/sprite-materials.mjs";
|
|
26
|
+
import { expandExpressionReferences } from "../../domain/sprite-expressions.mjs";
|
|
26
27
|
|
|
27
28
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
28
29
|
/** The sprite-tier template directory (data, not code) — every *.toml file
|
|
@@ -41,7 +42,10 @@ export const SPRITE_LARGE_TEMPLATES_DIR = join(HERE, "..", "..", "..", "data", "
|
|
|
41
42
|
* never go blank because one hand-authored sprite has a typo.
|
|
42
43
|
* test/adapters/sprite-large-template-files.test.mjs parses the real files
|
|
43
44
|
* directly (not through this lenient loader) so a broken file still fails a
|
|
44
|
-
* test loudly.
|
|
45
|
+
* test loudly. Material references expand first, expression references
|
|
46
|
+
* second — the two indirections are independent (a template can carry
|
|
47
|
+
* either, both, or neither) so the order between them never matters to the
|
|
48
|
+
* result, only that both run before a template reaches a caller. */
|
|
45
49
|
export function readSpriteLargeTemplateFiles(dir = SPRITE_LARGE_TEMPLATES_DIR) {
|
|
46
50
|
let files;
|
|
47
51
|
try {
|
|
@@ -58,5 +62,5 @@ export function readSpriteLargeTemplateFiles(dir = SPRITE_LARGE_TEMPLATES_DIR) {
|
|
|
58
62
|
continue; // one malformed file never takes the others down
|
|
59
63
|
}
|
|
60
64
|
}
|
|
61
|
-
return expandMaterialReferences(templates);
|
|
65
|
+
return expandExpressionReferences(expandMaterialReferences(templates));
|
|
62
66
|
}
|
|
@@ -18,10 +18,13 @@ export const DEFAULT_GAME_CONFIG = Object.freeze({
|
|
|
18
18
|
spiderMassDecrementPerTurn: 0.5,
|
|
19
19
|
flyInitialMass: 10,
|
|
20
20
|
flyMassDecrementPerTurn: 1,
|
|
21
|
-
|
|
21
|
+
spiderVisionRadius: 4,
|
|
22
|
+
flyVisionRadius: 4,
|
|
22
23
|
eggHatchDelayTurns: 3,
|
|
23
24
|
flySpawnIntervalTurns: 3,
|
|
24
|
-
|
|
25
|
+
eggLayMassThreshold: 25,
|
|
26
|
+
eggHatchCount: 2,
|
|
27
|
+
minHatchlingMass: 3,
|
|
25
28
|
webDurationTurns: 10,
|
|
26
29
|
}),
|
|
27
30
|
guessNumber: Object.freeze({
|
|
@@ -42,10 +45,13 @@ const SPIDER_FLY_KEY_MAP = Object.freeze({
|
|
|
42
45
|
spider_mass_decrement_per_turn: "spiderMassDecrementPerTurn",
|
|
43
46
|
fly_initial_mass: "flyInitialMass",
|
|
44
47
|
fly_mass_decrement_per_turn: "flyMassDecrementPerTurn",
|
|
45
|
-
|
|
48
|
+
spider_vision_radius: "spiderVisionRadius",
|
|
49
|
+
fly_vision_radius: "flyVisionRadius",
|
|
46
50
|
egg_hatch_delay_turns: "eggHatchDelayTurns",
|
|
47
51
|
fly_spawn_interval_turns: "flySpawnIntervalTurns",
|
|
48
|
-
|
|
52
|
+
egg_lay_mass_threshold: "eggLayMassThreshold",
|
|
53
|
+
egg_hatch_count: "eggHatchCount",
|
|
54
|
+
min_hatchling_mass: "minHatchlingMass",
|
|
49
55
|
web_duration_turns: "webDurationTurns",
|
|
50
56
|
});
|
|
51
57
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// hanoi-lesson.mjs — the one pure generator behind the taught towers-of-hanoi
|
|
2
|
+
// lesson: data/games/hanoi-3.txt's own content, generalized from a fixed
|
|
3
|
+
// 3-disk puzzle to any disk count. Pure, no imports — both
|
|
4
|
+
// scripts/build-demo-site.mjs (the static initial embed) and
|
|
5
|
+
// src/surfaces/web/plan-browser-entry.mjs (the live re-solve a visitor's
|
|
6
|
+
// disk-count control triggers) read the SAME sentence sequence from here, so
|
|
7
|
+
// neither can drift from data/games/hanoi-3.txt's own taught shape.
|
|
8
|
+
//
|
|
9
|
+
// Every sentence is emitted the way `tmct import --file` actually teaches
|
|
10
|
+
// one — split down to ONE fact per sentence, the exact granularity
|
|
11
|
+
// import-file.mjs's own splitSentencesPreservingPaths would produce from the
|
|
12
|
+
// committed file's body — not grouped multi-sentence lines, so each array
|
|
13
|
+
// entry maps 1:1 onto a single runTurn() call.
|
|
14
|
+
//
|
|
15
|
+
// Always 3 pegs (peg-a/b/c); only the disk count varies. The pairwise
|
|
16
|
+
// "smaller than" facts are EVERY pair, not just adjacent ones — the taught
|
|
17
|
+
// relation is stored as given and never chased transitively (see the
|
|
18
|
+
// "scale" variation documented in hanoi-3.txt itself), so a partial pairing
|
|
19
|
+
// would leave some disks with no legal move.
|
|
20
|
+
|
|
21
|
+
/** The taught lesson for an N-disk puzzle: the class/individual/ordering
|
|
22
|
+
* facts, the action rule, the render hints, the starting stack (largest at
|
|
23
|
+
* the bottom of peg-a, smallest on top), and the goal + solve trigger —
|
|
24
|
+
* one sentence per array entry, in teaching order. `diskCount` is floored
|
|
25
|
+
* and clamped to at least 1. */
|
|
26
|
+
export function hanoiLessonSentences(diskCount = 3, { goalPeg = "peg-c" } = {}) {
|
|
27
|
+
const n = Math.max(1, Math.floor(Number(diskCount) || 1));
|
|
28
|
+
const disks = Array.from({ length: n }, (_, i) => `disk-${i + 1}`);
|
|
29
|
+
const pegs = ["peg-a", "peg-b", "peg-c"];
|
|
30
|
+
const sentences = [];
|
|
31
|
+
|
|
32
|
+
sentences.push("a disk is a kind of game piece.");
|
|
33
|
+
sentences.push("a peg is a kind of place.");
|
|
34
|
+
for (const d of disks) sentences.push(`${d} is a disk.`);
|
|
35
|
+
for (const p of pegs) sentences.push(`${p} is a peg.`);
|
|
36
|
+
for (let i = 0; i < n; i += 1) {
|
|
37
|
+
for (let j = i + 1; j < n; j += 1) sentences.push(`${disks[i]} is smaller than ${disks[j]}.`);
|
|
38
|
+
}
|
|
39
|
+
sentences.push("you can move a disk onto a peg.");
|
|
40
|
+
sentences.push("you can move a disk onto a disk.");
|
|
41
|
+
sentences.push("to move a disk onto a target, nothing may rest on the disk.");
|
|
42
|
+
sentences.push("to move a disk onto a target, nothing may rest on the target.");
|
|
43
|
+
sentences.push("to move a disk onto a disk, the disk must be smaller than the target.");
|
|
44
|
+
sentences.push("moving a disk onto a target makes the disk rest on the target.");
|
|
45
|
+
sentences.push("a disk renders as a block.");
|
|
46
|
+
sentences.push("a peg renders as a slot.");
|
|
47
|
+
for (let i = 0; i < n - 1; i += 1) sentences.push(`${disks[i]} rests on ${disks[i + 1]}.`);
|
|
48
|
+
sentences.push(`${disks[n - 1]} rests on peg-a.`);
|
|
49
|
+
sentences.push(`the goal is that every disk rests on ${goalPeg}.`);
|
|
50
|
+
sentences.push("solve it.");
|
|
51
|
+
|
|
52
|
+
return sentences;
|
|
53
|
+
}
|
|
@@ -86,6 +86,22 @@ export const DIRECTION_DELTA = Object.freeze({
|
|
|
86
86
|
west: Object.freeze({ dx: -1, dy: 0 }),
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
/** The single compass direction from `fromCell` to `toCell` when `toCell`
|
|
90
|
+
* sits EXACTLY one cardinal step away (DIRECTION_DELTA) — null for the same
|
|
91
|
+
* cell, a diagonal, or any multi-step gap, so a caller never overstates
|
|
92
|
+
* "adjacent". The one shared primitive both the engine's own plan-driven
|
|
93
|
+
* facing (spider-fly.mjs) and the chat dock's deception pills
|
|
94
|
+
* (spider-fly-turn.mjs's pillsForSpiderFly) need — defined once here so
|
|
95
|
+
* neither has to re-derive it, and so the engine layer never has to import
|
|
96
|
+
* the chat-turn layer to get it (spider-fly-turn.mjs already imports
|
|
97
|
+
* spider-fly.mjs; the reverse would cycle). */
|
|
98
|
+
export function oneStepDirectionBetween(fromCell, toCell) {
|
|
99
|
+
for (const [direction, { dx, dy }] of Object.entries(DIRECTION_DELTA)) {
|
|
100
|
+
if (fromCell.x + dx === toCell.x && fromCell.y + dy === toCell.y) return direction;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
89
105
|
/** The world's seed taxonomy (PLAN_SPIDER_FLY.md §7): enough for the
|
|
90
106
|
* ontology-to-sprite worked example (a poodle sprite, a sheepdog falling
|
|
91
107
|
* back to the generic dog sprite) to run on the default persona, no
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// sprite-expressions.mjs — the shared face-fragment scheme the sprite tier's
|
|
2
|
+
// mgx:feels parameter draws from, one small dot/curve-eyes-plus-mouth
|
|
3
|
+
// drawing per curated emotion word, mirroring sprite-materials.mjs's own
|
|
4
|
+
// by-name-reference shape exactly: a frozen palette of raw treatment data,
|
|
5
|
+
// and a pure expand function that turns a short by-name reference in a
|
|
6
|
+
// template's own [parameters.emotion.values] table into the full
|
|
7
|
+
// substitution — so a face fragment is authored ONCE here and referenced by
|
|
8
|
+
// name from every data/sprites-large/*-with-emotion.toml file, rather than
|
|
9
|
+
// six-plus files each hand-copying the same eyes-and-mouth paths.
|
|
10
|
+
//
|
|
11
|
+
// The one real difference from a material treatment: a hex triple needs no
|
|
12
|
+
// context to drop into a template's placeholder, but a face fragment does —
|
|
13
|
+
// classes vary enough in head geometry (a dog's head circle is
|
|
14
|
+
// cx=7 cy=9 r=3.6, a cat's is cx=9 cy=9.4 r=4.4, a person's is
|
|
15
|
+
// cx=12 cy=6.6 r=3.3) that one universal anchor position would misplace the
|
|
16
|
+
// eyes on at least one of them. So EXPRESSION_PALETTE's own fragments are
|
|
17
|
+
// authored in a UNIT face — eyes and mouth drawn as if the head were a
|
|
18
|
+
// radius-1 circle centred on the origin — and expandExpressionReferences
|
|
19
|
+
// wraps the matched fragment in `<g transform="translate({cx} {cy})
|
|
20
|
+
// scale({scale})">` using the SAME template's own required `[face]` table
|
|
21
|
+
// (cx/cy/scale) to place and size it for that one class's real head. A
|
|
22
|
+
// template with a [parameters.emotion] table but no [face] table is left
|
|
23
|
+
// unexpanded (spriteTemplateProblems flags the pairing as a real problem —
|
|
24
|
+
// see sprite-templates.mjs — this function itself stays defensive, never
|
|
25
|
+
// throws on a malformed template).
|
|
26
|
+
//
|
|
27
|
+
// Every fragment uses a fixed dark ink colour rather than currentColor —
|
|
28
|
+
// a face needs to read against ANY body fill (a gold lamp's flame stays a
|
|
29
|
+
// fixed amber for the same reason: what the face/flame looks like doesn't
|
|
30
|
+
// come from what the body is made of or coloured).
|
|
31
|
+
//
|
|
32
|
+
// Convention for every future `*-with-emotion.toml` file (content-authoring
|
|
33
|
+
// agents: read this before copying the shape) — `{{FACE}}` is always the
|
|
34
|
+
// LAST child before `</svg>`, so the resolved face fragment paints over
|
|
35
|
+
// the body/highlight shapes beneath it, never the reverse.
|
|
36
|
+
|
|
37
|
+
/** The dark ink colour every face fragment draws its eyes/mouth in,
|
|
38
|
+
* regardless of the sprite's own body fill — the same fixed-colour
|
|
39
|
+
* reasoning lamp.toml's flame already uses (sprite-templates.mjs's own
|
|
40
|
+
* header explains the precedent). */
|
|
41
|
+
const FACE_INK = "#000000";
|
|
42
|
+
|
|
43
|
+
/** One `<g>`-ready fragment per curated emotion word, dot/curve eyes plus a
|
|
44
|
+
* mouth curve, authored in a unit face (radius 1, centred on the origin) —
|
|
45
|
+
* expandExpressionReferences positions and scales it per class. Distinct
|
|
46
|
+
* by more than mouth curvature alone: scared/surprised both open wide, but
|
|
47
|
+
* scared's mouth stays a small tense "o" while surprised's is a large
|
|
48
|
+
* dropped-jaw oval; calm's eyes are closed contented arcs where happy's
|
|
49
|
+
* are open dots, so no two of the six collapse into the same read at a
|
|
50
|
+
* glance. */
|
|
51
|
+
export const EXPRESSION_PALETTE = Object.freeze({
|
|
52
|
+
happy:
|
|
53
|
+
`<circle cx="-0.4" cy="-0.15" r="0.11" fill="${FACE_INK}" opacity="0.85"/>`
|
|
54
|
+
+ `<circle cx="0.4" cy="-0.15" r="0.11" fill="${FACE_INK}" opacity="0.85"/>`
|
|
55
|
+
+ `<path d="M -0.4 0.35 Q 0 0.68 0.4 0.35" fill="none" stroke="${FACE_INK}" stroke-width="0.09" stroke-linecap="round" opacity="0.85"/>`,
|
|
56
|
+
sad:
|
|
57
|
+
`<circle cx="-0.4" cy="-0.1" r="0.1" fill="${FACE_INK}" opacity="0.85"/>`
|
|
58
|
+
+ `<circle cx="0.4" cy="-0.1" r="0.1" fill="${FACE_INK}" opacity="0.85"/>`
|
|
59
|
+
+ `<path d="M -0.38 0.55 Q 0 0.3 0.38 0.55" fill="none" stroke="${FACE_INK}" stroke-width="0.09" stroke-linecap="round" opacity="0.85"/>`,
|
|
60
|
+
angry:
|
|
61
|
+
`<path d="M -0.58 -0.32 L -0.22 -0.16" stroke="${FACE_INK}" stroke-width="0.09" stroke-linecap="round" opacity="0.85"/>`
|
|
62
|
+
+ `<path d="M 0.58 -0.32 L 0.22 -0.16" stroke="${FACE_INK}" stroke-width="0.09" stroke-linecap="round" opacity="0.85"/>`
|
|
63
|
+
+ `<circle cx="-0.32" cy="-0.02" r="0.09" fill="${FACE_INK}" opacity="0.85"/>`
|
|
64
|
+
+ `<circle cx="0.32" cy="-0.02" r="0.09" fill="${FACE_INK}" opacity="0.85"/>`
|
|
65
|
+
+ `<path d="M -0.36 0.5 L 0.36 0.42" stroke="${FACE_INK}" stroke-width="0.1" stroke-linecap="round" opacity="0.85"/>`,
|
|
66
|
+
scared:
|
|
67
|
+
`<circle cx="-0.4" cy="-0.12" r="0.2" fill="none" stroke="${FACE_INK}" stroke-width="0.07" opacity="0.85"/>`
|
|
68
|
+
+ `<circle cx="-0.4" cy="-0.12" r="0.07" fill="${FACE_INK}" opacity="0.85"/>`
|
|
69
|
+
+ `<circle cx="0.4" cy="-0.12" r="0.2" fill="none" stroke="${FACE_INK}" stroke-width="0.07" opacity="0.85"/>`
|
|
70
|
+
+ `<circle cx="0.4" cy="-0.12" r="0.07" fill="${FACE_INK}" opacity="0.85"/>`
|
|
71
|
+
+ `<circle cx="0" cy="0.42" r="0.09" fill="none" stroke="${FACE_INK}" stroke-width="0.07" opacity="0.85"/>`,
|
|
72
|
+
surprised:
|
|
73
|
+
`<circle cx="-0.38" cy="-0.12" r="0.17" fill="none" stroke="${FACE_INK}" stroke-width="0.07" opacity="0.85"/>`
|
|
74
|
+
+ `<circle cx="-0.38" cy="-0.12" r="0.06" fill="${FACE_INK}" opacity="0.85"/>`
|
|
75
|
+
+ `<circle cx="0.38" cy="-0.12" r="0.17" fill="none" stroke="${FACE_INK}" stroke-width="0.07" opacity="0.85"/>`
|
|
76
|
+
+ `<circle cx="0.38" cy="-0.12" r="0.06" fill="${FACE_INK}" opacity="0.85"/>`
|
|
77
|
+
+ `<ellipse cx="0" cy="0.42" rx="0.16" ry="0.22" fill="${FACE_INK}" opacity="0.7"/>`,
|
|
78
|
+
calm:
|
|
79
|
+
`<path d="M -0.5 -0.1 Q -0.4 -0.2 -0.3 -0.1" fill="none" stroke="${FACE_INK}" stroke-width="0.08" stroke-linecap="round" opacity="0.85"/>`
|
|
80
|
+
+ `<path d="M 0.3 -0.1 Q 0.4 -0.2 0.5 -0.1" fill="none" stroke="${FACE_INK}" stroke-width="0.08" stroke-linecap="round" opacity="0.85"/>`
|
|
81
|
+
+ `<path d="M -0.3 0.4 Q 0 0.5 0.3 0.4" fill="none" stroke="${FACE_INK}" stroke-width="0.08" stroke-linecap="round" opacity="0.85"/>`,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/** The mgx:feels property name every emotion-bearing template's
|
|
85
|
+
* `[parameters.emotion]` table names — sprite-templates.mjs never needs
|
|
86
|
+
* to know this constant exists (it reads `param.property` generically),
|
|
87
|
+
* this module only uses it to recognise which parameter table is ITS OWN
|
|
88
|
+
* to expand, the same role sprite-materials.mjs's `param.placeholders`
|
|
89
|
+
* shape check plays for a material table. */
|
|
90
|
+
const FEELS_PROPERTY = "mgx:feels";
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Every `[parameters.emotion].values` string entry, in every template,
|
|
94
|
+
* expanded from a curated emotion-word KEY (e.g. "happy") to the full
|
|
95
|
+
* `<g transform="translate(cx cy) scale(scale)">…</g>` substitution for
|
|
96
|
+
* THIS template's own required `[face]` table — but only for a template
|
|
97
|
+
* whose `[parameters.emotion]` table names `mgx:feels` as its property
|
|
98
|
+
* (sprite-templates.mjs's `param.property`) and that also carries a
|
|
99
|
+
* `[face]` table; a template missing either is left alone (defensive, never
|
|
100
|
+
* throws — spriteTemplateProblems is what flags that pairing as a real
|
|
101
|
+
* authoring mistake). A values entry naming no known palette word is left
|
|
102
|
+
* as the plain string it was, the same never-guess posture
|
|
103
|
+
* expandMaterialReferences already uses for an unknown treatment name.
|
|
104
|
+
* Pure; `templates` is read only.
|
|
105
|
+
*/
|
|
106
|
+
export function expandExpressionReferences(templates, palette = EXPRESSION_PALETTE) {
|
|
107
|
+
return (templates || []).map((t) => {
|
|
108
|
+
const emotion = t?.parameters?.emotion;
|
|
109
|
+
if (!emotion?.property || emotion.property !== FEELS_PROPERTY) return t;
|
|
110
|
+
if (!emotion.placeholder || !emotion.values || !t.face) return t;
|
|
111
|
+
const { cx, cy, scale } = t.face;
|
|
112
|
+
const values = {};
|
|
113
|
+
for (const [key, value] of Object.entries(emotion.values)) {
|
|
114
|
+
values[key] = typeof value === "string" && palette[value]
|
|
115
|
+
? `<g transform="translate(${cx} ${cy}) scale(${scale})">${palette[value]}</g>`
|
|
116
|
+
: value;
|
|
117
|
+
}
|
|
118
|
+
return { ...t, parameters: { ...t.parameters, emotion: { ...emotion, values } } };
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -96,20 +96,31 @@ function fillFromValue(svg, param, value) {
|
|
|
96
96
|
return null;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
/** Fill a parameterized template's `svg` from
|
|
100
|
-
* `[parameters.*]` whose observed property value maps to a substitution
|
|
101
|
-
*
|
|
102
|
-
|
|
99
|
+
/** Fill a parameterized template's `svg` from EVERY one of its own
|
|
100
|
+
* `[parameters.*]` whose observed property value maps to a substitution,
|
|
101
|
+
* accumulating each successful fill onto the RUNNING svg string rather
|
|
102
|
+
* than stopping at the first hit — so a template declaring both e.g.
|
|
103
|
+
* `[parameters.material]` and `[parameters.emotion]` fills both dimensions
|
|
104
|
+
* in one pass, one parameter's substitution never undoing another's.
|
|
105
|
+
* Returns null when not even one parameter filled (never a guess, and
|
|
106
|
+
* never a half-filled template with a leftover placeholder token) — the
|
|
107
|
+
* caller (resolveAtTerm) falls through to a less specific template exactly
|
|
108
|
+
* as it did before this function tried more than one dimension. */
|
|
109
|
+
function parameterizedFillAll(template, propertyFacts) {
|
|
110
|
+
let svg = template.svg;
|
|
111
|
+
let filledCount = 0;
|
|
103
112
|
for (const param of Object.values(template.parameters || {})) {
|
|
104
113
|
const values = param?.values || {};
|
|
105
114
|
const hit = (propertyFacts || []).find(
|
|
106
115
|
(f) => f.predicate === param.property && Object.prototype.hasOwnProperty.call(values, f.object),
|
|
107
116
|
);
|
|
108
117
|
if (!hit) continue;
|
|
109
|
-
const filled = fillFromValue(
|
|
110
|
-
if (filled)
|
|
118
|
+
const filled = fillFromValue(svg, param, values[hit.object]);
|
|
119
|
+
if (!filled) continue;
|
|
120
|
+
svg = filled;
|
|
121
|
+
filledCount += 1;
|
|
111
122
|
}
|
|
112
|
-
return null;
|
|
123
|
+
return filledCount > 0 ? svg : null;
|
|
113
124
|
}
|
|
114
125
|
|
|
115
126
|
/** Resolve ONE class term (no ancestor walk here — the caller repeats this
|
|
@@ -123,7 +134,7 @@ function resolveAtTerm(term, propertyFacts, templates) {
|
|
|
123
134
|
if (matched) return matched.svg;
|
|
124
135
|
for (const t of candidates) {
|
|
125
136
|
if (t.match || !t.parameters) continue;
|
|
126
|
-
const filled =
|
|
137
|
+
const filled = parameterizedFillAll(t, propertyFacts);
|
|
127
138
|
if (filled) return filled;
|
|
128
139
|
}
|
|
129
140
|
const plain = candidates.find((t) => !t.match && !t.parameters);
|
|
@@ -174,7 +185,12 @@ export function resolveSpriteAsset(className, factRows, propertyFacts, templates
|
|
|
174
185
|
* names a `property` and exactly one of `placeholder`/`placeholders` (every
|
|
175
186
|
* token named appears in `svg`), its `values` map is non-empty and every
|
|
176
187
|
* entry matches the shape its own `placeholder`/`placeholders` choice
|
|
177
|
-
* expects,
|
|
188
|
+
* expects, a `[match]` table names both `property` and `value`, and
|
|
189
|
+
* `[face]`/`[parameters.emotion]` are always declared TOGETHER — a face
|
|
190
|
+
* anchor with nothing to select it, or an emotion parameter with nowhere to
|
|
191
|
+
* position its face fragment, is a real authoring mistake either way
|
|
192
|
+
* (sprite-expressions.mjs's own header explains why the face fragment
|
|
193
|
+
* needs the pairing). */
|
|
178
194
|
export function spriteTemplateProblems(template) {
|
|
179
195
|
const problems = [];
|
|
180
196
|
const t = template || {};
|
|
@@ -218,5 +234,11 @@ export function spriteTemplateProblems(template) {
|
|
|
218
234
|
if (t.match && (!t.match.property || t.match.value === undefined)) {
|
|
219
235
|
problems.push("match is missing property or value");
|
|
220
236
|
}
|
|
237
|
+
if (t.face && !t.parameters?.emotion) {
|
|
238
|
+
problems.push("face is declared without parameters.emotion — a face anchor with nothing to select it is dead data");
|
|
239
|
+
}
|
|
240
|
+
if (t.parameters?.emotion && !t.face) {
|
|
241
|
+
problems.push("parameters.emotion is declared without a face — an emotion parameter needs its own [face] anchor to position the fragment it fills");
|
|
242
|
+
}
|
|
221
243
|
return problems;
|
|
222
244
|
}
|