@polycode-projects/the-mechanical-code-talker 3.3.0 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +176 -8
- 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 +52 -0
- package/corpus/worlds/index.json.gz +0 -0
- package/corpus/worlds/manifest.json +53 -13
- 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-garden.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-garden.jsonl +34 -1
- 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/p2p/webrtc-transport.mjs +146 -0
- package/src/domain/game-config.mjs +26 -5
- package/src/domain/grammar/ace.mjs +40 -4
- package/src/domain/grammar/lexicon-core.json +2 -1
- package/src/domain/grammar/lexicon.mjs +31 -0
- package/src/domain/memory/trust.mjs +6 -4
- package/src/domain/p2p/facts.mjs +81 -0
- package/src/domain/p2p/peer-id.mjs +32 -0
- package/src/domain/p2p/provenance-relabel.mjs +26 -0
- package/src/domain/p2p/sync-filter.mjs +31 -0
- package/src/domain/p2p/wire.mjs +123 -0
- package/src/domain/reference-pack.mjs +31 -7
- package/src/domain/spider-fly-world.mjs +1 -1
- package/src/domain/sprite-map.mjs +10 -2
- package/src/domain/sprite-templates.mjs +8 -6
- package/src/services/adventure-editor.mjs +10 -2
- package/src/services/adventure-viz.mjs +309 -73
- package/src/services/adventure.mjs +1034 -330
- package/src/services/chat-page-viz.mjs +1097 -16
- package/src/services/chat.mjs +99 -18
- package/src/services/code-explorer-viz.mjs +52 -14
- package/src/services/extract-facts.mjs +4 -7
- package/src/services/ingest-viz.mjs +54 -26
- package/src/services/ledger-viz.mjs +105 -56
- package/src/services/memory-panel-viz.mjs +24 -0
- package/src/services/mud-editor.mjs +313 -0
- package/src/services/mud-turn.mjs +264 -87
- package/src/services/mud-viz.mjs +2606 -384
- package/src/services/p2p-room.mjs +559 -0
- package/src/services/plan-viz.mjs +119 -61
- package/src/services/research-viz.mjs +151 -57
- package/src/services/spider-fly-turn.mjs +1 -1
- package/src/services/spider-fly-viz.mjs +67 -43
- package/src/services/sprite-catalog-viz.mjs +175 -51
- package/src/services/viz-theme.mjs +15 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +109 -109
- package/src/surfaces/web/mud-browser-entry.mjs +226 -18
- package/src/surfaces/web/p2p-browser-entry.mjs +39 -0
|
@@ -33,7 +33,10 @@
|
|
|
33
33
|
// ancestor walk, then floor), `roomSceneLayout` (the room split into a wall
|
|
34
34
|
// band and floor stacks, over `roomSceneObjects` and `scenePlacement`),
|
|
35
35
|
// `roomKindForRoom` (a room's border treatment, from its own rdf:type),
|
|
36
|
-
// `carriedItems` (every object placed with the player),
|
|
36
|
+
// `carriedItems` (every object placed with the player), `exitDoorways` (the
|
|
37
|
+
// written ways out of one room, in compass order, each marked with whether
|
|
38
|
+
// this session has walked it — what the room view's own door plates are
|
|
39
|
+
// drawn from), and `visitedRoomGraph`
|
|
37
40
|
// (a directions-only layout of the rooms a session has actually visited —
|
|
38
41
|
// see its own header for the exposure discipline). None of these import
|
|
39
42
|
// anything beyond this module's own exports, which is what keeps a raw
|
|
@@ -81,7 +84,7 @@
|
|
|
81
84
|
// an edit implies run through the browser bundle's own `session.applyEdit`
|
|
82
85
|
// (adventure-browser-entry.mjs), never here — this module only renders and
|
|
83
86
|
// reads.
|
|
84
|
-
import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
|
|
87
|
+
import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, scenarioLabel } from "./viz-theme.mjs";
|
|
85
88
|
import { createTicker } from "./viz-ticker.mjs";
|
|
86
89
|
import { worldDigestRows, roomAffordances, foldWorldState } from "./adventure.mjs";
|
|
87
90
|
import { exposedFacts } from "./adventure-autoplay.mjs";
|
|
@@ -109,22 +112,41 @@ export function spriteClassForObject(rows, subject) {
|
|
|
109
112
|
return typeRow ? typeRow.object : "portable";
|
|
110
113
|
}
|
|
111
114
|
|
|
112
|
-
/** `rows` plus
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
115
|
+
/** `rows` plus synthetic rdfs:subClassOf edges covering `subject`'s whole
|
|
116
|
+
* class chain — never written back to the corpus, just handed to
|
|
117
|
+
* sprite-templates.mjs's resolver so a NAMED object (`cabinet`, `butler`)
|
|
118
|
+
* can carry its own sprite template while an unauthored object of the same
|
|
119
|
+
* class still falls back cleanly, one ancestor at a time, to the nearest
|
|
120
|
+
* ancestor that HAS a sprite. Two kinds of edge are synthesized: one from
|
|
121
|
+
* the subject's own name to its declared sprite class
|
|
122
|
+
* (spriteClassForObject's own result), and one mirroring every `rdf:type`
|
|
123
|
+
* hop further up the chain. The mirroring is what makes the fallback reach
|
|
124
|
+
* past the first hop: the resolver walks rdfs:subClassOf only, so a world
|
|
125
|
+
* that writes `turnip rdf:type vegetable` would otherwise stop at `turnip`
|
|
126
|
+
* and skip a perfectly good `vegetable` sprite. Ashcombe Hall's own objects
|
|
127
|
+
* have no rdfs:subClassOf chain at all (each is directly typed, e.g.
|
|
128
|
+
* `cabinet rdf:type furniture`); this is the same ancestor-walk mechanism
|
|
129
|
+
* spider-fly's real poodle-IsA-dog taxonomy already exercises, synthesized
|
|
130
|
+
* at render time for a world whose taxonomy doesn't reach that deep. Returns
|
|
131
|
+
* `rows` itself when there is nothing to synthesize. Pure. */
|
|
124
132
|
export function spriteAncestryRows(rows, subject) {
|
|
125
133
|
const declaredClass = spriteClassForObject(rows, subject);
|
|
126
|
-
|
|
127
|
-
|
|
134
|
+
const synthesized = [];
|
|
135
|
+
if (subject !== declaredClass) synthesized.push({ subject, predicate: "rdfs:subClassOf", object: declaredClass });
|
|
136
|
+
const seen = new Set([subject]);
|
|
137
|
+
const queue = [declaredClass];
|
|
138
|
+
while (queue.length) {
|
|
139
|
+
const node = queue.shift();
|
|
140
|
+
if (seen.has(node)) continue;
|
|
141
|
+
seen.add(node);
|
|
142
|
+
for (const row of rows || []) {
|
|
143
|
+
if (row.subject !== node || row.object === node) continue;
|
|
144
|
+
if (row.predicate === "rdf:type") synthesized.push({ subject: node, predicate: "rdfs:subClassOf", object: row.object });
|
|
145
|
+
else if (row.predicate !== "rdfs:subClassOf") continue;
|
|
146
|
+
queue.push(row.object);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return synthesized.length ? [...(rows || []), ...synthesized] : rows;
|
|
128
150
|
}
|
|
129
151
|
|
|
130
152
|
/** Every fact row belonging to `subject` — the small `{predicate, object}`
|
|
@@ -146,15 +168,21 @@ export function factsForSubject(rows, subject) {
|
|
|
146
168
|
* feeding it an exposure-filtered pair (as `goalStatusLines` does) is what
|
|
147
169
|
* keeps a not-yet-visited location unknown. Pure, self-contained — no
|
|
148
170
|
* reference to adventure.mjs's own private helpers, since those aren't
|
|
149
|
-
* exported.
|
|
150
|
-
|
|
171
|
+
* exported. `actingSubject` is the identity the world places as the
|
|
172
|
+
* carrier/viewer — a parameter, not a baked-in name, the same way
|
|
173
|
+
* adventure.mjs's own `runWorldCommand`/`roomAffordances` take it; it
|
|
174
|
+
* defaults to "player" because that's the one identity Ashcombe Hall's own
|
|
175
|
+
* world facts actually use, not because this function only ever means
|
|
176
|
+
* that individual (mud-viz.mjs threads its own per-character viewer through
|
|
177
|
+
* the same shape, see `mudRoomSceneObjects`/`carriedItemsFor`). */
|
|
178
|
+
export function visibleRoomOf(rows, state, subject, actingSubject = "player") {
|
|
151
179
|
const isRoom = (id) => (rows || []).some((r) => r.subject === id && r.predicate === "rdf:type" && r.object === "room");
|
|
152
180
|
if (isRoom(subject)) return subject;
|
|
153
181
|
const place = state.placements.get(subject);
|
|
154
182
|
if (!place || place.predicate === "mgx:hidden-in") return null;
|
|
155
183
|
if (place.predicate === "mgx:currently-in" || isRoom(place.object)) return place.object;
|
|
156
184
|
const holder = place.object;
|
|
157
|
-
if (holder ===
|
|
185
|
+
if (holder === actingSubject) return null;
|
|
158
186
|
if (!state.openness.get(holder)?.open) return null;
|
|
159
187
|
const holderPlace = state.placements.get(holder);
|
|
160
188
|
return holderPlace && holderPlace.predicate !== "mgx:hidden-in" ? holderPlace.object : null;
|
|
@@ -163,13 +191,14 @@ export function visibleRoomOf(rows, state, subject) {
|
|
|
163
191
|
/** Every subject actually visible in `here`, sorted, each with its sprite
|
|
164
192
|
* class — built over `visibleRoomOf` (one containment hop through an OPEN
|
|
165
193
|
* container) so this can never draw a hidden or carried object the text
|
|
166
|
-
* digest wouldn't also mention. `
|
|
167
|
-
* player's own adventurer sprite
|
|
168
|
-
|
|
194
|
+
* digest wouldn't also mention. `actingSubject` is excluded (default
|
|
195
|
+
* "player"); the caller draws that individual's own adventurer sprite
|
|
196
|
+
* separately. Pure. */
|
|
197
|
+
export function roomSceneObjects(rows, state, here, actingSubject = "player") {
|
|
169
198
|
const out = [];
|
|
170
199
|
for (const subject of [...state.placements.keys()].sort()) {
|
|
171
|
-
if (subject ===
|
|
172
|
-
if (visibleRoomOf(rows, state, subject) !== here) continue;
|
|
200
|
+
if (subject === actingSubject) continue;
|
|
201
|
+
if (visibleRoomOf(rows, state, subject, actingSubject) !== here) continue;
|
|
173
202
|
out.push({ subject, spriteClass: spriteClassForObject(rows, subject) });
|
|
174
203
|
}
|
|
175
204
|
return out;
|
|
@@ -307,13 +336,13 @@ export function roomKindForRoom(rows, roomId) {
|
|
|
307
336
|
return "indoor";
|
|
308
337
|
}
|
|
309
338
|
|
|
310
|
-
/** Every object currently `mgx:located-in`
|
|
311
|
-
* sprite class — the exact placement
|
|
312
|
-
* own "carries the" branch already
|
|
313
|
-
* instead of prose. Pure. */
|
|
314
|
-
export function carriedItems(rows, state) {
|
|
339
|
+
/** Every object currently `mgx:located-in` `actingSubject` (default
|
|
340
|
+
* "player"), sorted, each with its sprite class — the exact placement
|
|
341
|
+
* `worldDigestRows`'/`inventoryAnswer`'s own "carries the" branch already
|
|
342
|
+
* reads, just returned as a plain list instead of prose. Pure. */
|
|
343
|
+
export function carriedItems(rows, state, actingSubject = "player") {
|
|
315
344
|
return [...state.placements]
|
|
316
|
-
.filter(([, p]) => p.predicate === "mgx:located-in" && p.object ===
|
|
345
|
+
.filter(([, p]) => p.predicate === "mgx:located-in" && p.object === actingSubject)
|
|
317
346
|
.map(([subject]) => ({ subject, spriteClass: spriteClassForObject(rows, subject) }))
|
|
318
347
|
.sort((a, b) => a.subject.localeCompare(b.subject));
|
|
319
348
|
}
|
|
@@ -333,10 +362,10 @@ export function carriedItems(rows, state) {
|
|
|
333
362
|
* Disconnected visited rooms (not reachable from each other by traveled
|
|
334
363
|
* edges) lay out as separate side-by-side blocks rather than overlapping.
|
|
335
364
|
* Pure. */
|
|
336
|
-
export function visitedRoomGraph(state, visitedRoomIds) {
|
|
365
|
+
export function visitedRoomGraph(state, visitedRoomIds, actingSubject = "player") {
|
|
337
366
|
const DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
|
|
338
367
|
const visited = new Set(visitedRoomIds || []);
|
|
339
|
-
const here = state.placements.get(
|
|
368
|
+
const here = state.placements.get(actingSubject)?.object ?? null;
|
|
340
369
|
const positions = new Map();
|
|
341
370
|
const edges = [];
|
|
342
371
|
const edgeKeys = new Set();
|
|
@@ -415,6 +444,28 @@ export function goalStatusLines(rows, state, visitedRoomIds) {
|
|
|
415
444
|
});
|
|
416
445
|
}
|
|
417
446
|
|
|
447
|
+
/** Every written way out of `here`, in a fixed compass order, each with the
|
|
448
|
+
* room it leads to and whether this session has already walked there:
|
|
449
|
+
* `[{ direction, target, walked }]`. Only exits the world actually writes
|
|
450
|
+
* appear, so a doorway drawn from this list can never offer a `go` the
|
|
451
|
+
* engine would then refuse. `walked` reads the SAME visited set the manor
|
|
452
|
+
* map and the goal lines already do — the rooms the player has stood in, so
|
|
453
|
+
* marking a door as new tells them nothing they haven't earned. Ashcombe
|
|
454
|
+
* Hall teaches no `dig` action family, so unlike the burrow's own compass
|
|
455
|
+
* ring there is no second kind of way out to draw. Pure and
|
|
456
|
+
* `.toString()`-splice-safe. */
|
|
457
|
+
export function exitDoorways(state, here, visitedRoomIds) {
|
|
458
|
+
const compass = ["north", "east", "south", "west", "up", "down"];
|
|
459
|
+
const walked = new Set(visitedRoomIds || []);
|
|
460
|
+
const exits = state.exits.get(here);
|
|
461
|
+
if (!exits) return [];
|
|
462
|
+
return compass.filter((direction) => exits.has(direction)).map((direction) => ({
|
|
463
|
+
direction,
|
|
464
|
+
target: exits.get(direction),
|
|
465
|
+
walked: walked.has(exits.get(direction)),
|
|
466
|
+
}));
|
|
467
|
+
}
|
|
468
|
+
|
|
418
469
|
/** The clickable command suggestions for the room the player is CURRENTLY
|
|
419
470
|
* in — a thin, testable wrapper over adventure.mjs's own `roomAffordances`
|
|
420
471
|
* (the exact same data take/open/talk/examine already check), so a pill can
|
|
@@ -514,12 +565,16 @@ export function roomCaptionText(rows, state, here) {
|
|
|
514
565
|
export function renderAdventureHtml({
|
|
515
566
|
title = DEFAULT_TITLE,
|
|
516
567
|
worldPayload = { facts: [], rules: [], opening: "" },
|
|
568
|
+
scenarios = [],
|
|
517
569
|
spriteTemplates = [],
|
|
518
570
|
largeSpriteTemplates = [],
|
|
519
571
|
engineBundleJs = "",
|
|
520
572
|
} = {}) {
|
|
573
|
+
const scenarioList = scenarios.length
|
|
574
|
+
? scenarios
|
|
575
|
+
: [{ label: scenarioLabel(worldPayload?.name), worldPayload }];
|
|
521
576
|
const pageData = embedJson({
|
|
522
|
-
|
|
577
|
+
scenarios: scenarioList,
|
|
523
578
|
previewMaxTicks: PREVIEW_MAX_TICKS,
|
|
524
579
|
tickWaitMs: TICK_WAIT_MS,
|
|
525
580
|
spriteTemplates,
|
|
@@ -667,10 +722,72 @@ ${THEME_TOKENS_CSS}
|
|
|
667
722
|
falls back through room's own ancestor chain to the generic room icon).
|
|
668
723
|
Rendered at 120px — twice a room-object sprite's own 60px frame
|
|
669
724
|
(.sprite-frame below) — so the room itself reads as the biggest thing
|
|
670
|
-
drawn in its own scene.
|
|
671
|
-
|
|
725
|
+
drawn in its own scene. It takes the wall's own brass rather than the
|
|
726
|
+
page ink: at that size an ink-black icon out-shouts every person and
|
|
727
|
+
prop standing in front of it, and this is a mark ON the wall, not
|
|
728
|
+
another thing in the room. */
|
|
729
|
+
.room-kind-icon { position: absolute; top: .5rem; right: .6rem; width: 120px; height: 120px; color: var(--gilt); opacity: .5; }
|
|
672
730
|
.room-kind-icon svg { width: 100%; height: 100%; display: block; }
|
|
673
731
|
.room-kind-icon:empty { display: none; }
|
|
732
|
+
/* the doorways — every written way out drawn where it actually points,
|
|
733
|
+
as a brass door plate set into the wall it leads through. The plate
|
|
734
|
+
borrows the room plaque's own treatment above (parchment ground, gilt
|
|
735
|
+
rule, inset mat): a manor has one brass vocabulary, and a door plate and
|
|
736
|
+
a room plate are the same object. North and south sit in a band OUTSIDE
|
|
737
|
+
the frame so neither fights the plaque or the room-kind icon; west and
|
|
738
|
+
east sit just inside their own walls. Stairs get a round chip instead —
|
|
739
|
+
neither up nor down is a compass point, and neither hangs on a wall. */
|
|
740
|
+
.room-stage { position: relative; padding: 1.85rem 0; }
|
|
741
|
+
/* the "ways out" eyebrow — this ring is the ONLY place movement controls
|
|
742
|
+
exist on the page (the pill row deliberately drops every "go", see
|
|
743
|
+
renderPills), so a first-time player needs a named affordance here, not
|
|
744
|
+
just brass plates they have to guess at. */
|
|
745
|
+
.ways-out-label {
|
|
746
|
+
font-family: ${MONO_STACK}; font-size: .68rem; letter-spacing: .14em; text-transform: uppercase;
|
|
747
|
+
color: var(--gilt); opacity: .85; margin: 0 0 -.3rem .1rem;
|
|
748
|
+
}
|
|
749
|
+
.dir-ring { position: absolute; inset: 0; pointer-events: none; }
|
|
750
|
+
.dir-slot { position: absolute; pointer-events: auto; }
|
|
751
|
+
.dir-north { top: 0; left: 50%; transform: translateX(-50%); }
|
|
752
|
+
.dir-south { bottom: 0; left: 50%; transform: translateX(-50%); }
|
|
753
|
+
.dir-west { left: .4rem; top: 50%; transform: translateY(-50%); }
|
|
754
|
+
.dir-east { right: .4rem; top: 50%; transform: translateY(-50%); }
|
|
755
|
+
/* the stairs stand at the left of each band: the frame's own corner
|
|
756
|
+
flourishes sit top-left and bottom-right, and this is the one inset that
|
|
757
|
+
clears both. */
|
|
758
|
+
.dir-up { top: 0; left: 1.5rem; }
|
|
759
|
+
.dir-down { bottom: 0; left: 1.5rem; }
|
|
760
|
+
/* a brass PUSH-plate, not a name plate: real button chrome (an outer drop
|
|
761
|
+
shadow, not just the inset highlight the static room/door plaques use),
|
|
762
|
+
a bigger touch target, and a lift on hover/press — so this reads as
|
|
763
|
+
clickable at a glance instead of blending into the wallpaper the way a
|
|
764
|
+
plain brass label would. The leading triangle is a direction-neutral
|
|
765
|
+
"go" cue, the same role an arrow plays on any door-push icon. */
|
|
766
|
+
.dir-door {
|
|
767
|
+
position: relative; display: inline-flex; align-items: center; justify-content: center; gap: .3rem;
|
|
768
|
+
font-family: ${MONO_STACK}; font-size: .74rem; font-weight: 600; letter-spacing: .08em; text-transform: uppercase;
|
|
769
|
+
color: var(--gilt); background: var(--parchment); border: 1px solid var(--gilt); border-radius: 3px;
|
|
770
|
+
box-shadow: inset 0 0 0 2px var(--parchment-strong), 0 2px 4px rgba(0, 0, 0, .25);
|
|
771
|
+
padding: .5rem .95rem; white-space: nowrap;
|
|
772
|
+
transition: transform .08s ease, box-shadow .08s ease, background .08s ease, color .08s ease;
|
|
773
|
+
}
|
|
774
|
+
.dir-door::before { content: "\\25B8"; font-size: .62em; opacity: .9; }
|
|
775
|
+
.dir-door:hover, .dir-door:focus-visible {
|
|
776
|
+
background: var(--gilt); color: var(--parchment);
|
|
777
|
+
box-shadow: inset 0 0 0 2px var(--parchment-strong), 0 4px 7px rgba(0, 0, 0, .32);
|
|
778
|
+
transform: translateY(-1.5px);
|
|
779
|
+
}
|
|
780
|
+
.dir-door:active {
|
|
781
|
+
transform: translateY(0);
|
|
782
|
+
box-shadow: inset 0 0 0 2px var(--parchment-strong), 0 1px 2px rgba(0, 0, 0, .28);
|
|
783
|
+
}
|
|
784
|
+
.dir-door.stair { border-radius: 50%; width: 2.15rem; height: 2.15rem; padding: 0; font-size: .86rem; letter-spacing: 0; }
|
|
785
|
+
.dir-door.stair::before { content: ""; }
|
|
786
|
+
/* a door onto a room this session has not stood in yet, marked with the
|
|
787
|
+
same gilt dot the manor board prints for a direction it cannot draw a
|
|
788
|
+
room for — one hint shape, two surfaces. */
|
|
789
|
+
.dir-door .unwalked { position: absolute; top: -.2rem; right: -.2rem; width: .4rem; height: .4rem; border-radius: 50%; background: var(--gilt); border: 1px solid var(--parchment); }
|
|
790
|
+
|
|
674
791
|
.sprite-row { display: flex; align-items: flex-end; gap: .9rem .7rem; min-height: 2.5rem; }
|
|
675
792
|
/* the floor band: every non-wall-mounted stack, wrapping and pinned to the
|
|
676
793
|
left edge of the room, as the flat sprite row always has. */
|
|
@@ -849,6 +966,9 @@ ${THEME_TOKENS_CSS}
|
|
|
849
966
|
.controls-row button { font-family: ${MONO_STACK}; font-size: .72rem; letter-spacing: .05em; text-transform: uppercase; padding: .38rem .85rem; border: 1px solid var(--gilt); background: var(--parchment); color: var(--ink); }
|
|
850
967
|
.controls-row button:hover:not(:disabled) { background: var(--parchment-strong); }
|
|
851
968
|
.controls-row button:disabled { opacity: .4; cursor: default; }
|
|
969
|
+
.controls-row select { font-family: ${MONO_STACK}; font-size: .72rem; letter-spacing: .05em; text-transform: uppercase; padding: .38rem .85rem; border: 1px solid var(--gilt); background: var(--parchment); color: var(--ink); }
|
|
970
|
+
.controls-row select:hover:not(:disabled) { background: var(--parchment-strong); }
|
|
971
|
+
.controls-row select:disabled { opacity: .4; cursor: default; }
|
|
852
972
|
.controls-row .turn { margin-left: auto; font-family: ${MONO_STACK}; font-size: .72rem; letter-spacing: .05em; text-transform: uppercase; color: var(--ink); background: var(--parchment); border: 1px solid var(--gilt); padding: .3rem .6rem; font-variant-numeric: tabular-nums; }
|
|
853
973
|
.goal-line { font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); margin-top: .5rem; }
|
|
854
974
|
.status { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); margin-top: .3rem; }
|
|
@@ -874,6 +994,10 @@ ${THEME_TOKENS_CSS}
|
|
|
874
994
|
#legendList { display: flex; flex-wrap: wrap; gap: .5rem .3rem; }
|
|
875
995
|
|
|
876
996
|
body.preview .side, body.preview .stage-left > .panel, body.preview .controls-row, body.preview .status { display: none; }
|
|
997
|
+
/* the hero iframe is a picture of a room, not a control surface — every
|
|
998
|
+
other control is hidden there, and the doorways go with them. */
|
|
999
|
+
body.preview .room-stage { padding: 0; }
|
|
1000
|
+
body.preview .dir-ring { display: none; }
|
|
877
1001
|
body.preview main { padding: 0; max-width: none; }
|
|
878
1002
|
body.preview .stage { display: block; }
|
|
879
1003
|
body.preview .eyebrow, body.preview .mode-toggle, body.preview #editStage, body.preview .page-note { display: none; }
|
|
@@ -885,11 +1009,14 @@ ${THEME_TOKENS_CSS}
|
|
|
885
1009
|
<div class="eyebrow">tmct · the adventure</div>
|
|
886
1010
|
<button id="editModeBtn" type="button" class="mode-toggle" disabled>edit the world</button>
|
|
887
1011
|
</div>
|
|
888
|
-
<p class="page-note">${escapeHtml(worldPayload.opening)}</p>
|
|
1012
|
+
<p class="page-note" id="pageNote">${escapeHtml(scenarioList[0].worldPayload.opening || "")}</p>
|
|
889
1013
|
<div class="stage" id="playStage">
|
|
890
1014
|
<div class="stage-left">
|
|
891
1015
|
<div class="controls-row" id="playControls">
|
|
892
1016
|
<button id="resetBtn" type="button" disabled>reset</button>
|
|
1017
|
+
${scenarioList.length > 1 ? ` <select id="scenarioSelect" aria-label="which world to play" disabled>
|
|
1018
|
+
${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " selected" : ""}>${escapeHtml(s.label || scenarioLabel(s.worldPayload?.name))}</option>`).join("\n")}
|
|
1019
|
+
</select>` : ""}
|
|
893
1020
|
<button id="playBtn" type="button" disabled>▶ play</button>
|
|
894
1021
|
<button id="stepBtn" type="button" disabled>step</button>
|
|
895
1022
|
<span class="turn mono" id="turnLabel">turn: 0</span>
|
|
@@ -904,13 +1031,24 @@ ${THEME_TOKENS_CSS}
|
|
|
904
1031
|
<h2>satchel</h2>
|
|
905
1032
|
<div class="chips" id="carryList"></div>
|
|
906
1033
|
</div>
|
|
907
|
-
<div class="
|
|
908
|
-
|
|
909
|
-
<div class="room-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
<div class="
|
|
913
|
-
<div class="
|
|
1034
|
+
<div class="ways-out-label" aria-hidden="true">ways out</div>
|
|
1035
|
+
<div class="room-stage">
|
|
1036
|
+
<div class="room-frame" id="roomFrame">
|
|
1037
|
+
<div class="room-plaque mono" id="roomName"></div>
|
|
1038
|
+
<div class="room-kind-icon" id="roomKindIcon"></div>
|
|
1039
|
+
<div class="wall-row" id="wallRow"></div>
|
|
1040
|
+
<div class="sprite-row" id="spriteRow">
|
|
1041
|
+
<div class="floor-row" id="floorRow"></div>
|
|
1042
|
+
<div class="you-slot" id="youSlot"></div>
|
|
1043
|
+
</div>
|
|
1044
|
+
</div>
|
|
1045
|
+
<div class="dir-ring" id="dirRing" aria-label="the ways out of this room">
|
|
1046
|
+
<div class="dir-slot dir-north" id="dir-north"></div>
|
|
1047
|
+
<div class="dir-slot dir-east" id="dir-east"></div>
|
|
1048
|
+
<div class="dir-slot dir-south" id="dir-south"></div>
|
|
1049
|
+
<div class="dir-slot dir-west" id="dir-west"></div>
|
|
1050
|
+
<div class="dir-slot dir-up" id="dir-up"></div>
|
|
1051
|
+
<div class="dir-slot dir-down" id="dir-down"></div>
|
|
914
1052
|
</div>
|
|
915
1053
|
</div>
|
|
916
1054
|
<div class="panel command">
|
|
@@ -995,15 +1133,23 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
995
1133
|
const visitedRoomGraph = ${visitedRoomGraph.toString()};
|
|
996
1134
|
const allRoomIds = ${allRoomIds.toString()};
|
|
997
1135
|
const groundedPlaceholder = ${groundedPlaceholder.toString()};
|
|
1136
|
+
const exitDoorways = ${exitDoorways.toString()};
|
|
998
1137
|
const spriteAncestryRows = ${spriteAncestryRows.toString()};
|
|
999
1138
|
const factsForSubject = ${factsForSubject.toString()};
|
|
1000
1139
|
const renderWorldEditorText = ${renderWorldEditorText.toString()};
|
|
1001
1140
|
const wordBeforeCursor = ${wordBeforeCursor.toString()};
|
|
1002
1141
|
const esc = ${escapeHtml.toString()};
|
|
1142
|
+
// The identity Ashcombe Hall's own world facts place as the carrier/viewer
|
|
1143
|
+
// — one named constant standing in for what was six separate "player"
|
|
1144
|
+
// string literals scattered through the room/inventory/map drawing calls
|
|
1145
|
+
// below, so a future scenario shipping a differently-named single
|
|
1146
|
+
// character never falls out of step with the calls that use it.
|
|
1147
|
+
const ACTING_SUBJECT = "player";
|
|
1003
1148
|
const el = (id) => document.getElementById(id);
|
|
1004
1149
|
const roomFrameEl = el("roomFrame");
|
|
1005
1150
|
const roomNameEl = el("roomName");
|
|
1006
1151
|
const roomKindIconEl = el("roomKindIcon");
|
|
1152
|
+
const dirRingEl = el("dirRing");
|
|
1007
1153
|
const wallRowEl = el("wallRow");
|
|
1008
1154
|
const floorRowEl = el("floorRow");
|
|
1009
1155
|
const youSlotEl = el("youSlot");
|
|
@@ -1042,6 +1188,15 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1042
1188
|
const roomDetailSpritesEl = el("roomDetailSprites");
|
|
1043
1189
|
const roomDetailCaptionEl = el("roomDetailCaption");
|
|
1044
1190
|
const legendListEl = el("legendList");
|
|
1191
|
+
const scenarioSelectEl = el("scenarioSelect");
|
|
1192
|
+
const pageNoteEl = el("pageNote");
|
|
1193
|
+
|
|
1194
|
+
// Which of the shipped worlds is loaded. Every read of the world — the
|
|
1195
|
+
// session it is opened over, its opening line, the provenance prefix edit
|
|
1196
|
+
// mode filters on, the key its progress is saved under — goes through this
|
|
1197
|
+
// one index, so picking another world needs no second copy of any of them.
|
|
1198
|
+
let worldIndex = 0;
|
|
1199
|
+
const world = () => ADVENTURE.scenarios[worldIndex].worldPayload;
|
|
1045
1200
|
|
|
1046
1201
|
const params = new URLSearchParams(location.search);
|
|
1047
1202
|
const preview = params.get("preview") === "1";
|
|
@@ -1171,12 +1326,12 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1171
1326
|
// holding it has actually been visited this session.
|
|
1172
1327
|
function goalStatusLinesFor(rows, state, visitedRoomIds) {
|
|
1173
1328
|
const ids = Array.from(new Set(rows.filter((r) => r.predicate === "mgx:is-objective" && r.object === "true").map((r) => r.subject)));
|
|
1174
|
-
const carriedIds = new Set(carriedItems(rows, state).map((o) => o.subject));
|
|
1329
|
+
const carriedIds = new Set(carriedItems(rows, state, ACTING_SUBJECT).map((o) => o.subject));
|
|
1175
1330
|
const exposedRows = tmctAdventure.exposedFacts(rows, visitedRoomIds);
|
|
1176
1331
|
const exposedState = tmctAdventure.foldWorldState(exposedRows);
|
|
1177
1332
|
return ids.map((id) => {
|
|
1178
1333
|
if (carriedIds.has(id)) return { subject: id, status: "carried", text: "carrying the " + id + " \\u2014 the adventure is won." };
|
|
1179
|
-
const room = visibleRoomOf(exposedRows, exposedState, id);
|
|
1334
|
+
const room = visibleRoomOf(exposedRows, exposedState, id, ACTING_SUBJECT);
|
|
1180
1335
|
return room
|
|
1181
1336
|
? { subject: id, status: "known", text: "last known: the " + id + " is in the " + room + "." }
|
|
1182
1337
|
: { subject: id, status: "unknown", text: "there's a sought-after " + id + " somewhere." };
|
|
@@ -1187,7 +1342,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1187
1342
|
// the same non-interactive chip shape the room's own pills use for
|
|
1188
1343
|
// actions, so the sidebar reads as one visual family.
|
|
1189
1344
|
function renderCarrying(rows, state) {
|
|
1190
|
-
const items = carriedItems(rows, state);
|
|
1345
|
+
const items = carriedItems(rows, state, ACTING_SUBJECT);
|
|
1191
1346
|
carryListEl.innerHTML = items.length
|
|
1192
1347
|
? items.map((o) => '<span class="chip">' + esc(o.subject) + "</span>").join("")
|
|
1193
1348
|
: '<span class="empty-note">nothing yet</span>';
|
|
@@ -1232,7 +1387,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1232
1387
|
+ edgesSvg + hintsSvg + nodesSvg + "</svg>";
|
|
1233
1388
|
}
|
|
1234
1389
|
function renderRoomMap(rows, state, visitedRoomIds) {
|
|
1235
|
-
mapWrapEl.innerHTML = roomMapSvg(visitedRoomGraph(state, visitedRoomIds), false) || '<span class="empty-note">nowhere yet</span>';
|
|
1390
|
+
mapWrapEl.innerHTML = roomMapSvg(visitedRoomGraph(state, visitedRoomIds, ACTING_SUBJECT), false) || '<span class="empty-note">nowhere yet</span>';
|
|
1236
1391
|
}
|
|
1237
1392
|
|
|
1238
1393
|
// ---- the map lightbox — clicking the fixed-square play-mode map redraws
|
|
@@ -1240,7 +1395,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1240
1395
|
// backdrop (not the board itself) or pressing Escape closes it.
|
|
1241
1396
|
function openMapLightbox() {
|
|
1242
1397
|
if (!lastSnapshot) return;
|
|
1243
|
-
const svg = roomMapSvg(visitedRoomGraph(lastSnapshot.state, lastSnapshot.visitedRoomIds), false);
|
|
1398
|
+
const svg = roomMapSvg(visitedRoomGraph(lastSnapshot.state, lastSnapshot.visitedRoomIds, ACTING_SUBJECT), false);
|
|
1244
1399
|
if (!svg) return;
|
|
1245
1400
|
mapLightboxInnerEl.innerHTML = svg;
|
|
1246
1401
|
mapLightboxEl.hidden = false;
|
|
@@ -1348,7 +1503,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1348
1503
|
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !objLightboxEl.hidden) closeObjectLightbox(); });
|
|
1349
1504
|
|
|
1350
1505
|
function renderEditMap(rows, state) {
|
|
1351
|
-
editMapWrapEl.innerHTML = roomMapSvg(visitedRoomGraph(state, allRoomIds(rows)), true) || '<span class="empty-note">this world defines no rooms</span>';
|
|
1506
|
+
editMapWrapEl.innerHTML = roomMapSvg(visitedRoomGraph(state, allRoomIds(rows), ACTING_SUBJECT), true) || '<span class="empty-note">this world defines no rooms</span>';
|
|
1352
1507
|
}
|
|
1353
1508
|
editMapWrapEl.addEventListener("click", (e) => {
|
|
1354
1509
|
const g = e.target.closest("[data-room]");
|
|
@@ -1386,11 +1541,16 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1386
1541
|
// Clicking one inserts its exact command text into the input and focuses
|
|
1387
1542
|
// it; it never auto-submits, so free typing still works and a clicked
|
|
1388
1543
|
// suggestion can still be edited first.
|
|
1544
|
+
//
|
|
1545
|
+
// The row shows what to do with the things IN the room; the doorways above
|
|
1546
|
+
// own every way OUT of it. A "go" command is the one affordance no player
|
|
1547
|
+
// ever wants to edit before running, and drawing it in both places would
|
|
1548
|
+
// make one control a suggestion and its twin an action.
|
|
1389
1549
|
function pillsFor(rows, state, here) {
|
|
1390
1550
|
return tmctAdventure.roomAffordances(rows, state, here);
|
|
1391
1551
|
}
|
|
1392
1552
|
function renderPills(rows, state, here) {
|
|
1393
|
-
const actions = pillsFor(rows, state, here);
|
|
1553
|
+
const actions = pillsFor(rows, state, here).filter((a) => a.indexOf("go ") !== 0);
|
|
1394
1554
|
pillsEl.innerHTML = actions.map((a) => '<button type="button" class="pill">' + esc(a) + "</button>").join("");
|
|
1395
1555
|
// The empty input teaches the grounded noun form off the same affordance
|
|
1396
1556
|
// list the pills read — real props from THIS room, so a first-time player
|
|
@@ -1422,11 +1582,19 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1422
1582
|
// stay keyed on that declared class unchanged. The player's own "you" row
|
|
1423
1583
|
// has no backing fact row, so it resolves directly by its fixed
|
|
1424
1584
|
// "adventurer" class with no ancestry/property rows to check.
|
|
1585
|
+
//
|
|
1586
|
+
// When the whole chain misses, the root the resolver lands on is chosen by
|
|
1587
|
+
// what KIND of thing the class is, never the resolver's own "animal"
|
|
1588
|
+
// default: an unknown prop in a manor should read as a plain parcel and an
|
|
1589
|
+
// unknown character as a figure, and neither should ever draw a creature.
|
|
1590
|
+
const ROOT_FALLBACK_BY_CLASS = { adventurer: "person", person: "person", room: "room" };
|
|
1591
|
+
const rootFallbackFor = (cls) => ROOT_FALLBACK_BY_CLASS[cls] || "portable";
|
|
1425
1592
|
function resolveObjectSprite(rows, s) {
|
|
1426
|
-
if (s.subject === "you") return tmctAdventure.resolveSpriteAsset("adventurer", [], [], activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY, { instanceKey: "you" });
|
|
1593
|
+
if (s.subject === "you") return tmctAdventure.resolveSpriteAsset("adventurer", [], [], activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY, { rootFallback: "person", instanceKey: "you" });
|
|
1427
1594
|
return tmctAdventure.resolveSpriteAsset(
|
|
1428
1595
|
s.subject, spriteAncestryRows(rows, s.subject), factsForSubject(rows, s.subject),
|
|
1429
|
-
activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY,
|
|
1596
|
+
activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY,
|
|
1597
|
+
{ rootFallback: rootFallbackFor(s.spriteClass), instanceKey: s.subject },
|
|
1430
1598
|
);
|
|
1431
1599
|
}
|
|
1432
1600
|
|
|
@@ -1438,10 +1606,38 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1438
1606
|
function roomKindIconSvg(rows, here) {
|
|
1439
1607
|
return tmctAdventure.resolveSpriteAsset(
|
|
1440
1608
|
here, spriteAncestryRows(rows, here), factsForSubject(rows, here),
|
|
1441
|
-
activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY,
|
|
1609
|
+
activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY,
|
|
1610
|
+
{ rootFallback: "room", instanceKey: "roomkind-" + here },
|
|
1442
1611
|
);
|
|
1443
1612
|
}
|
|
1444
1613
|
|
|
1614
|
+
// ---- the doorways — one brass plate per written exit, in the slot that
|
|
1615
|
+
// exit actually points at, drawn from exitDoorways over the SAME snapshot
|
|
1616
|
+
// the room scene reads. A direction the world writes no exit for draws
|
|
1617
|
+
// nothing at all, so the ring never offers a "go" the engine would refuse.
|
|
1618
|
+
const STAIR_GLYPH = { up: "\\u2191", down: "\\u2193" };
|
|
1619
|
+
function renderDoorways(state, here, visitedRoomIds) {
|
|
1620
|
+
const bySlot = {};
|
|
1621
|
+
exitDoorways(state, here, visitedRoomIds).forEach((d) => { bySlot[d.direction] = d; });
|
|
1622
|
+
for (const direction of ["north", "east", "south", "west", "up", "down"]) {
|
|
1623
|
+
const slot = el("dir-" + direction);
|
|
1624
|
+
const doorway = bySlot[direction];
|
|
1625
|
+
if (!doorway) { slot.innerHTML = ""; continue; }
|
|
1626
|
+
const stair = direction === "up" || direction === "down";
|
|
1627
|
+
const label = "go " + direction + (doorway.walked ? "" : " (not walked yet)");
|
|
1628
|
+
slot.innerHTML = '<button type="button" class="dir-door' + (stair ? " stair" : "") + '"'
|
|
1629
|
+
+ ' data-direction="' + esc(direction) + '" title="' + esc(label) + '" aria-label="' + esc(label) + '">'
|
|
1630
|
+
+ (stair ? STAIR_GLYPH[direction] : esc(direction))
|
|
1631
|
+
+ (doorway.walked ? "" : '<span class="unwalked" aria-hidden="true"></span>') + "</button>";
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
// A door is not a suggestion to edit — clicking one walks through it.
|
|
1635
|
+
dirRingEl.addEventListener("click", (e) => {
|
|
1636
|
+
const btn = e.target.closest(".dir-door");
|
|
1637
|
+
if (!btn || !session) return;
|
|
1638
|
+
runTurn("go " + btn.getAttribute("data-direction"));
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1445
1641
|
function redraw(snap) {
|
|
1446
1642
|
lastSnapshot = snap;
|
|
1447
1643
|
const layout = roomSceneLayout(snap.rows, snap.state, snap.here);
|
|
@@ -1460,27 +1656,34 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1460
1656
|
captionEl.textContent = captionFor(snap.rows, snap.state, snap.here);
|
|
1461
1657
|
turnLabelEl.textContent = "turn: " + snap.turn;
|
|
1462
1658
|
renderPills(snap.rows, snap.state, snap.here);
|
|
1659
|
+
renderDoorways(snap.state, snap.here, snap.visitedRoomIds);
|
|
1463
1660
|
renderCarrying(snap.rows, snap.state);
|
|
1464
1661
|
renderRoomMap(snap.rows, snap.state, snap.visitedRoomIds);
|
|
1465
1662
|
renderGoals(snap.rows, snap.state, snap.visitedRoomIds);
|
|
1466
1663
|
schedulePersistSave();
|
|
1467
1664
|
}
|
|
1468
1665
|
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
// A manual command lands mid-tick otherwise — pause first, and stay
|
|
1475
|
-
// paused until the visitor presses play again.
|
|
1666
|
+
// Every command a visitor issues by hand — typed, or walked through a
|
|
1667
|
+
// doorway — runs here, so the transcript, the pause and the redraw are the
|
|
1668
|
+
// same for both. A manual command lands mid-tick otherwise, so this pauses
|
|
1669
|
+
// first, and stays paused until the visitor presses play again.
|
|
1670
|
+
function runTurn(line) {
|
|
1476
1671
|
ticker.pause();
|
|
1477
|
-
addChatLine("u", esc(
|
|
1478
|
-
withLock(async () => {
|
|
1479
|
-
const result = await session.turn(
|
|
1672
|
+
addChatLine("u", esc(line));
|
|
1673
|
+
return withLock(async () => {
|
|
1674
|
+
const result = await session.turn(line);
|
|
1480
1675
|
addChatLine("a", esc(result.answer).replace(/\\n/g, "<br>"));
|
|
1481
1676
|
const snap = await session.snapshot();
|
|
1482
1677
|
redraw(snap);
|
|
1483
1678
|
});
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
chatformEl.addEventListener("submit", (e) => {
|
|
1682
|
+
e.preventDefault();
|
|
1683
|
+
const q = chatqEl.value.trim();
|
|
1684
|
+
if (!q || !session) return;
|
|
1685
|
+
chatqEl.value = "";
|
|
1686
|
+
runTurn(q);
|
|
1484
1687
|
});
|
|
1485
1688
|
|
|
1486
1689
|
// ---- edit mode ---------------------------------------------------------
|
|
@@ -1498,7 +1701,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1498
1701
|
// diff + write; this page only ever reads its result back.
|
|
1499
1702
|
|
|
1500
1703
|
function worldOnlyRows(rows) {
|
|
1501
|
-
const prefix = "world:" +
|
|
1704
|
+
const prefix = "world:" + world().name;
|
|
1502
1705
|
return (rows || []).filter((r) => typeof r.provenance === "string" && r.provenance.indexOf(prefix) === 0);
|
|
1503
1706
|
}
|
|
1504
1707
|
|
|
@@ -1512,7 +1715,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1512
1715
|
}
|
|
1513
1716
|
roomDetailPanelEl.setAttribute("data-room-kind", roomKindForRoom(editRows, selectedRoomId));
|
|
1514
1717
|
roomDetailTitleEl.textContent = selectedRoomId;
|
|
1515
|
-
const objects = roomSceneObjects(editRows, editState, selectedRoomId);
|
|
1718
|
+
const objects = roomSceneObjects(editRows, editState, selectedRoomId, ACTING_SUBJECT);
|
|
1516
1719
|
roomDetailSpritesEl.innerHTML = objects.length
|
|
1517
1720
|
? objects.map((o) => spriteCardHtml(o.subject, o.spriteClass, resolveObjectSprite(editRows, o))).join("")
|
|
1518
1721
|
: '<span class="empty-note">nothing placed here yet</span>';
|
|
@@ -1522,12 +1725,20 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1522
1725
|
// ---- legend — every known object/character class this world actually
|
|
1523
1726
|
// uses, by its real icon. Scoped to worldOnlyRows so the background
|
|
1524
1727
|
// corpus's own (unrelated) rdf:type vocabulary never floods the list.
|
|
1728
|
+
// Each class resolves through the SAME ancestry walk and kind-aware root
|
|
1729
|
+
// the room's own cards use, so a class an edited world invents climbs to
|
|
1730
|
+
// the nearest ancestor that has a picture instead of dropping straight to
|
|
1731
|
+
// a generic one.
|
|
1525
1732
|
function renderLegend(rows) {
|
|
1526
1733
|
const subjects = new Set(rows.filter((r) => r.predicate === "rdf:type").map((r) => r.subject));
|
|
1527
1734
|
const classes = new Set(["adventurer"]);
|
|
1528
|
-
subjects.forEach((s) => { if (s !==
|
|
1735
|
+
subjects.forEach((s) => { if (s !== ACTING_SUBJECT) classes.add(spriteClassForObject(rows, s)); });
|
|
1529
1736
|
legendListEl.innerHTML = Array.from(classes).sort().map((cls) => {
|
|
1530
|
-
const svg = tmctAdventure.resolveSpriteAsset(
|
|
1737
|
+
const svg = tmctAdventure.resolveSpriteAsset(
|
|
1738
|
+
cls, spriteAncestryRows(rows, cls), factsForSubject(rows, cls),
|
|
1739
|
+
activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY,
|
|
1740
|
+
{ rootFallback: rootFallbackFor(cls), instanceKey: "legend-" + cls },
|
|
1741
|
+
);
|
|
1531
1742
|
return spriteCardHtml(cls, cls, svg);
|
|
1532
1743
|
}).join("");
|
|
1533
1744
|
}
|
|
@@ -1639,7 +1850,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1639
1850
|
async function boot({ fresh = false } = {}) {
|
|
1640
1851
|
const saved = !fresh && persist ? await persist.load() : null;
|
|
1641
1852
|
session = await tmctAdventure.createAdventureSession(
|
|
1642
|
-
|
|
1853
|
+
world(),
|
|
1643
1854
|
saved && saved.payload && saved.payload.memoryPayload
|
|
1644
1855
|
? { restoredPayload: saved.payload.memoryPayload, restoredVisitedRoomIds: saved.payload.visitedRoomIds }
|
|
1645
1856
|
: {},
|
|
@@ -1650,12 +1861,14 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1650
1861
|
goalLineEl.textContent = "";
|
|
1651
1862
|
chatlogEl.innerHTML = "";
|
|
1652
1863
|
statusEl.textContent = "";
|
|
1653
|
-
|
|
1864
|
+
if (pageNoteEl) pageNoteEl.textContent = world().opening || "";
|
|
1865
|
+
addChatLine("t", esc(world().opening || "the adventure begins."));
|
|
1654
1866
|
if (saved && snap.turn > 0) {
|
|
1655
1867
|
addChatLine("t", "resumed where you left off (turn " + snap.turn + ") \\u2014 progress kept best-effort on this device; reset starts the manor over.");
|
|
1656
1868
|
}
|
|
1657
1869
|
chatqEl.disabled = false;
|
|
1658
1870
|
resetBtn.disabled = false; playBtn.disabled = false; stepBtn.disabled = false; editModeBtn.disabled = false;
|
|
1871
|
+
if (scenarioSelectEl) scenarioSelectEl.disabled = false;
|
|
1659
1872
|
}
|
|
1660
1873
|
|
|
1661
1874
|
const ticker = createTicker({
|
|
@@ -1690,11 +1903,34 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1690
1903
|
stepBtn.addEventListener("click", () => ticker.stepOnce());
|
|
1691
1904
|
resetBtn.addEventListener("click", () => ticker.reset());
|
|
1692
1905
|
|
|
1906
|
+
// Each world keeps its own saved progress, so the stamp carries the world's
|
|
1907
|
+
// name and a switch opens the store belonging to the world being switched
|
|
1908
|
+
// to. Without that, one world's snapshot would restore into another's graph.
|
|
1909
|
+
let siteVersion = "";
|
|
1910
|
+
async function openPersistFor(worldName) {
|
|
1911
|
+
if (preview || !tmctAdventure.openPersistedStore) return;
|
|
1912
|
+
if (!siteVersion) siteVersion = await fetchSiteVersion();
|
|
1913
|
+
persist = tmctAdventure.openPersistedStore({ storeKey: "adventure", stamp: siteVersion + ":" + worldName });
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
if (scenarioSelectEl) {
|
|
1917
|
+
scenarioSelectEl.addEventListener("change", () => withLock(async () => {
|
|
1918
|
+
const picked = Number(scenarioSelectEl.value);
|
|
1919
|
+
if (!ADVENTURE.scenarios[picked] || picked === worldIndex) return;
|
|
1920
|
+
ticker.pause();
|
|
1921
|
+
// The pending save belongs to the world being left, and it is about to
|
|
1922
|
+
// be written under the world being opened. Cancel it rather than let it
|
|
1923
|
+
// land in the wrong store.
|
|
1924
|
+
clearTimeout(persistSaveTimer);
|
|
1925
|
+
persistSaveTimer = null;
|
|
1926
|
+
worldIndex = picked;
|
|
1927
|
+
await openPersistFor(world().name);
|
|
1928
|
+
await boot();
|
|
1929
|
+
}));
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1693
1932
|
(async () => {
|
|
1694
|
-
|
|
1695
|
-
const siteVersion = await fetchSiteVersion();
|
|
1696
|
-
persist = tmctAdventure.openPersistedStore({ storeKey: "adventure", stamp: siteVersion + ":" + ADVENTURE.world.name });
|
|
1697
|
-
}
|
|
1933
|
+
await openPersistFor(world().name);
|
|
1698
1934
|
await boot();
|
|
1699
1935
|
if (preview) ticker.play();
|
|
1700
1936
|
})();
|