@polycode-projects/the-mechanical-code-talker 2.7.25 → 2.8.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/data/sprites/adventurer-icon.toml +13 -0
- package/data/sprites/animal-icon.toml +15 -0
- package/data/sprites/butler-icon.toml +16 -0
- package/data/sprites/cabinet-icon.toml +13 -0
- package/data/sprites/container-icon.toml +12 -0
- package/data/sprites/{cook.toml → cook-icon.toml} +8 -1
- package/data/sprites/desk-icon.toml +14 -0
- package/data/sprites/dog-icon.toml +17 -0
- package/data/sprites/dog-with-colour-icon.toml +31 -0
- package/data/sprites/egg-icon.toml +9 -0
- package/data/sprites/fly-icon.toml +19 -0
- package/data/sprites/furniture-icon.toml +12 -0
- package/data/sprites/gardener-icon.toml +11 -0
- package/data/sprites/housekeeper-icon.toml +14 -0
- package/data/sprites/key-icon.toml +12 -0
- package/data/sprites/lamp-icon.toml +11 -0
- package/data/sprites/letter-icon.toml +11 -0
- package/data/sprites/person-icon.toml +10 -0
- package/data/sprites/poodle-icon.toml +22 -0
- package/data/sprites/portable-icon.toml +12 -0
- package/data/sprites/portrait-icon.toml +12 -0
- package/data/sprites/room-icon.toml +11 -0
- package/data/sprites/spider-icon.toml +21 -0
- package/package.json +4 -2
- package/src/adapters/corpus/sprite-large-template-files.mjs +62 -0
- package/src/domain/sprite-materials.mjs +73 -0
- package/src/domain/sprite-size.mjs +34 -0
- package/src/domain/sprite-templates.mjs +106 -15
- package/src/domain/svg-instance-ids.mjs +40 -0
- package/src/services/adventure-viz.mjs +266 -36
- package/src/surfaces/web/adventure-browser-entry.mjs +38 -12
- package/data/sprites/adventurer.toml +0 -5
- package/data/sprites/animal.toml +0 -6
- package/data/sprites/butler.toml +0 -8
- package/data/sprites/cabinet.toml +0 -5
- package/data/sprites/container.toml +0 -5
- package/data/sprites/desk.toml +0 -4
- package/data/sprites/dog-with-colour.toml +0 -20
- package/data/sprites/dog.toml +0 -5
- package/data/sprites/egg.toml +0 -4
- package/data/sprites/fly.toml +0 -5
- package/data/sprites/furniture.toml +0 -5
- package/data/sprites/gardener.toml +0 -4
- package/data/sprites/housekeeper.toml +0 -6
- package/data/sprites/key.toml +0 -5
- package/data/sprites/lamp.toml +0 -4
- package/data/sprites/letter.toml +0 -5
- package/data/sprites/person.toml +0 -4
- package/data/sprites/poodle.toml +0 -5
- package/data/sprites/portable.toml +0 -6
- package/data/sprites/portrait.toml +0 -5
- package/data/sprites/room.toml +0 -5
- package/data/sprites/spider.toml +0 -5
|
@@ -28,6 +28,33 @@
|
|
|
28
28
|
// exact fact it requires, so it outranks the parameterized template
|
|
29
29
|
// when both would otherwise apply.
|
|
30
30
|
//
|
|
31
|
+
// A parameterized template's `[parameters.<name>]` table comes in two
|
|
32
|
+
// shapes, picked by which of `placeholder`/`placeholders` it declares:
|
|
33
|
+
// - single-placeholder (the shape above): one `placeholder` token, and
|
|
34
|
+
// every `[parameters.<name>.values]` entry is a plain string substituted
|
|
35
|
+
// for it directly.
|
|
36
|
+
// - multi-placeholder (data/sprites-large/*.toml's gradient-shaded
|
|
37
|
+
// materials): a `placeholders` table instead, naming several tokens at
|
|
38
|
+
// once (e.g. `{ light = "{{FILL_LIGHT}}", base = "{{FILL}}", dark =
|
|
39
|
+
// "{{FILL_DARK}}" }`), and every `[parameters.<name>.values]` entry is a
|
|
40
|
+
// table with the SAME sub-keys (e.g. `{ light = "#f0dfa0", base =
|
|
41
|
+
// "#c9a24b", dark = "#8a6a1e" }`), one substituted per token. A value
|
|
42
|
+
// missing even one of the declared sub-keys is never a partial match —
|
|
43
|
+
// the same never-guess posture as an unmapped value in the single shape.
|
|
44
|
+
// sprite-materials.mjs's `expandMaterialReferences` is what lets a
|
|
45
|
+
// sprite-large file write a short by-name reference (`gold = "metal"`)
|
|
46
|
+
// instead of hand-copying the triple — this module never has to know
|
|
47
|
+
// that indirection exists, it only ever sees the expanded table shape.
|
|
48
|
+
//
|
|
49
|
+
// An object with no taught material still gets a real gradient at the
|
|
50
|
+
// sprite tier (data/sprites-large/*.toml's own non-material files), built
|
|
51
|
+
// from currentColor via `color-mix(in srgb, currentColor N%, white/black)`
|
|
52
|
+
// rather than `stop-opacity` — opacity blends toward whatever sits BEHIND
|
|
53
|
+
// the shape, so its light/dark direction silently flips between a light
|
|
54
|
+
// theme (currentColor dark-on-light) and a dark one (currentColor
|
|
55
|
+
// light-on-dark); color-mix lightens/darkens currentColor itself, so the
|
|
56
|
+
// same corner of the shape reads as the lit one on either theme.
|
|
57
|
+
//
|
|
31
58
|
// Specificity order, checked at EACH term of the class's ancestor chain
|
|
32
59
|
// (nearest first, sprite-map.mjs's own classAncestorChain) before moving to
|
|
33
60
|
// the next ancestor: an exact fully-specific variant whose [match] is
|
|
@@ -39,6 +66,7 @@
|
|
|
39
66
|
// against `rootFallback`, falling back to spriteRegistry's own root entry
|
|
40
67
|
// only if nothing there matches either.
|
|
41
68
|
import { classAncestorChain } from "./sprite-map.mjs";
|
|
69
|
+
import { namespaceSvgIds } from "./svg-instance-ids.mjs";
|
|
42
70
|
|
|
43
71
|
/** Every template in `templates` whose `classes` list names `term`. */
|
|
44
72
|
function templatesForClass(term, templates) {
|
|
@@ -50,6 +78,24 @@ function matchSatisfied(match, propertyFacts) {
|
|
|
50
78
|
return (propertyFacts || []).some((f) => f.predicate === match.property && f.object === match.value);
|
|
51
79
|
}
|
|
52
80
|
|
|
81
|
+
/** Substitute one matched `[parameters.*.values]` entry into `svg`: a plain
|
|
82
|
+
* string fills the parameter's single `placeholder` token; an object fills
|
|
83
|
+
* every token `param.placeholders` names from the SAME sub-key — returning
|
|
84
|
+
* null (never a partial gradient) if the object is missing even one of the
|
|
85
|
+
* sub-keys the parameter declares. */
|
|
86
|
+
function fillFromValue(svg, param, value) {
|
|
87
|
+
if (typeof value === "string" && param.placeholder) return svg.split(param.placeholder).join(value);
|
|
88
|
+
if (value && typeof value === "object" && param.placeholders) {
|
|
89
|
+
let out = svg;
|
|
90
|
+
for (const [sub, token] of Object.entries(param.placeholders)) {
|
|
91
|
+
if (!Object.prototype.hasOwnProperty.call(value, sub)) return null;
|
|
92
|
+
out = out.split(token).join(value[sub]);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
53
99
|
/** Fill a parameterized template's `svg` from the first of its own
|
|
54
100
|
* `[parameters.*]` whose observed property value maps to a substitution —
|
|
55
101
|
* or null when no property fact names a mapped value (never a guess). */
|
|
@@ -59,7 +105,9 @@ function parameterizedFill(template, propertyFacts) {
|
|
|
59
105
|
const hit = (propertyFacts || []).find(
|
|
60
106
|
(f) => f.predicate === param.property && Object.prototype.hasOwnProperty.call(values, f.object),
|
|
61
107
|
);
|
|
62
|
-
if (hit)
|
|
108
|
+
if (!hit) continue;
|
|
109
|
+
const filled = fillFromValue(template.svg, param, values[hit.object]);
|
|
110
|
+
if (filled) return filled;
|
|
63
111
|
}
|
|
64
112
|
return null;
|
|
65
113
|
}
|
|
@@ -82,6 +130,17 @@ function resolveAtTerm(term, propertyFacts, templates) {
|
|
|
82
130
|
return plain ? plain.svg : null;
|
|
83
131
|
}
|
|
84
132
|
|
|
133
|
+
function resolveSpriteAssetRaw(className, factRows, propertyFacts, templates, spriteRegistry, rootFallback) {
|
|
134
|
+
for (const term of classAncestorChain(className, factRows)) {
|
|
135
|
+
const hit = resolveAtTerm(term, propertyFacts, templates);
|
|
136
|
+
if (hit) return hit;
|
|
137
|
+
if (Object.prototype.hasOwnProperty.call(spriteRegistry, term)) return spriteRegistry[term];
|
|
138
|
+
}
|
|
139
|
+
const rootHit = resolveAtTerm(rootFallback, propertyFacts, templates);
|
|
140
|
+
if (rootHit) return rootHit;
|
|
141
|
+
return spriteRegistry[rootFallback];
|
|
142
|
+
}
|
|
143
|
+
|
|
85
144
|
/**
|
|
86
145
|
* Resolve `className` to sprite SVG markup, property-aware: the ancestor
|
|
87
146
|
* chain (from `factRows`, sprite-map.mjs's own walk) is checked nearest-
|
|
@@ -91,16 +150,19 @@ function resolveAtTerm(term, propertyFacts, templates) {
|
|
|
91
150
|
* as resolveSpriteForClass already does. `propertyFacts` is the instance's
|
|
92
151
|
* own small `{predicate, object}` fact set (e.g. its mgx:hasProperty rows) —
|
|
93
152
|
* read only, never required to be non-empty. Pure.
|
|
153
|
+
*
|
|
154
|
+
* `instanceKey`, when given, namespaces every gradient id the resolved svg
|
|
155
|
+
* declares (svg-instance-ids.mjs's own `namespaceSvgIds`) — pass the
|
|
156
|
+
* instance's own identity (e.g. its subject name) whenever more than one
|
|
157
|
+
* resolved sprite can appear in the same document at once, so two
|
|
158
|
+
* differently-valued instances of the SAME template (a gold lamp and a
|
|
159
|
+
* ceramic lamp both on screen) never share one `<linearGradient id>` and
|
|
160
|
+
* silently render each other's colours. Omit it for a single-instance
|
|
161
|
+
* caller (a template with no ids at all is untouched either way).
|
|
94
162
|
*/
|
|
95
|
-
export function resolveSpriteAsset(className, factRows, propertyFacts, templates, spriteRegistry, { rootFallback = "animal" } = {}) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (hit) return hit;
|
|
99
|
-
if (Object.prototype.hasOwnProperty.call(spriteRegistry, term)) return spriteRegistry[term];
|
|
100
|
-
}
|
|
101
|
-
const rootHit = resolveAtTerm(rootFallback, propertyFacts, templates);
|
|
102
|
-
if (rootHit) return rootHit;
|
|
103
|
-
return spriteRegistry[rootFallback];
|
|
163
|
+
export function resolveSpriteAsset(className, factRows, propertyFacts, templates, spriteRegistry, { rootFallback = "animal", instanceKey } = {}) {
|
|
164
|
+
const svg = resolveSpriteAssetRaw(className, factRows, propertyFacts, templates, spriteRegistry, rootFallback);
|
|
165
|
+
return instanceKey ? namespaceSvgIds(svg, instanceKey) : svg;
|
|
104
166
|
}
|
|
105
167
|
|
|
106
168
|
/** Every internal-consistency problem with one parsed template, as plain
|
|
@@ -109,8 +171,10 @@ export function resolveSpriteAsset(className, factRows, propertyFacts, templates
|
|
|
109
171
|
* data/sprites/ directory) and available to any future loader that wants to
|
|
110
172
|
* warn rather than silently drop a broken file. Checks: `classes` is a
|
|
111
173
|
* non-empty array, `svg` is a real `<svg` string, a `[parameters.*]` table
|
|
112
|
-
* names a `property` and
|
|
113
|
-
*
|
|
174
|
+
* names a `property` and exactly one of `placeholder`/`placeholders` (every
|
|
175
|
+
* token named appears in `svg`), its `values` map is non-empty and every
|
|
176
|
+
* entry matches the shape its own `placeholder`/`placeholders` choice
|
|
177
|
+
* expects, and a `[match]` table names both `property` and `value`. */
|
|
114
178
|
export function spriteTemplateProblems(template) {
|
|
115
179
|
const problems = [];
|
|
116
180
|
const t = template || {};
|
|
@@ -118,11 +182,38 @@ export function spriteTemplateProblems(template) {
|
|
|
118
182
|
if (typeof t.svg !== "string" || !t.svg.trim().startsWith("<svg")) problems.push("svg is missing or not an <svg> string");
|
|
119
183
|
for (const [name, param] of Object.entries(t.parameters || {})) {
|
|
120
184
|
if (!param?.property) problems.push(`parameters.${name}.property is missing`);
|
|
121
|
-
if (
|
|
122
|
-
|
|
185
|
+
if (param?.placeholder && param?.placeholders) {
|
|
186
|
+
problems.push(`parameters.${name} sets both placeholder and placeholders — pick one`);
|
|
187
|
+
} else if (param?.placeholders) {
|
|
188
|
+
const tokens = Object.entries(param.placeholders);
|
|
189
|
+
if (tokens.length === 0) problems.push(`parameters.${name}.placeholders is empty`);
|
|
190
|
+
for (const [sub, token] of tokens) {
|
|
191
|
+
if (typeof t.svg === "string" && !t.svg.includes(token)) {
|
|
192
|
+
problems.push(`parameters.${name}.placeholders.${sub} ${JSON.stringify(token)} does not appear in svg`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} else if (!param?.placeholder) {
|
|
196
|
+
problems.push(`parameters.${name}.placeholder is missing`);
|
|
197
|
+
} else if (typeof t.svg === "string" && !t.svg.includes(param.placeholder)) {
|
|
123
198
|
problems.push(`parameters.${name}.placeholder ${JSON.stringify(param.placeholder)} does not appear in svg`);
|
|
124
199
|
}
|
|
125
|
-
if (!param?.values || Object.keys(param.values).length === 0)
|
|
200
|
+
if (!param?.values || Object.keys(param.values).length === 0) {
|
|
201
|
+
problems.push(`parameters.${name}.values is empty`);
|
|
202
|
+
} else {
|
|
203
|
+
for (const [key, value] of Object.entries(param.values)) {
|
|
204
|
+
if (param.placeholders) {
|
|
205
|
+
if (!value || typeof value !== "object") {
|
|
206
|
+
problems.push(`parameters.${name}.values.${key} is not an expanded {${Object.keys(param.placeholders).join("/")}} object — an unresolved material reference?`);
|
|
207
|
+
} else {
|
|
208
|
+
for (const sub of Object.keys(param.placeholders)) {
|
|
209
|
+
if (!Object.prototype.hasOwnProperty.call(value, sub)) problems.push(`parameters.${name}.values.${key} is missing "${sub}"`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} else if (typeof value !== "string") {
|
|
213
|
+
problems.push(`parameters.${name}.values.${key} must be a plain string for a single-placeholder parameter`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
126
217
|
}
|
|
127
218
|
if (t.match && (!t.match.property || t.match.value === undefined)) {
|
|
128
219
|
problems.push("match is missing property or value");
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// svg-instance-ids.mjs — a resolved sprite's own `id="…"`/`url(#…)` pairs
|
|
2
|
+
// (a gradient def and its fill/stroke reference) made unique per rendered
|
|
3
|
+
// instance. It exists because two DIFFERENT resolved values of the SAME
|
|
4
|
+
// class's SAME template — e.g. a gold lamp and a ceramic lamp, both on
|
|
5
|
+
// screen at once — share the identical `id="lamp-fill"` string (the id lives
|
|
6
|
+
// in the TEMPLATE, not the substituted colour), and duplicate ids are a real
|
|
7
|
+
// bug, not a cosmetic one: a browser's `url(#id)` reference resolves to
|
|
8
|
+
// whichever element with that id it saw FIRST in the document, so every
|
|
9
|
+
// later instance silently renders the FIRST instance's colours. Namespacing
|
|
10
|
+
// each id (and every reference to it) by the caller's own instanceKey before
|
|
11
|
+
// two instances share a DOM keeps each gradient its own.
|
|
12
|
+
//
|
|
13
|
+
// A resolved sprite with no ids at all (most non-material templates carry
|
|
14
|
+
// none) is untouched — this is a no-op unless there's actually a collision
|
|
15
|
+
// to prevent.
|
|
16
|
+
|
|
17
|
+
/** Every id `svg` declares, in first-appearance order. */
|
|
18
|
+
function idsIn(svg) {
|
|
19
|
+
return [...new Set([...svg.matchAll(/\bid="([^"]+)"/g)].map((m) => m[1]))];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Suffix every `id="X"` and every `url(#X)` reference to it in `svg` with
|
|
24
|
+
* `-{instanceKey}` (sanitized to id-safe characters) — so the SAME template
|
|
25
|
+
* resolved for two different instances never collides once both are in the
|
|
26
|
+
* same document. `instanceKey` falsy (undefined/""/0) returns `svg`
|
|
27
|
+
* unchanged, the same never-touch-it-unless-asked posture as any other
|
|
28
|
+
* optional pass-through in this pack. Pure.
|
|
29
|
+
*/
|
|
30
|
+
export function namespaceSvgIds(svg, instanceKey) {
|
|
31
|
+
if (!instanceKey || typeof svg !== "string") return svg;
|
|
32
|
+
const safe = String(instanceKey).replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
33
|
+
let out = svg;
|
|
34
|
+
for (const id of idsIn(svg)) {
|
|
35
|
+
const namespaced = `${id}-${safe}`;
|
|
36
|
+
out = out.split(`id="${id}"`).join(`id="${namespaced}"`);
|
|
37
|
+
out = out.split(`url(#${id})`).join(`url(#${namespaced})`);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
@@ -19,27 +19,33 @@
|
|
|
19
19
|
// already takes with its own precomputed memory payload, just applied to a
|
|
20
20
|
// second kind of build-time data.
|
|
21
21
|
//
|
|
22
|
-
//
|
|
22
|
+
// Five pure, `.toString()`-splice-safe pieces are exported as real functions
|
|
23
23
|
// (not raw inline-script text) so they can be pinned directly by tests, the
|
|
24
24
|
// same discipline spider-fly-viz.mjs holds classOfAgentId/
|
|
25
25
|
// threadCellsForSpiderPlan to: `spriteClassForObject` (an object's sprite
|
|
26
|
-
// class, from its own rdf:type or mgx:is-container fact
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// in-page
|
|
38
|
-
//
|
|
26
|
+
// class, from its own rdf:type or mgx:is-container fact), `visibleRoomOf`
|
|
27
|
+
// (the room a subject's placement resolves into, for visibility — mirroring
|
|
28
|
+
// adventure.mjs's own private `visibleRoomOf` the same way
|
|
29
|
+
// adventure-autoplay.mjs's own `roomOfSubject` already has to), `roomSceneObjects`
|
|
30
|
+
// (every subject actually visible in a room, built over `visibleRoomOf`),
|
|
31
|
+
// `carriedItems` (every object placed with the player), and `visitedRoomGraph`
|
|
32
|
+
// (a directions-only layout of the rooms a session has actually visited —
|
|
33
|
+
// see its own header for the exposure discipline). None of these import
|
|
34
|
+
// anything beyond this module's own exports, which is what keeps a raw
|
|
35
|
+
// `.toString()` splice safe. Three further pure helpers are exported for
|
|
36
|
+
// testing but NOT spliced, because each calls another module's export the
|
|
37
|
+
// in-page script instead reaches through the browser bundle's own
|
|
38
|
+
// `tmctAdventure` global (mirroring how the inline script calls
|
|
39
|
+
// `tmctSpiderFly.*` rather than re-importing spider-fly-world.mjs):
|
|
40
|
+
// `roomCaptionText` (calls `worldDigestRows`; the in-page `captionFor`
|
|
41
|
+
// mirrors it against `tmctAdventure.worldDigestRows`), `pillsForRoom` (a thin
|
|
39
42
|
// wrapper over adventure.mjs's own exported `roomAffordances`, whose header
|
|
40
43
|
// explains why its list can never promise an action one of take/open/talk/
|
|
41
44
|
// examine would then refuse; the in-page `pillsFor` mirrors it against
|
|
42
|
-
// `tmctAdventure.roomAffordances`)
|
|
45
|
+
// `tmctAdventure.roomAffordances`), and `goalStatusLines` (calls
|
|
46
|
+
// `foldWorldState` and adventure-autoplay.mjs's own `exposedFacts`; the
|
|
47
|
+
// in-page `goalStatusLinesFor` mirrors both against the `tmctAdventure`
|
|
48
|
+
// global too).
|
|
43
49
|
//
|
|
44
50
|
// The chat dock (chatlog/chatform/chatq/pills, below) mirrors
|
|
45
51
|
// spider-fly-viz.mjs's own side panel: every manual exchange (via
|
|
@@ -48,9 +54,17 @@
|
|
|
48
54
|
// a visitor sees one continuous history rather than a line overwritten every
|
|
49
55
|
// tick. `#caption`/`#goalLine` keep their existing job as the current-state
|
|
50
56
|
// summary; the chat log is the persistent addition.
|
|
57
|
+
//
|
|
58
|
+
// Three further panels sit alongside the chat dock in `.side`: carrying
|
|
59
|
+
// (`carriedItems`), the visited-room map (`visitedRoomGraph`), and goal
|
|
60
|
+
// status (`goalStatusLines`) — all three read `session.snapshot()`'s own
|
|
61
|
+
// `visitedRoomIds`, the manual+auto-play exposure set
|
|
62
|
+
// adventure-browser-entry.mjs threads forward (see that module's header for
|
|
63
|
+
// why the two paths share one set rather than two).
|
|
51
64
|
import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
|
|
52
65
|
import { createTicker } from "./viz-ticker.mjs";
|
|
53
|
-
import { worldDigestRows, roomAffordances } from "./adventure.mjs";
|
|
66
|
+
import { worldDigestRows, roomAffordances, foldWorldState } from "./adventure.mjs";
|
|
67
|
+
import { exposedFacts } from "./adventure-autoplay.mjs";
|
|
54
68
|
|
|
55
69
|
const DEFAULT_TITLE = "tmct — the adventure";
|
|
56
70
|
const PREVIEW_MAX_TICKS = 30;
|
|
@@ -97,34 +111,142 @@ export function factsForSubject(rows, subject) {
|
|
|
97
111
|
return (rows || []).filter((r) => r.subject === subject);
|
|
98
112
|
}
|
|
99
113
|
|
|
114
|
+
/** The room a subject's placement resolves into, for VISIBILITY — a room
|
|
115
|
+
* resolves to itself; an object placed directly in a room resolves to that
|
|
116
|
+
* room; an object one containment hop inside an OPEN container resolves to
|
|
117
|
+
* the container's own room; anything hidden, carried, or inside a closed
|
|
118
|
+
* container resolves to null. Mirrors adventure.mjs's own private
|
|
119
|
+
* `visibleRoomOf` / adventure-autoplay.mjs's own private `roomOfSubject`
|
|
120
|
+
* exactly, against whatever rows/state pair the caller hands it: feeding it
|
|
121
|
+
* the full fold (as `roomSceneObjects` does, since the player is already
|
|
122
|
+
* standing in the room being drawn) lets an open container's contents show;
|
|
123
|
+
* feeding it an exposure-filtered pair (as `goalStatusLines` does) is what
|
|
124
|
+
* keeps a not-yet-visited location unknown. Pure, self-contained — no
|
|
125
|
+
* reference to adventure.mjs's own private helpers, since those aren't
|
|
126
|
+
* exported. */
|
|
127
|
+
export function visibleRoomOf(rows, state, subject) {
|
|
128
|
+
const isRoom = (id) => (rows || []).some((r) => r.subject === id && r.predicate === "rdf:type" && r.object === "room");
|
|
129
|
+
if (isRoom(subject)) return subject;
|
|
130
|
+
const place = state.placements.get(subject);
|
|
131
|
+
if (!place || place.predicate === "mgx:hidden-in") return null;
|
|
132
|
+
if (place.predicate === "mgx:currently-in" || isRoom(place.object)) return place.object;
|
|
133
|
+
const holder = place.object;
|
|
134
|
+
if (holder === "player") return null;
|
|
135
|
+
if (!state.openness.get(holder)?.open) return null;
|
|
136
|
+
const holderPlace = state.placements.get(holder);
|
|
137
|
+
return holderPlace && holderPlace.predicate !== "mgx:hidden-in" ? holderPlace.object : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
100
140
|
/** Every subject actually visible in `here`, sorted, each with its sprite
|
|
101
|
-
* class —
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* separately. Pure. */
|
|
141
|
+
* class — built over `visibleRoomOf` (one containment hop through an OPEN
|
|
142
|
+
* container) so this can never draw a hidden or carried object the text
|
|
143
|
+
* digest wouldn't also mention. `player` is excluded; the caller draws the
|
|
144
|
+
* player's own adventurer sprite separately. Pure. */
|
|
106
145
|
export function roomSceneObjects(rows, state, here) {
|
|
107
|
-
const isTypedRoom = (subject) =>
|
|
108
|
-
(rows || []).some((r) => r.subject === subject && r.predicate === "rdf:type" && r.object === "room");
|
|
109
|
-
const visibleRoomOf = (subject) => {
|
|
110
|
-
const place = state.placements.get(subject);
|
|
111
|
-
if (!place || place.predicate === "mgx:hidden-in") return null;
|
|
112
|
-
if (place.predicate === "mgx:currently-in" || isTypedRoom(place.object)) return place.object;
|
|
113
|
-
const holder = place.object;
|
|
114
|
-
if (holder === "player") return null;
|
|
115
|
-
if (!state.openness.get(holder)?.open) return null;
|
|
116
|
-
const holderPlace = state.placements.get(holder);
|
|
117
|
-
return holderPlace && holderPlace.predicate !== "mgx:hidden-in" ? holderPlace.object : null;
|
|
118
|
-
};
|
|
119
146
|
const out = [];
|
|
120
147
|
for (const subject of [...state.placements.keys()].sort()) {
|
|
121
148
|
if (subject === "player") continue;
|
|
122
|
-
if (visibleRoomOf(subject) !== here) continue;
|
|
149
|
+
if (visibleRoomOf(rows, state, subject) !== here) continue;
|
|
123
150
|
out.push({ subject, spriteClass: spriteClassForObject(rows, subject) });
|
|
124
151
|
}
|
|
125
152
|
return out;
|
|
126
153
|
}
|
|
127
154
|
|
|
155
|
+
/** Every object currently `mgx:located-in` "player", sorted, each with its
|
|
156
|
+
* sprite class — the exact placement `worldDigestRows`'/`inventoryAnswer`'s
|
|
157
|
+
* own "carries the" branch already reads, just returned as a plain list
|
|
158
|
+
* instead of prose. Pure. */
|
|
159
|
+
export function carriedItems(rows, state) {
|
|
160
|
+
return [...state.placements]
|
|
161
|
+
.filter(([, p]) => p.predicate === "mgx:located-in" && p.object === "player")
|
|
162
|
+
.map(([subject]) => ({ subject, spriteClass: spriteClassForObject(rows, subject) }))
|
|
163
|
+
.sort((a, b) => a.subject.localeCompare(b.subject));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A visited-rooms-only map: one node per room `visitedRoomIds` actually
|
|
167
|
+
* names, laid out on an integer grid FROM the world's own has-exit-*
|
|
168
|
+
* directions — never a force-directed guess, so a room with a north exit to
|
|
169
|
+
* another visited room sits one row above it (up/down read the same way,
|
|
170
|
+
* since neither world this project ships gives a vertical exit its own
|
|
171
|
+
* lateral position). An edge is drawn only between two rooms BOTH already
|
|
172
|
+
* visited, mirroring adventure-autoplay.mjs's own exposedExitApplyActions:
|
|
173
|
+
* an exit fact belongs to the room whose subject it is, so it is only ever
|
|
174
|
+
* read off a VISITED room's own state.exits entry, never an unvisited
|
|
175
|
+
* room's. `hints` names every exit a visited room has toward a room not yet
|
|
176
|
+
* visited — the direction only, never the unvisited room's own name, so a
|
|
177
|
+
* caller can draw at most "there's an exit that way" and nothing more.
|
|
178
|
+
* Disconnected visited rooms (not reachable from each other by traveled
|
|
179
|
+
* edges) lay out as separate side-by-side blocks rather than overlapping.
|
|
180
|
+
* Pure. */
|
|
181
|
+
export function visitedRoomGraph(state, visitedRoomIds) {
|
|
182
|
+
const DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
|
|
183
|
+
const visited = new Set(visitedRoomIds || []);
|
|
184
|
+
const here = state.placements.get("player")?.object ?? null;
|
|
185
|
+
const positions = new Map();
|
|
186
|
+
const edges = [];
|
|
187
|
+
const edgeKeys = new Set();
|
|
188
|
+
const hints = [];
|
|
189
|
+
let offsetX = 0;
|
|
190
|
+
for (const start of [...visited].sort()) {
|
|
191
|
+
if (positions.has(start)) continue;
|
|
192
|
+
positions.set(start, { x: offsetX, y: 0 });
|
|
193
|
+
const queue = [start];
|
|
194
|
+
const component = [start];
|
|
195
|
+
while (queue.length) {
|
|
196
|
+
const room = queue.shift();
|
|
197
|
+
const pos = positions.get(room);
|
|
198
|
+
const dirs = state.exits.get(room);
|
|
199
|
+
for (const direction of [...(dirs?.keys() ?? [])].sort()) {
|
|
200
|
+
const target = dirs.get(direction);
|
|
201
|
+
if (!visited.has(target)) { hints.push({ from: room, direction }); continue; }
|
|
202
|
+
const key = [room, target].sort().join("\0");
|
|
203
|
+
if (!edgeKeys.has(key)) { edgeKeys.add(key); edges.push({ from: room, to: target, direction }); }
|
|
204
|
+
if (!positions.has(target)) {
|
|
205
|
+
const [dx, dy] = DELTA[direction] ?? [0, 0];
|
|
206
|
+
positions.set(target, { x: pos.x + dx, y: pos.y + dy });
|
|
207
|
+
component.push(target);
|
|
208
|
+
queue.push(target);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
offsetX = Math.max(...component.map((r) => positions.get(r).x)) + 2;
|
|
213
|
+
}
|
|
214
|
+
const minX = Math.min(0, ...[...positions.values()].map((p) => p.x));
|
|
215
|
+
const minY = Math.min(0, ...[...positions.values()].map((p) => p.y));
|
|
216
|
+
const nodes = [...visited].sort().map((room) => {
|
|
217
|
+
const p = positions.get(room) || { x: 0, y: 0 };
|
|
218
|
+
return { id: room, x: p.x - minX, y: p.y - minY, current: room === here };
|
|
219
|
+
});
|
|
220
|
+
return { nodes, edges, hints };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** One status line per exposed `mgx:is-objective` fact, using the SAME
|
|
224
|
+
* three-state logic adventure-autoplay.mjs's own goal inference already
|
|
225
|
+
* distinguishes, restricted to what `visitedRoomIds` actually supports:
|
|
226
|
+
* carried (the full, unfiltered state — carrying is always self-evidently
|
|
227
|
+
* known, the same unconditional exposure the marker fact itself gets);
|
|
228
|
+
* exposed with a known room (an `exposedFacts` filter, mirroring
|
|
229
|
+
* runAdventureAutoplayTick's own objectiveRoom lookup, so a location is
|
|
230
|
+
* never claimed before the room holding it has actually been visited); or
|
|
231
|
+
* not yet exposed at all (the opening-line-level knowledge every session
|
|
232
|
+
* gets unconditionally — the marker fact names the sought thing, nothing
|
|
233
|
+
* more). Pure. */
|
|
234
|
+
export function goalStatusLines(rows, state, visitedRoomIds) {
|
|
235
|
+
const ids = [...new Set((rows || [])
|
|
236
|
+
.filter((r) => r.predicate === "mgx:is-objective" && r.object === "true")
|
|
237
|
+
.map((r) => r.subject))];
|
|
238
|
+
const carriedIds = new Set(carriedItems(rows, state).map((o) => o.subject));
|
|
239
|
+
const exposedRows = exposedFacts(rows, visitedRoomIds);
|
|
240
|
+
const exposedState = foldWorldState(exposedRows);
|
|
241
|
+
return ids.map((id) => {
|
|
242
|
+
if (carriedIds.has(id)) return { subject: id, status: "carried", text: `carrying the ${id} — the adventure is won.` };
|
|
243
|
+
const room = visibleRoomOf(exposedRows, exposedState, id);
|
|
244
|
+
return room
|
|
245
|
+
? { subject: id, status: "known", text: `last known: the ${id} is in the ${room}.` }
|
|
246
|
+
: { subject: id, status: "unknown", text: `there's a sought-after ${id} somewhere.` };
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
128
250
|
/** The clickable command suggestions for the room the player is CURRENTLY
|
|
129
251
|
* in — a thin, testable wrapper over adventure.mjs's own `roomAffordances`
|
|
130
252
|
* (the exact same data take/open/talk/examine already check), so a pill can
|
|
@@ -210,8 +332,22 @@ ${THEME_TOKENS_CSS}
|
|
|
210
332
|
.sprite-label { font-family: ${MONO_STACK}; font-size: .62rem; text-align: center; color: var(--muted); margin-top: .15rem; }
|
|
211
333
|
.caption { background: var(--card); border: 1px solid var(--line); padding: .6rem .75rem; font-size: .9rem; }
|
|
212
334
|
.side { display: flex; flex-direction: column; gap: .8rem; min-width: 0; }
|
|
213
|
-
.chat { background: var(--card); border: 1px solid var(--line); padding: .6rem .75rem; }
|
|
214
|
-
.chat h2 { font-family: ${MONO_STACK}; font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); font-weight: 400; margin: 0 0 .5rem; }
|
|
335
|
+
.chat, .panel { background: var(--card); border: 1px solid var(--line); padding: .6rem .75rem; }
|
|
336
|
+
.chat h2, .panel h2 { font-family: ${MONO_STACK}; font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); font-weight: 400; margin: 0 0 .5rem; }
|
|
337
|
+
.empty-note { font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); }
|
|
338
|
+
.chips { display: flex; flex-wrap: wrap; gap: .35rem; }
|
|
339
|
+
.chip { font-family: ${MONO_STACK}; font-size: .72rem; padding: .25rem .6rem; border: 1px solid var(--line); background: var(--bg); color: var(--ink); border-radius: 999px; }
|
|
340
|
+
.roommap svg { width: 100%; height: auto; display: block; }
|
|
341
|
+
.roommap .room-edge { stroke: var(--line); stroke-width: 1.5; }
|
|
342
|
+
.roommap .room-hint { fill: var(--muted); opacity: .45; }
|
|
343
|
+
.roommap .room-node circle { fill: var(--card); stroke: var(--line); stroke-width: 1.5; }
|
|
344
|
+
.roommap .room-node.current circle { stroke: var(--taught); fill: var(--taught-soft); stroke-width: 2; }
|
|
345
|
+
.roommap .room-node text { font-family: ${MONO_STACK}; font-size: 6.5px; fill: var(--ink); text-anchor: middle; }
|
|
346
|
+
.goal-status { display: flex; flex-direction: column; gap: .4rem; }
|
|
347
|
+
.goal-status .g { display: flex; align-items: baseline; gap: .4rem; font-size: .86rem; line-height: 1.35; }
|
|
348
|
+
.goal-status .dot { width: .5rem; height: .5rem; border-radius: 50%; flex: none; background: var(--muted); }
|
|
349
|
+
.goal-status .g.known .dot { background: var(--corpus); }
|
|
350
|
+
.goal-status .g.carried .dot { background: var(--taught); }
|
|
215
351
|
.chatlog { display: flex; flex-direction: column; gap: .4rem; max-height: 320px; overflow-y: auto; margin-bottom: .5rem; }
|
|
216
352
|
.chatlog .u { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); }
|
|
217
353
|
.chatlog .u::before { content: "tmct> "; color: var(--taught); }
|
|
@@ -257,6 +393,18 @@ ${THEME_TOKENS_CSS}
|
|
|
257
393
|
<input id="chatq" type="text" placeholder="go north" aria-label="Type a command, or ask a question" disabled>
|
|
258
394
|
</form>
|
|
259
395
|
</div>
|
|
396
|
+
<div class="panel carrying">
|
|
397
|
+
<h2>carrying</h2>
|
|
398
|
+
<div class="chips" id="carryList"></div>
|
|
399
|
+
</div>
|
|
400
|
+
<div class="panel roommap">
|
|
401
|
+
<h2>rooms visited</h2>
|
|
402
|
+
<div id="mapWrap"></div>
|
|
403
|
+
</div>
|
|
404
|
+
<div class="panel goals">
|
|
405
|
+
<h2>goal</h2>
|
|
406
|
+
<div id="goalList"></div>
|
|
407
|
+
</div>
|
|
260
408
|
</aside>
|
|
261
409
|
</div>
|
|
262
410
|
<div class="caption" id="caption"></div>
|
|
@@ -278,7 +426,10 @@ const ADVENTURE = ${pageData};
|
|
|
278
426
|
"use strict";
|
|
279
427
|
const createTicker = ${createTicker.toString()};
|
|
280
428
|
const spriteClassForObject = ${spriteClassForObject.toString()};
|
|
429
|
+
const visibleRoomOf = ${visibleRoomOf.toString()};
|
|
281
430
|
const roomSceneObjects = ${roomSceneObjects.toString()};
|
|
431
|
+
const carriedItems = ${carriedItems.toString()};
|
|
432
|
+
const visitedRoomGraph = ${visitedRoomGraph.toString()};
|
|
282
433
|
const spriteAncestryRows = ${spriteAncestryRows.toString()};
|
|
283
434
|
const factsForSubject = ${factsForSubject.toString()};
|
|
284
435
|
const esc = ${escapeHtml.toString()};
|
|
@@ -295,6 +446,9 @@ const ADVENTURE = ${pageData};
|
|
|
295
446
|
const pillsEl = el("pills");
|
|
296
447
|
const chatformEl = el("chatform");
|
|
297
448
|
const chatqEl = el("chatq");
|
|
449
|
+
const carryListEl = el("carryList");
|
|
450
|
+
const mapWrapEl = el("mapWrap");
|
|
451
|
+
const goalListEl = el("goalList");
|
|
298
452
|
|
|
299
453
|
const params = new URLSearchParams(location.search);
|
|
300
454
|
const preview = params.get("preview") === "1";
|
|
@@ -311,6 +465,79 @@ const ADVENTURE = ${pageData};
|
|
|
311
465
|
return lines.length ? lines.join(" ") : "Nothing more about the " + here + " is written down yet.";
|
|
312
466
|
}
|
|
313
467
|
|
|
468
|
+
// ---- goal status — mirrors adventure-viz.mjs's own goalStatusLines
|
|
469
|
+
// against tmctAdventure.foldWorldState/tmctAdventure.exposedFacts (the
|
|
470
|
+
// same mirroring captionFor/pillsFor already do against their own
|
|
471
|
+
// adventure.mjs exports), so a location is never claimed before the room
|
|
472
|
+
// holding it has actually been visited this session.
|
|
473
|
+
function goalStatusLinesFor(rows, state, visitedRoomIds) {
|
|
474
|
+
const ids = Array.from(new Set(rows.filter((r) => r.predicate === "mgx:is-objective" && r.object === "true").map((r) => r.subject)));
|
|
475
|
+
const carriedIds = new Set(carriedItems(rows, state).map((o) => o.subject));
|
|
476
|
+
const exposedRows = tmctAdventure.exposedFacts(rows, visitedRoomIds);
|
|
477
|
+
const exposedState = tmctAdventure.foldWorldState(exposedRows);
|
|
478
|
+
return ids.map((id) => {
|
|
479
|
+
if (carriedIds.has(id)) return { subject: id, status: "carried", text: "carrying the " + id + " \\u2014 the adventure is won." };
|
|
480
|
+
const room = visibleRoomOf(exposedRows, exposedState, id);
|
|
481
|
+
return room
|
|
482
|
+
? { subject: id, status: "known", text: "last known: the " + id + " is in the " + room + "." }
|
|
483
|
+
: { subject: id, status: "unknown", text: "there's a sought-after " + id + " somewhere." };
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ---- carrying panel — every object placed with the player, rendered as
|
|
488
|
+
// the same non-interactive chip shape the room's own pills use for
|
|
489
|
+
// actions, so the sidebar reads as one visual family.
|
|
490
|
+
function renderCarrying(rows, state) {
|
|
491
|
+
const items = carriedItems(rows, state);
|
|
492
|
+
carryListEl.innerHTML = items.length
|
|
493
|
+
? items.map((o) => '<span class="chip">' + esc(o.subject) + "</span>").join("")
|
|
494
|
+
: '<span class="empty-note">nothing yet</span>';
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ---- visited-room map — an SVG laid out directly from visitedRoomGraph's
|
|
498
|
+
// own directional grid, never a second layout. Edges only ever join two
|
|
499
|
+
// ALREADY-visited rooms; hints mark a known exit's direction from a
|
|
500
|
+
// visited room without naming or drawing the unvisited room itself.
|
|
501
|
+
function renderRoomMap(rows, state, visitedRoomIds) {
|
|
502
|
+
const graph = visitedRoomGraph(state, visitedRoomIds);
|
|
503
|
+
if (!graph.nodes.length) { mapWrapEl.innerHTML = '<span class="empty-note">nowhere yet</span>'; return; }
|
|
504
|
+
const cell = 56, radius = 15;
|
|
505
|
+
const maxX = Math.max.apply(null, graph.nodes.map((n) => n.x));
|
|
506
|
+
const maxY = Math.max.apply(null, graph.nodes.map((n) => n.y));
|
|
507
|
+
const w = (maxX + 1) * cell, h = (maxY + 1) * cell + 14;
|
|
508
|
+
const cx = (n) => (n.x + 0.5) * cell;
|
|
509
|
+
const cy = (n) => (n.y + 0.5) * cell;
|
|
510
|
+
const byRoom = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
511
|
+
const edgesSvg = graph.edges.map((e) => {
|
|
512
|
+
const a = byRoom.get(e.from), b = byRoom.get(e.to);
|
|
513
|
+
return '<line class="room-edge" x1="' + cx(a) + '" y1="' + cy(a) + '" x2="' + cx(b) + '" y2="' + cy(b) + '"></line>';
|
|
514
|
+
}).join("");
|
|
515
|
+
const HINT_DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
|
|
516
|
+
const hintsSvg = graph.hints.map((hi) => {
|
|
517
|
+
const from = byRoom.get(hi.from);
|
|
518
|
+
const d = HINT_DELTA[hi.direction] || [0, 0];
|
|
519
|
+
return '<circle class="room-hint" cx="' + (cx(from) + d[0] * cell * 0.42) + '" cy="' + (cy(from) + d[1] * cell * 0.42) + '" r="3"></circle>';
|
|
520
|
+
}).join("");
|
|
521
|
+
const nodesSvg = graph.nodes.map((n) => {
|
|
522
|
+
const cls = "room-node" + (n.current ? " current" : "");
|
|
523
|
+
return '<g class="' + cls + '"><circle cx="' + cx(n) + '" cy="' + cy(n) + '" r="' + radius + '"></circle>'
|
|
524
|
+
+ '<text x="' + cx(n) + '" y="' + (cy(n) + radius + 9) + '">' + esc(n.id) + "</text></g>";
|
|
525
|
+
}).join("");
|
|
526
|
+
mapWrapEl.innerHTML = '<svg viewBox="0 0 ' + w + " " + h + '" role="img" aria-label="the rooms visited so far">'
|
|
527
|
+
+ edgesSvg + hintsSvg + nodesSvg + "</svg>";
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ---- goal panel — one line per exposed objective, a colored dot carrying
|
|
531
|
+
// the same three-state read goalStatusLinesFor's own status field names.
|
|
532
|
+
function renderGoals(rows, state, visitedRoomIds) {
|
|
533
|
+
const lines = goalStatusLinesFor(rows, state, visitedRoomIds);
|
|
534
|
+
goalListEl.innerHTML = lines.length
|
|
535
|
+
? '<div class="goal-status">' + lines.map((l) =>
|
|
536
|
+
'<div class="g ' + l.status + '"><span class="dot"></span><span>' + esc(l.text) + "</span></div>").join("")
|
|
537
|
+
+ "</div>"
|
|
538
|
+
: '<span class="empty-note">this world names no goal.</span>';
|
|
539
|
+
}
|
|
540
|
+
|
|
314
541
|
// ---- chat/event log — every manual exchange AND every auto-play tick's
|
|
315
542
|
// own narration append here, in order, so it reads as one continuous
|
|
316
543
|
// history rather than a line overwritten every tick.
|
|
@@ -370,6 +597,9 @@ const ADVENTURE = ${pageData};
|
|
|
370
597
|
captionEl.textContent = captionFor(snap.rows, snap.state, snap.here);
|
|
371
598
|
turnLabelEl.textContent = "turn: " + snap.turn;
|
|
372
599
|
renderPills(snap.rows, snap.state, snap.here);
|
|
600
|
+
renderCarrying(snap.rows, snap.state);
|
|
601
|
+
renderRoomMap(snap.rows, snap.state, snap.visitedRoomIds);
|
|
602
|
+
renderGoals(snap.rows, snap.state, snap.visitedRoomIds);
|
|
373
603
|
}
|
|
374
604
|
|
|
375
605
|
// ---- serialize every engine-touching call: the ticker and the chat dock
|