@polycode-projects/the-mechanical-code-talker 4.0.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 +34 -0
- package/corpus/worlds/index.json.gz +0 -0
- package/corpus/worlds/manifest.json +49 -9
- package/corpus/worlds/shards/greyvale-museum.jsonl.gz +0 -0
- package/corpus/worlds/shards/lantern-cottage.jsonl.gz +0 -0
- package/corpus/worlds/shards/mud-hollow.jsonl.gz +0 -0
- package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
- package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
- package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
- package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
- package/corpus/worlds/src/mud-hollow.jsonl +82 -0
- package/corpus/worlds/src/mud-warren.jsonl +124 -0
- package/corpus/worlds/src/spider-fly.jsonl +1 -1
- package/data/sprites/animal-icon.toml +11 -5
- package/data/sprites/book-icon.toml +9 -5
- package/data/sprites/cabinet-icon.toml +10 -5
- package/data/sprites/cellar-icon.toml +16 -6
- package/data/sprites/container-icon.toml +7 -3
- package/data/sprites/desk-icon.toml +8 -5
- package/data/sprites/dog-icon.toml +8 -5
- package/data/sprites/dog-with-colour-icon.toml +13 -12
- package/data/sprites/drawing-room-icon.toml +14 -5
- package/data/sprites/egg-icon.toml +6 -3
- package/data/sprites/fly-icon.toml +10 -5
- package/data/sprites/furniture-icon.toml +5 -3
- package/data/sprites/garden-icon.toml +10 -3
- package/data/sprites/key-icon.toml +3 -1
- package/data/sprites/kitchen-icon.toml +15 -6
- package/data/sprites/lamp-icon.toml +9 -4
- package/data/sprites/letter-icon.toml +7 -4
- package/data/sprites/library-icon.toml +14 -3
- package/data/sprites/pan-icon.toml +7 -3
- package/data/sprites/person-icon.toml +6 -2
- package/data/sprites/poodle-icon.toml +1 -1
- package/data/sprites/portable-icon.toml +6 -4
- package/data/sprites/portrait-icon.toml +7 -4
- package/data/sprites/room-icon.toml +11 -2
- package/data/sprites/spider-icon.toml +9 -3
- package/data/sprites/study-icon.toml +10 -2
- package/package.json +5 -4
- package/src/domain/grammar/ace.mjs +40 -4
- package/src/domain/grammar/lexicon-core.json +2 -1
- package/src/domain/grammar/lexicon.mjs +18 -0
- package/src/domain/reference-pack.mjs +31 -7
- package/src/domain/spider-fly-world.mjs +1 -1
- package/src/domain/sprite-templates.mjs +8 -6
- package/src/services/adventure-viz.mjs +130 -47
- package/src/services/adventure.mjs +440 -367
- package/src/services/chat-page-viz.mjs +37 -9
- 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-viz.mjs +940 -21
- 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 +107 -107
- package/src/surfaces/web/mud-browser-entry.mjs +44 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.1",
|
|
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; indexes a repo on request (tmct index) or reads any producer's graph.",
|
|
@@ -111,11 +111,12 @@
|
|
|
111
111
|
"chat:repo": "node --disable-warning=ExperimentalWarning bin/tmct.mjs chat --repo",
|
|
112
112
|
"chat:plain": "node --disable-warning=ExperimentalWarning bin/tmct.mjs chat --plain",
|
|
113
113
|
"chat:narrate": "node --disable-warning=ExperimentalWarning bin/tmct.mjs chat --narrate",
|
|
114
|
-
"init": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init",
|
|
115
|
-
"init:
|
|
114
|
+
"init": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus seon && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus conceptnet && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus aws && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus python && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus java",
|
|
115
|
+
"init:small": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init",
|
|
116
|
+
"init:sqlite": "npm run init:small",
|
|
116
117
|
"init:persona:human": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init --with-persona human",
|
|
117
118
|
"init:persona:empty": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init --with-persona empty",
|
|
118
|
-
"init:large": "
|
|
119
|
+
"init:large": "npm run init",
|
|
119
120
|
"init:xl": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init --persona-size large && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus seon && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus conceptnet && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus aws && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus python && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus java && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus wordnet-xl",
|
|
120
121
|
"init:xxl": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init --persona-size large && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus seon && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus conceptnet && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus aws && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus python && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus java && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus wordnet-full && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus namenet",
|
|
121
122
|
"memory": "node --disable-warning=ExperimentalWarning bin/tmct.mjs memory",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
import {
|
|
30
30
|
loadLexicon, lookupNoun, lookupVerb, lookupAdjective, lookupProperName,
|
|
31
|
-
predicateOf, numberOf, classify,
|
|
31
|
+
predicateOf, numberOf, classify, OF_CLASSIFIER_HEADS, OF_PARTITIVE_HEADS,
|
|
32
32
|
} from "./lexicon.mjs";
|
|
33
33
|
import { fuzzyMatchInSet } from "../interpret/fuzzy.mjs";
|
|
34
34
|
|
|
@@ -85,7 +85,15 @@ function local(lexicon, term) {
|
|
|
85
85
|
const stripDet = (tokens) =>
|
|
86
86
|
tokens.length > 1 && DET.has(tokens[0].toLowerCase()) ? tokens.slice(1) : tokens;
|
|
87
87
|
|
|
88
|
-
/**
|
|
88
|
+
/** A token a compound noun phrase may be built out of: a plain word (never a
|
|
89
|
+
* code-shaped ref, a number or a CURIE) that is not a DECLARED proper name —
|
|
90
|
+
* a name in a compound slot ("GitLab pipeline") is a structural miss, not a
|
|
91
|
+
* compound. Shared by both compound arms of resolveNP below. */
|
|
92
|
+
const compoundableWord = (lexicon, token) =>
|
|
93
|
+
/^[a-z][a-z'-]*$/i.test(token) && !lookupProperName(lexicon, token);
|
|
94
|
+
|
|
95
|
+
/** Resolve a noun phrase: PROPERNAME | code-ref | NOUN | ADJ NOUN | NOUN NOUN
|
|
96
|
+
* | NOUN of NOUN (the last two opt-in, see `allowCompound`).
|
|
89
97
|
* Returns { term, individual, extras, unknown } — `term` null on a miss with
|
|
90
98
|
* the undeclared tokens in `unknown` (empty `unknown` = structurally
|
|
91
99
|
* unparseable phrase → the caller returns a hard null). `extras` carries the
|
|
@@ -149,8 +157,7 @@ function resolveNP(lexicon, tokensIn, { allowCompound = false } = {}) {
|
|
|
149
157
|
// DECLARED proper name in either slot ("GitLab pipeline") keeps the
|
|
150
158
|
// structural miss below — a name in the wrong slot, not a compound.
|
|
151
159
|
if (allowCompound
|
|
152
|
-
&&
|
|
153
|
-
&& /^[a-z][a-z'-]*$/i.test(tokens[0]) && /^[a-z][a-z'-]*$/i.test(tokens[1])) {
|
|
160
|
+
&& compoundableWord(lexicon, tokens[0]) && compoundableWord(lexicon, tokens[1])) {
|
|
154
161
|
const n0 = lookupNoun(lexicon, tokens[0], { singularOnly: false });
|
|
155
162
|
const n1 = lookupNoun(lexicon, tokens[1], { singularOnly });
|
|
156
163
|
if (n0 && n1) {
|
|
@@ -162,6 +169,35 @@ function resolveNP(lexicon, tokensIn, { allowCompound = false } = {}) {
|
|
|
162
169
|
const unknown = tokens.filter((t) => !classify(t, lexicon));
|
|
163
170
|
return { term: null, individual: false, extras: [], unknown };
|
|
164
171
|
}
|
|
172
|
+
// "NOUN of NOUN" is ONE compound noun ("unit of work", "chain of command",
|
|
173
|
+
// "conflict of interest"), joined with its own "of" the same way two plain
|
|
174
|
+
// nouns are joined with a space, so the taught fact and the query side
|
|
175
|
+
// unify on one term. Opt-in through the same `allowCompound` gate and the
|
|
176
|
+
// same compoundableWord shape test as the two-noun arm above; the head noun
|
|
177
|
+
// leads, so an "a"/"an" agrees with IT.
|
|
178
|
+
//
|
|
179
|
+
// Which "of" this claims is decided by the head, against the two closed
|
|
180
|
+
// of-frame vocabularies in lexicon.mjs — the same ones the fact extractor
|
|
181
|
+
// reads. A CLASSIFIER head is a rewrite of the inner noun rather than a
|
|
182
|
+
// compound ("a kind of dog" is a dog), and a PARTITIVE head states quantity
|
|
183
|
+
// or composition rather than a class ("a piece of cake", "a lot of dogs");
|
|
184
|
+
// both decline here and land on the miss below. The relational reading is
|
|
185
|
+
// pattern 7's and is claimed before any of this: parseAce routes "the N of N
|
|
186
|
+
// is VALUE" to parseOfForm, so "the version of tmct is 0.2.0" never reaches
|
|
187
|
+
// a copula split at all. An inner determiner is likewise out of the shape —
|
|
188
|
+
// "a piece of the cake" is four tokens and no compound noun carries one.
|
|
189
|
+
if (tokens.length === 3 && allowCompound && tokens[1].toLowerCase() === "of"
|
|
190
|
+
&& compoundableWord(lexicon, tokens[0]) && compoundableWord(lexicon, tokens[2])) {
|
|
191
|
+
const head = tokens[0].toLowerCase();
|
|
192
|
+
const inner = tokens[2].toLowerCase();
|
|
193
|
+
if (!OF_CLASSIFIER_HEADS.has(head) && !OF_PARTITIVE_HEADS.has(head)) {
|
|
194
|
+
const headNoun = lookupNoun(lexicon, head, { singularOnly });
|
|
195
|
+
const innerNoun = lookupNoun(lexicon, inner, { singularOnly: false });
|
|
196
|
+
if (headNoun && innerNoun) {
|
|
197
|
+
return { term: `${ns}${head} of ${inner}`, individual: false, noun: headNoun, extras: [], unknown: [] };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
165
201
|
// 0 or 3+ tokens: not a fragment NP. Name the undeclared words if any.
|
|
166
202
|
return { term: null, individual: false, extras: [], unknown: tokens.filter((t) => !classify(t, lexicon)) };
|
|
167
203
|
}
|
|
@@ -43,6 +43,24 @@ export const QUANTIFIERS = Object.freeze({
|
|
|
43
43
|
exactly: "owl:cardinality",
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
// The two closed head vocabularies that say what an "of" between two nouns is
|
|
47
|
+
// DOING, so every reader of the of-frame decides it the same way instead of
|
|
48
|
+
// each growing its own list. A CLASSIFIER head reads THROUGH to the inner noun
|
|
49
|
+
// — "a kind of dog" is a dog, "a type of mammal" is a mammal — so the phrase
|
|
50
|
+
// is a rewrite of the inner noun, never a term of its own. A PARTITIVE head
|
|
51
|
+
// states quantity or composition — "a piece of cake", "a lot of dogs", "a body
|
|
52
|
+
// of ice" — so the phrase names no class at all. Anything else ("unit of
|
|
53
|
+
// work", "chain of command") is a compound noun the of belongs to.
|
|
54
|
+
/** Of-frame heads that read through to the inner noun. */
|
|
55
|
+
export const OF_CLASSIFIER_HEADS = Object.freeze(new Set([
|
|
56
|
+
"type", "kind", "sort", "form", "class", "variety", "species", "breed", "genus",
|
|
57
|
+
]));
|
|
58
|
+
/** Of-frame heads that state quantity or composition, not a class. */
|
|
59
|
+
export const OF_PARTITIVE_HEADS = Object.freeze(new Set([
|
|
60
|
+
"body", "mass", "group", "collection", "set", "series", "number", "amount",
|
|
61
|
+
"piece", "part", "lot", "pair", "bunch", "pile",
|
|
62
|
+
]));
|
|
63
|
+
|
|
46
64
|
const NUMBER_WORDS = Object.freeze({
|
|
47
65
|
one: 1, two: 2, three: 3, four: 4, five: 5,
|
|
48
66
|
six: 6, seven: 7, eight: 8, nine: 9, ten: 10,
|
|
@@ -35,25 +35,49 @@ export function isReferenceIndexEntry(e) {
|
|
|
35
35
|
&& Number.isInteger(e.r) && e.r > 0;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/** A shard row: {term, title, text, summary, url, revid, isa
|
|
38
|
+
/** A shard row: {term, title, text, summary, url, revid, isa?, source?,
|
|
39
|
+
* licence?}. `source`/`licence` are optional per-entry overrides of the
|
|
40
|
+
* citation's source name and licence line (renderReferenceAnswer, below) —
|
|
41
|
+
* absent on every real Simple English Wikipedia row the build pipeline
|
|
42
|
+
* emits, present only on a hand-added synthetic entry (e.g. a demo/test
|
|
43
|
+
* term) whose citation must name its OWN real source, not Wikipedia's. */
|
|
39
44
|
export function isReferenceArticleRow(row) {
|
|
40
45
|
if (!row || typeof row !== "object") return false;
|
|
41
46
|
for (const field of ["term", "title", "text", "summary", "url"]) {
|
|
42
47
|
if (typeof row[field] !== "string" || !row[field]) return false;
|
|
43
48
|
}
|
|
44
49
|
if (!Number.isInteger(row.revid) || row.revid <= 0) return false;
|
|
45
|
-
|
|
50
|
+
for (const field of ["isa", "source", "licence"]) {
|
|
51
|
+
if (row[field] !== undefined && (typeof row[field] !== "string" || !row[field])) return false;
|
|
52
|
+
}
|
|
46
53
|
return true;
|
|
47
54
|
}
|
|
48
55
|
|
|
56
|
+
/** The default citation source/licence — every article the build pipeline
|
|
57
|
+
* (scripts/fetch-reference-pack.mjs) emits from the pinned Simple English
|
|
58
|
+
* Wikipedia dump carries neither field, so this is what renderReferenceAnswer
|
|
59
|
+
* falls back to for the overwhelming majority of rows. */
|
|
60
|
+
export const DEFAULT_REFERENCE_SOURCE = "Simple English Wikipedia";
|
|
61
|
+
export const DEFAULT_REFERENCE_LICENCE = "CC BY-SA 4.0";
|
|
62
|
+
|
|
49
63
|
/** The cited answer for a clean miss the pack could ground: the article's
|
|
50
|
-
* summary with its title, licence and
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
64
|
+
* summary with its title, licence and source always visible, then the grain
|
|
65
|
+
* cue. Without the cue a reader has to recognize the source name to tell
|
|
66
|
+
* this apart from a fact read out of their own graph — both answer the same
|
|
67
|
+
* "what is X" question in the same voice.
|
|
68
|
+
*
|
|
69
|
+
* The source/licence line reads the article's OWN `source`/`licence` fields
|
|
70
|
+
* when present, falling back to the Wikipedia defaults otherwise — every
|
|
71
|
+
* real pack row renders byte-identically to before this existed. Wikipedia's
|
|
72
|
+
* revision-pinned `?oldid=` query is a convention of that source alone, so it
|
|
73
|
+
* is only appended when the article is citing the default source; a
|
|
74
|
+
* non-Wikipedia entry's own `url` is shown bare. */
|
|
54
75
|
export function renderReferenceAnswer(term, article) {
|
|
76
|
+
const source = article.source ?? DEFAULT_REFERENCE_SOURCE;
|
|
77
|
+
const licence = article.licence ?? DEFAULT_REFERENCE_LICENCE;
|
|
78
|
+
const url = source === DEFAULT_REFERENCE_SOURCE ? `${article.url}?oldid=${article.revid}` : article.url;
|
|
55
79
|
return `${term} — ${article.summary} (source: reference article "${article.title}", `
|
|
56
|
-
+
|
|
80
|
+
+ `${source}, ${licence} — ${url})`
|
|
57
81
|
+ ` ${GENERAL_VOCABULARY_CUE}`;
|
|
58
82
|
}
|
|
59
83
|
|
|
@@ -122,7 +122,7 @@ export const SEED_TAXONOMY = Object.freeze([
|
|
|
122
122
|
]);
|
|
123
123
|
|
|
124
124
|
export const WORLD_OPENING =
|
|
125
|
-
"a spider waits in its web; a fly drifts in from the edge of the board. Neither is yours to move
|
|
125
|
+
"a spider waits in its web; a fly drifts in from the edge of the board. Neither is yours to move. Watch, or address one by name in chat.";
|
|
126
126
|
|
|
127
127
|
/** Every fact row the shipped world source carries: cell typing, grid
|
|
128
128
|
* adjacency (mgx:has-exit-<direction>), the web block (mgx:in-web) and the
|
|
@@ -21,12 +21,14 @@
|
|
|
21
21
|
// substitution (e.g. `black = "#22201d"`). A value with no entry in that
|
|
22
22
|
// map is not a match for this template at all — it falls through to a
|
|
23
23
|
// less specific one, never a guessed/invented substitution.
|
|
24
|
-
// - a fully-specific hand-authored VARIANT — `
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
24
|
+
// - a fully-specific hand-authored VARIANT — carries the same `classes`
|
|
25
|
+
// as the class it specializes, plus a `[match]` table (`property`,
|
|
26
|
+
// `value`) naming the exact fact it requires, so it outranks the
|
|
27
|
+
// parameterized template when both would otherwise apply. The filename
|
|
28
|
+
// is free-form and reads `{class}-{what it shows}` in practice
|
|
29
|
+
// (`portrait-round.toml` for mgx:hasProperty = round,
|
|
30
|
+
// `bear-facing-left.toml` for mgx:faces = left) — the `[match]` table,
|
|
31
|
+
// never the name, is what selects it.
|
|
30
32
|
//
|
|
31
33
|
// A parameterized template's `[parameters.<name>]` table comes in two
|
|
32
34
|
// shapes, picked by which of `placeholder`/`placeholders` it declares:
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
// an edit implies run through the browser bundle's own `session.applyEdit`
|
|
85
85
|
// (adventure-browser-entry.mjs), never here — this module only renders and
|
|
86
86
|
// reads.
|
|
87
|
-
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";
|
|
88
88
|
import { createTicker } from "./viz-ticker.mjs";
|
|
89
89
|
import { worldDigestRows, roomAffordances, foldWorldState } from "./adventure.mjs";
|
|
90
90
|
import { exposedFacts } from "./adventure-autoplay.mjs";
|
|
@@ -168,15 +168,21 @@ export function factsForSubject(rows, subject) {
|
|
|
168
168
|
* feeding it an exposure-filtered pair (as `goalStatusLines` does) is what
|
|
169
169
|
* keeps a not-yet-visited location unknown. Pure, self-contained — no
|
|
170
170
|
* reference to adventure.mjs's own private helpers, since those aren't
|
|
171
|
-
* exported.
|
|
172
|
-
|
|
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") {
|
|
173
179
|
const isRoom = (id) => (rows || []).some((r) => r.subject === id && r.predicate === "rdf:type" && r.object === "room");
|
|
174
180
|
if (isRoom(subject)) return subject;
|
|
175
181
|
const place = state.placements.get(subject);
|
|
176
182
|
if (!place || place.predicate === "mgx:hidden-in") return null;
|
|
177
183
|
if (place.predicate === "mgx:currently-in" || isRoom(place.object)) return place.object;
|
|
178
184
|
const holder = place.object;
|
|
179
|
-
if (holder ===
|
|
185
|
+
if (holder === actingSubject) return null;
|
|
180
186
|
if (!state.openness.get(holder)?.open) return null;
|
|
181
187
|
const holderPlace = state.placements.get(holder);
|
|
182
188
|
return holderPlace && holderPlace.predicate !== "mgx:hidden-in" ? holderPlace.object : null;
|
|
@@ -185,13 +191,14 @@ export function visibleRoomOf(rows, state, subject) {
|
|
|
185
191
|
/** Every subject actually visible in `here`, sorted, each with its sprite
|
|
186
192
|
* class — built over `visibleRoomOf` (one containment hop through an OPEN
|
|
187
193
|
* container) so this can never draw a hidden or carried object the text
|
|
188
|
-
* digest wouldn't also mention. `
|
|
189
|
-
* player's own adventurer sprite
|
|
190
|
-
|
|
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") {
|
|
191
198
|
const out = [];
|
|
192
199
|
for (const subject of [...state.placements.keys()].sort()) {
|
|
193
|
-
if (subject ===
|
|
194
|
-
if (visibleRoomOf(rows, state, subject) !== here) continue;
|
|
200
|
+
if (subject === actingSubject) continue;
|
|
201
|
+
if (visibleRoomOf(rows, state, subject, actingSubject) !== here) continue;
|
|
195
202
|
out.push({ subject, spriteClass: spriteClassForObject(rows, subject) });
|
|
196
203
|
}
|
|
197
204
|
return out;
|
|
@@ -329,13 +336,13 @@ export function roomKindForRoom(rows, roomId) {
|
|
|
329
336
|
return "indoor";
|
|
330
337
|
}
|
|
331
338
|
|
|
332
|
-
/** Every object currently `mgx:located-in`
|
|
333
|
-
* sprite class — the exact placement
|
|
334
|
-
* own "carries the" branch already
|
|
335
|
-
* instead of prose. Pure. */
|
|
336
|
-
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") {
|
|
337
344
|
return [...state.placements]
|
|
338
|
-
.filter(([, p]) => p.predicate === "mgx:located-in" && p.object ===
|
|
345
|
+
.filter(([, p]) => p.predicate === "mgx:located-in" && p.object === actingSubject)
|
|
339
346
|
.map(([subject]) => ({ subject, spriteClass: spriteClassForObject(rows, subject) }))
|
|
340
347
|
.sort((a, b) => a.subject.localeCompare(b.subject));
|
|
341
348
|
}
|
|
@@ -355,10 +362,10 @@ export function carriedItems(rows, state) {
|
|
|
355
362
|
* Disconnected visited rooms (not reachable from each other by traveled
|
|
356
363
|
* edges) lay out as separate side-by-side blocks rather than overlapping.
|
|
357
364
|
* Pure. */
|
|
358
|
-
export function visitedRoomGraph(state, visitedRoomIds) {
|
|
365
|
+
export function visitedRoomGraph(state, visitedRoomIds, actingSubject = "player") {
|
|
359
366
|
const DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
|
|
360
367
|
const visited = new Set(visitedRoomIds || []);
|
|
361
|
-
const here = state.placements.get(
|
|
368
|
+
const here = state.placements.get(actingSubject)?.object ?? null;
|
|
362
369
|
const positions = new Map();
|
|
363
370
|
const edges = [];
|
|
364
371
|
const edgeKeys = new Set();
|
|
@@ -558,12 +565,16 @@ export function roomCaptionText(rows, state, here) {
|
|
|
558
565
|
export function renderAdventureHtml({
|
|
559
566
|
title = DEFAULT_TITLE,
|
|
560
567
|
worldPayload = { facts: [], rules: [], opening: "" },
|
|
568
|
+
scenarios = [],
|
|
561
569
|
spriteTemplates = [],
|
|
562
570
|
largeSpriteTemplates = [],
|
|
563
571
|
engineBundleJs = "",
|
|
564
572
|
} = {}) {
|
|
573
|
+
const scenarioList = scenarios.length
|
|
574
|
+
? scenarios
|
|
575
|
+
: [{ label: scenarioLabel(worldPayload?.name), worldPayload }];
|
|
565
576
|
const pageData = embedJson({
|
|
566
|
-
|
|
577
|
+
scenarios: scenarioList,
|
|
567
578
|
previewMaxTicks: PREVIEW_MAX_TICKS,
|
|
568
579
|
tickWaitMs: TICK_WAIT_MS,
|
|
569
580
|
spriteTemplates,
|
|
@@ -726,31 +737,56 @@ ${THEME_TOKENS_CSS}
|
|
|
726
737
|
the frame so neither fights the plaque or the room-kind icon; west and
|
|
727
738
|
east sit just inside their own walls. Stairs get a round chip instead —
|
|
728
739
|
neither up nor down is a compass point, and neither hangs on a wall. */
|
|
729
|
-
.room-stage { position: relative; padding: 1.
|
|
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
|
+
}
|
|
730
749
|
.dir-ring { position: absolute; inset: 0; pointer-events: none; }
|
|
731
750
|
.dir-slot { position: absolute; pointer-events: auto; }
|
|
732
751
|
.dir-north { top: 0; left: 50%; transform: translateX(-50%); }
|
|
733
752
|
.dir-south { bottom: 0; left: 50%; transform: translateX(-50%); }
|
|
734
|
-
.dir-west { left: .
|
|
735
|
-
.dir-east { right: .
|
|
753
|
+
.dir-west { left: .4rem; top: 50%; transform: translateY(-50%); }
|
|
754
|
+
.dir-east { right: .4rem; top: 50%; transform: translateY(-50%); }
|
|
736
755
|
/* the stairs stand at the left of each band: the frame's own corner
|
|
737
756
|
flourishes sit top-left and bottom-right, and this is the one inset that
|
|
738
757
|
clears both. */
|
|
739
|
-
.dir-up { top: 0; left: 1.
|
|
740
|
-
.dir-down { bottom: 0; left: 1.
|
|
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. */
|
|
741
766
|
.dir-door {
|
|
742
|
-
position: relative; display: inline-flex; align-items: center; justify-content: center;
|
|
743
|
-
font-family: ${MONO_STACK}; font-size: .
|
|
744
|
-
color: var(--gilt); background: var(--parchment); border: 1px solid var(--gilt);
|
|
745
|
-
box-shadow: inset 0 0 0 2px var(--parchment-strong);
|
|
746
|
-
padding: .
|
|
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);
|
|
747
779
|
}
|
|
748
|
-
.dir-door:
|
|
749
|
-
|
|
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: ""; }
|
|
750
786
|
/* a door onto a room this session has not stood in yet, marked with the
|
|
751
787
|
same gilt dot the manor board prints for a direction it cannot draw a
|
|
752
788
|
room for — one hint shape, two surfaces. */
|
|
753
|
-
.dir-door .unwalked { position: absolute; top: -.
|
|
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); }
|
|
754
790
|
|
|
755
791
|
.sprite-row { display: flex; align-items: flex-end; gap: .9rem .7rem; min-height: 2.5rem; }
|
|
756
792
|
/* the floor band: every non-wall-mounted stack, wrapping and pinned to the
|
|
@@ -930,6 +966,9 @@ ${THEME_TOKENS_CSS}
|
|
|
930
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); }
|
|
931
967
|
.controls-row button:hover:not(:disabled) { background: var(--parchment-strong); }
|
|
932
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; }
|
|
933
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; }
|
|
934
973
|
.goal-line { font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); margin-top: .5rem; }
|
|
935
974
|
.status { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); margin-top: .3rem; }
|
|
@@ -970,11 +1009,14 @@ ${THEME_TOKENS_CSS}
|
|
|
970
1009
|
<div class="eyebrow">tmct · the adventure</div>
|
|
971
1010
|
<button id="editModeBtn" type="button" class="mode-toggle" disabled>edit the world</button>
|
|
972
1011
|
</div>
|
|
973
|
-
<p class="page-note">${escapeHtml(worldPayload.opening)}</p>
|
|
1012
|
+
<p class="page-note" id="pageNote">${escapeHtml(scenarioList[0].worldPayload.opening || "")}</p>
|
|
974
1013
|
<div class="stage" id="playStage">
|
|
975
1014
|
<div class="stage-left">
|
|
976
1015
|
<div class="controls-row" id="playControls">
|
|
977
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>` : ""}
|
|
978
1020
|
<button id="playBtn" type="button" disabled>▶ play</button>
|
|
979
1021
|
<button id="stepBtn" type="button" disabled>step</button>
|
|
980
1022
|
<span class="turn mono" id="turnLabel">turn: 0</span>
|
|
@@ -989,6 +1031,7 @@ ${THEME_TOKENS_CSS}
|
|
|
989
1031
|
<h2>satchel</h2>
|
|
990
1032
|
<div class="chips" id="carryList"></div>
|
|
991
1033
|
</div>
|
|
1034
|
+
<div class="ways-out-label" aria-hidden="true">ways out</div>
|
|
992
1035
|
<div class="room-stage">
|
|
993
1036
|
<div class="room-frame" id="roomFrame">
|
|
994
1037
|
<div class="room-plaque mono" id="roomName"></div>
|
|
@@ -1096,6 +1139,12 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1096
1139
|
const renderWorldEditorText = ${renderWorldEditorText.toString()};
|
|
1097
1140
|
const wordBeforeCursor = ${wordBeforeCursor.toString()};
|
|
1098
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";
|
|
1099
1148
|
const el = (id) => document.getElementById(id);
|
|
1100
1149
|
const roomFrameEl = el("roomFrame");
|
|
1101
1150
|
const roomNameEl = el("roomName");
|
|
@@ -1139,6 +1188,15 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1139
1188
|
const roomDetailSpritesEl = el("roomDetailSprites");
|
|
1140
1189
|
const roomDetailCaptionEl = el("roomDetailCaption");
|
|
1141
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;
|
|
1142
1200
|
|
|
1143
1201
|
const params = new URLSearchParams(location.search);
|
|
1144
1202
|
const preview = params.get("preview") === "1";
|
|
@@ -1268,12 +1326,12 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1268
1326
|
// holding it has actually been visited this session.
|
|
1269
1327
|
function goalStatusLinesFor(rows, state, visitedRoomIds) {
|
|
1270
1328
|
const ids = Array.from(new Set(rows.filter((r) => r.predicate === "mgx:is-objective" && r.object === "true").map((r) => r.subject)));
|
|
1271
|
-
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));
|
|
1272
1330
|
const exposedRows = tmctAdventure.exposedFacts(rows, visitedRoomIds);
|
|
1273
1331
|
const exposedState = tmctAdventure.foldWorldState(exposedRows);
|
|
1274
1332
|
return ids.map((id) => {
|
|
1275
1333
|
if (carriedIds.has(id)) return { subject: id, status: "carried", text: "carrying the " + id + " \\u2014 the adventure is won." };
|
|
1276
|
-
const room = visibleRoomOf(exposedRows, exposedState, id);
|
|
1334
|
+
const room = visibleRoomOf(exposedRows, exposedState, id, ACTING_SUBJECT);
|
|
1277
1335
|
return room
|
|
1278
1336
|
? { subject: id, status: "known", text: "last known: the " + id + " is in the " + room + "." }
|
|
1279
1337
|
: { subject: id, status: "unknown", text: "there's a sought-after " + id + " somewhere." };
|
|
@@ -1284,7 +1342,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1284
1342
|
// the same non-interactive chip shape the room's own pills use for
|
|
1285
1343
|
// actions, so the sidebar reads as one visual family.
|
|
1286
1344
|
function renderCarrying(rows, state) {
|
|
1287
|
-
const items = carriedItems(rows, state);
|
|
1345
|
+
const items = carriedItems(rows, state, ACTING_SUBJECT);
|
|
1288
1346
|
carryListEl.innerHTML = items.length
|
|
1289
1347
|
? items.map((o) => '<span class="chip">' + esc(o.subject) + "</span>").join("")
|
|
1290
1348
|
: '<span class="empty-note">nothing yet</span>';
|
|
@@ -1329,7 +1387,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1329
1387
|
+ edgesSvg + hintsSvg + nodesSvg + "</svg>";
|
|
1330
1388
|
}
|
|
1331
1389
|
function renderRoomMap(rows, state, visitedRoomIds) {
|
|
1332
|
-
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>';
|
|
1333
1391
|
}
|
|
1334
1392
|
|
|
1335
1393
|
// ---- the map lightbox — clicking the fixed-square play-mode map redraws
|
|
@@ -1337,7 +1395,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1337
1395
|
// backdrop (not the board itself) or pressing Escape closes it.
|
|
1338
1396
|
function openMapLightbox() {
|
|
1339
1397
|
if (!lastSnapshot) return;
|
|
1340
|
-
const svg = roomMapSvg(visitedRoomGraph(lastSnapshot.state, lastSnapshot.visitedRoomIds), false);
|
|
1398
|
+
const svg = roomMapSvg(visitedRoomGraph(lastSnapshot.state, lastSnapshot.visitedRoomIds, ACTING_SUBJECT), false);
|
|
1341
1399
|
if (!svg) return;
|
|
1342
1400
|
mapLightboxInnerEl.innerHTML = svg;
|
|
1343
1401
|
mapLightboxEl.hidden = false;
|
|
@@ -1445,7 +1503,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1445
1503
|
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !objLightboxEl.hidden) closeObjectLightbox(); });
|
|
1446
1504
|
|
|
1447
1505
|
function renderEditMap(rows, state) {
|
|
1448
|
-
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>';
|
|
1449
1507
|
}
|
|
1450
1508
|
editMapWrapEl.addEventListener("click", (e) => {
|
|
1451
1509
|
const g = e.target.closest("[data-room]");
|
|
@@ -1643,7 +1701,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1643
1701
|
// diff + write; this page only ever reads its result back.
|
|
1644
1702
|
|
|
1645
1703
|
function worldOnlyRows(rows) {
|
|
1646
|
-
const prefix = "world:" +
|
|
1704
|
+
const prefix = "world:" + world().name;
|
|
1647
1705
|
return (rows || []).filter((r) => typeof r.provenance === "string" && r.provenance.indexOf(prefix) === 0);
|
|
1648
1706
|
}
|
|
1649
1707
|
|
|
@@ -1657,7 +1715,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1657
1715
|
}
|
|
1658
1716
|
roomDetailPanelEl.setAttribute("data-room-kind", roomKindForRoom(editRows, selectedRoomId));
|
|
1659
1717
|
roomDetailTitleEl.textContent = selectedRoomId;
|
|
1660
|
-
const objects = roomSceneObjects(editRows, editState, selectedRoomId);
|
|
1718
|
+
const objects = roomSceneObjects(editRows, editState, selectedRoomId, ACTING_SUBJECT);
|
|
1661
1719
|
roomDetailSpritesEl.innerHTML = objects.length
|
|
1662
1720
|
? objects.map((o) => spriteCardHtml(o.subject, o.spriteClass, resolveObjectSprite(editRows, o))).join("")
|
|
1663
1721
|
: '<span class="empty-note">nothing placed here yet</span>';
|
|
@@ -1674,7 +1732,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1674
1732
|
function renderLegend(rows) {
|
|
1675
1733
|
const subjects = new Set(rows.filter((r) => r.predicate === "rdf:type").map((r) => r.subject));
|
|
1676
1734
|
const classes = new Set(["adventurer"]);
|
|
1677
|
-
subjects.forEach((s) => { if (s !==
|
|
1735
|
+
subjects.forEach((s) => { if (s !== ACTING_SUBJECT) classes.add(spriteClassForObject(rows, s)); });
|
|
1678
1736
|
legendListEl.innerHTML = Array.from(classes).sort().map((cls) => {
|
|
1679
1737
|
const svg = tmctAdventure.resolveSpriteAsset(
|
|
1680
1738
|
cls, spriteAncestryRows(rows, cls), factsForSubject(rows, cls),
|
|
@@ -1792,7 +1850,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1792
1850
|
async function boot({ fresh = false } = {}) {
|
|
1793
1851
|
const saved = !fresh && persist ? await persist.load() : null;
|
|
1794
1852
|
session = await tmctAdventure.createAdventureSession(
|
|
1795
|
-
|
|
1853
|
+
world(),
|
|
1796
1854
|
saved && saved.payload && saved.payload.memoryPayload
|
|
1797
1855
|
? { restoredPayload: saved.payload.memoryPayload, restoredVisitedRoomIds: saved.payload.visitedRoomIds }
|
|
1798
1856
|
: {},
|
|
@@ -1803,12 +1861,14 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1803
1861
|
goalLineEl.textContent = "";
|
|
1804
1862
|
chatlogEl.innerHTML = "";
|
|
1805
1863
|
statusEl.textContent = "";
|
|
1806
|
-
|
|
1864
|
+
if (pageNoteEl) pageNoteEl.textContent = world().opening || "";
|
|
1865
|
+
addChatLine("t", esc(world().opening || "the adventure begins."));
|
|
1807
1866
|
if (saved && snap.turn > 0) {
|
|
1808
1867
|
addChatLine("t", "resumed where you left off (turn " + snap.turn + ") \\u2014 progress kept best-effort on this device; reset starts the manor over.");
|
|
1809
1868
|
}
|
|
1810
1869
|
chatqEl.disabled = false;
|
|
1811
1870
|
resetBtn.disabled = false; playBtn.disabled = false; stepBtn.disabled = false; editModeBtn.disabled = false;
|
|
1871
|
+
if (scenarioSelectEl) scenarioSelectEl.disabled = false;
|
|
1812
1872
|
}
|
|
1813
1873
|
|
|
1814
1874
|
const ticker = createTicker({
|
|
@@ -1843,11 +1903,34 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1843
1903
|
stepBtn.addEventListener("click", () => ticker.stepOnce());
|
|
1844
1904
|
resetBtn.addEventListener("click", () => ticker.reset());
|
|
1845
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
|
+
|
|
1846
1932
|
(async () => {
|
|
1847
|
-
|
|
1848
|
-
const siteVersion = await fetchSiteVersion();
|
|
1849
|
-
persist = tmctAdventure.openPersistedStore({ storeKey: "adventure", stamp: siteVersion + ":" + ADVENTURE.world.name });
|
|
1850
|
-
}
|
|
1933
|
+
await openPersistFor(world().name);
|
|
1851
1934
|
await boot();
|
|
1852
1935
|
if (preview) ticker.play();
|
|
1853
1936
|
})();
|