@polycode-projects/the-mechanical-code-talker 2.11.9 → 2.11.11
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 +52 -51
- package/package.json +1 -1
- package/src/adapters/research-queue-store.mjs +76 -0
- package/src/domain/grammar/ace.mjs +8 -1
- package/src/services/adventure-viz.mjs +156 -6
- package/src/services/adventure.mjs +65 -6
- package/src/services/chat.mjs +16 -2
- package/src/services/extract-facts.mjs +127 -19
- package/src/surfaces/web/memory-ask-browser.bundle.js +105 -105
package/README.md
CHANGED
|
@@ -124,14 +124,14 @@ page answers codebase questions live, and eight more pages each ground their
|
|
|
124
124
|
own domain: a full chat seeded with 32,646 facts (the same nine bands as
|
|
125
125
|
`npm run init:xl`, capped to a 38.7 MB download, 2.1 MB on the wire), the
|
|
126
126
|
**memory ledger** (every fact as a readable sentence; drill by clicking the
|
|
127
|
-
terms inside), the **code explorer** (the same ledger UI refocused on a
|
|
128
|
-
graph, with a hint rail of suggested next questions)
|
|
129
|
-
turns pasted or dropped text into grounded facts and downloads them
|
|
130
|
-
canonical JSONL
|
|
131
|
-
spider-and-fly and text-adventure games, and a sprite gallery
|
|
132
|
-
answers from 1,033 generated sprite facts. The chat page and
|
|
133
|
-
the same paste-or-drop text in place; every page that holds
|
|
134
|
-
exports it as JSONL.
|
|
127
|
+
terms inside), and the **code explorer** (the same ledger UI refocused on a
|
|
128
|
+
code graph, with a hint rail of suggested next questions). An **ingest
|
|
129
|
+
page** turns pasted or dropped text into grounded facts and downloads them
|
|
130
|
+
as canonical JSONL. The rest are a Towers-of-Hanoi plan replayed move by
|
|
131
|
+
move, the spider-and-fly and text-adventure games, and a sprite gallery
|
|
132
|
+
whose chat dock answers from 1,033 generated sprite facts. The chat page and
|
|
133
|
+
the ledger take the same paste-or-drop text in place; every page that holds
|
|
134
|
+
a fact store exports it as JSONL.
|
|
135
135
|
The site hosts its own copy of wink-nlp, ships its assets precompressed,
|
|
136
136
|
and a service worker precaches the big ones, so a second visit works
|
|
137
137
|
offline. `tmct chat --render spider-fly|adventure|sprites [--output <path>]`
|
|
@@ -168,13 +168,14 @@ The same ledger UI, refocused on a code graph, also runs as a desktop app. It
|
|
|
168
168
|
reads a `graph.json` (or a repo's `.tmct/` folder), shows every import, call and
|
|
169
169
|
`contains` edge as a plain sentence around a focus symbol, and docks a live chat
|
|
170
170
|
over the same graph. A hint rail suggests the next question from what the graph
|
|
171
|
-
actually holds
|
|
172
|
-
functions"
|
|
171
|
+
actually holds: "what does X import", "which functions call Y", "list
|
|
172
|
+
functions". Every suggestion resolves to a real answer.
|
|
173
173
|
|
|
174
|
-
The identical page also runs as a plain hosted page
|
|
175
|
-
graph
|
|
176
|
-
|
|
177
|
-
exploring your own repo or graph, which the
|
|
174
|
+
The identical page also runs as a plain hosted page over the demo code
|
|
175
|
+
graph, with nothing to install:
|
|
176
|
+
**[try it live →](https://polycode-projects.gitlab.io/the-mechanical-code-talker/code.html)**.
|
|
177
|
+
The desktop build below is for exploring your own repo or graph, which the
|
|
178
|
+
hosted page cannot reach.
|
|
178
179
|
|
|
179
180
|
Electron is a dev-only dependency and never ships in the npm package. Because
|
|
180
181
|
`.npmrc` sets `ignore-scripts=true`, installing it does not fetch the runtime
|
|
@@ -188,8 +189,8 @@ npm run electron # open the code explorer on the demo gra
|
|
|
188
189
|
```
|
|
189
190
|
|
|
190
191
|
Open a graph or a repo from the window's title bar to explore your own code.
|
|
191
|
-
The UI is channel-agnostic
|
|
192
|
-
`electron/preload.cjs`) is desktop-specific
|
|
192
|
+
The UI is channel-agnostic. Only the Electron shell (`electron/main.mjs` +
|
|
193
|
+
`electron/preload.cjs`) is desktop-specific. The same page stays servable as a
|
|
193
194
|
plain web page. `npm run test:electron` runs the shell smoke via Playwright and
|
|
194
195
|
skips cleanly when the binary is absent.
|
|
195
196
|
|
|
@@ -226,8 +227,8 @@ resolves to a real graph traversal or declines cleanly:
|
|
|
226
227
|
(*because/although/while*), conditionals, and false-premise flags ("why
|
|
227
228
|
does X still import Y" when it no longer does).
|
|
228
229
|
|
|
229
|
-
The full catalog with measured coverage lives in `
|
|
230
|
-
|
|
230
|
+
The full catalog with measured coverage lives in the `BENCHMARK_*.md`
|
|
231
|
+
reports.
|
|
231
232
|
|
|
232
233
|
**Response finishing.** Before an answer prints, it is segmented into typed
|
|
233
234
|
spans: prose versus *protected* entities, paths, numbers, code, provenance,
|
|
@@ -310,7 +311,7 @@ planner over the same read-only graph-query tools chat/serve use
|
|
|
310
311
|
step in order with a provable causal-link proof chain, and folds the results
|
|
311
312
|
into one answer. A request neither the planner nor a single lookup can ground
|
|
312
313
|
escalates to a closed-world goal-reasoner, which deduces maintenance goals
|
|
313
|
-
(coverage gaps, change-coupling risk) straight from the graph
|
|
314
|
+
(coverage gaps, change-coupling risk) straight from the graph, never from
|
|
314
315
|
keywords in your question. Anything none of that grounds is an honest "no plan
|
|
315
316
|
found", the same "grounded or an honest miss" rule as everywhere else in tmct.
|
|
316
317
|
|
|
@@ -344,7 +345,7 @@ composed answer (4): src/handlers/base.mjs, src/handlers/users.mjs, src/server/a
|
|
|
344
345
|
```
|
|
345
346
|
|
|
346
347
|
tmct planned two calls (`tmct_impact` then `tmct_untested`), ran both against the
|
|
347
|
-
real graph, and intersected the results itself
|
|
348
|
+
real graph, and intersected the results itself. You get the four modules that
|
|
348
349
|
are both downstream of the change AND missing coverage, not two separate lists
|
|
349
350
|
you'd have to cross-reference by hand.
|
|
350
351
|
|
|
@@ -361,7 +362,7 @@ composed answer (1): src/lib/http.mjs
|
|
|
361
362
|
|
|
362
363
|
It deduced the goal ("an impactful module must be tested"), gathered every
|
|
363
364
|
untested module, ranked each by blast radius, and named the one worth testing
|
|
364
|
-
first
|
|
365
|
+
first: `src/lib/http.mjs`, the module with the widest reach.
|
|
365
366
|
|
|
366
367
|
`--tools tmct_impact,tmct_untested` restricts which capabilities the planner is
|
|
367
368
|
allowed to use; `--json` prints the full machine-readable loop result (calls,
|
|
@@ -372,9 +373,9 @@ flag reference.
|
|
|
372
373
|
## Teach it a game, then ask it to plan
|
|
373
374
|
|
|
374
375
|
The planner above works over a fixed toolset. This one works over rules you
|
|
375
|
-
teach. A game definition is a plain-text file of controlled English
|
|
376
|
+
teach. A game definition is a plain-text file of controlled English: the
|
|
376
377
|
classes, the pieces, the ordering, and the legal moves as taught action
|
|
377
|
-
rules
|
|
378
|
+
rules, with `#` comment lines carrying example prompts. `tmct init`
|
|
378
379
|
scaffolds one at `.tmct/imports/games/hanoi-3.txt`, and
|
|
379
380
|
`tmct import --file` teaches it sentence by sentence, reporting every line
|
|
380
381
|
and refusing (exit 1) if any sentence declines.
|
|
@@ -402,8 +403,8 @@ Goal (inferred): Plan a move sequence from the current state to the goal (7 move
|
|
|
402
403
|
facts stamped with the step that produced them ("disk-1@step1 rests on peg-c",
|
|
403
404
|
sourced to the plan). The final step re-reads the store and confirms the goal
|
|
404
405
|
from those written facts, never assuming success. The stamp is what makes each
|
|
405
|
-
step a separate record
|
|
406
|
-
rest?", "is disk-1 clear?") reads the current board
|
|
406
|
+
step a separate record. A question about the piece itself ("where does disk-1
|
|
407
|
+
rest?", "is disk-1 clear?") reads the current board: the latest step's facts,
|
|
407
408
|
not every step at once. The search is
|
|
408
409
|
domain-general: the test
|
|
409
410
|
suite teaches Towers of Hanoi purely as sentences for 1 to 8 disks and
|
|
@@ -417,17 +418,17 @@ the plan as a self-contained animated page (see "Two more surfaces" above).
|
|
|
417
418
|
Three games run inside an ordinary chat session, no setup.
|
|
418
419
|
|
|
419
420
|
**Guess the number.** Say `I'm thinking of a number between 1 and 100` and
|
|
420
|
-
tmct guesses by narrowing an interval
|
|
421
|
+
tmct guesses by narrowing an interval: answer `higher`, `lower`, or
|
|
421
422
|
`correct`. It finds any number in at most 7 guesses, and if your answers
|
|
422
423
|
contradict each other it names the contradicting pair and stops rather than
|
|
423
424
|
guessing on. Say `think of a number` to swap seats: tmct commits to a secret
|
|
424
|
-
and answers your guesses
|
|
425
|
-
its own record if you claim it already said `correct`. The
|
|
426
|
-
pinned by `test/corpus/games/guess-number.jsonl`.
|
|
425
|
+
and sticks to it. It answers your guesses, reveals the number on request, and
|
|
426
|
+
corrects you from its own record if you claim it already said `correct`. The
|
|
427
|
+
behaviour is pinned by `test/corpus/games/guess-number.jsonl`.
|
|
427
428
|
|
|
428
429
|
**A text adventure.** Say `start the adventure` (or `play ashcombe hall`)
|
|
429
430
|
and tmct loads a small country-house mystery from a lazily-fetched worlds
|
|
430
|
-
pack (`corpus/worlds/`) into the session's ordinary memory graph
|
|
431
|
+
pack (`corpus/worlds/`) into the session's ordinary memory graph: rooms,
|
|
431
432
|
objects and people become graph facts, and the verbs (`go`, `take`, `open`,
|
|
432
433
|
`unlock`, `look`…) are taught action rules, not hard-wired code. Every move
|
|
433
434
|
writes per-turn snapshot facts, `look` is an extractive digest of the graph,
|
|
@@ -436,30 +437,30 @@ schedule whether you are there to see it or not. The full worked mystery is
|
|
|
436
437
|
pinned step by step in `test/corpus/games/adventure.jsonl`.
|
|
437
438
|
|
|
438
439
|
**Two agents, planning against each other.** Say `play spider and fly` (or
|
|
439
|
-
`watch the spider and the fly`) and tmct runs both sides itself
|
|
440
|
+
`watch the spider and the fly`) and tmct runs both sides itself. Neither is
|
|
440
441
|
player-controlled. A spider hunts a fly across a 10×10 web; each side only
|
|
441
442
|
believes what it can currently see (`vision_radius`, tunable), a fly wanders
|
|
442
443
|
when nothing threatens it and evades when something does, a spider avoids
|
|
443
444
|
other spiders, chases what it believes it sees, and builds a web when it
|
|
444
445
|
holds position. Mass is real: both sides waste away each turn they don't
|
|
445
446
|
eat, and a spider gains exactly the mass of what it catches. You can address
|
|
446
|
-
either side directly (`@spider the fly is east`) to feed it a belief
|
|
447
|
-
or false
|
|
447
|
+
either side directly (`@spider the fly is east`) to feed it a belief, true
|
|
448
|
+
or false, and watch a wrong assertion mislead it for as long as the real
|
|
448
449
|
target stays out of sight. `tmct.toml`'s `[games.spider-fly]` table tunes
|
|
449
450
|
every rate; the full mechanic is pinned in `test/corpus/games/spider-fly.jsonl`.
|
|
450
451
|
|
|
451
452
|
## Learning on a miss
|
|
452
453
|
|
|
453
|
-
A question tmct cannot ground is still an honest miss
|
|
454
|
-
kind of miss (a recognised word, a clean parse, simply no facts anywhere) it
|
|
454
|
+
A question tmct cannot ground is still an honest miss. But on the cleanest
|
|
455
|
+
kind of miss (a recognised word, a clean parse, simply no facts anywhere), it
|
|
455
456
|
now consults two shipped, lazily-loaded packs before giving up:
|
|
456
457
|
|
|
457
|
-
- `corpus/child
|
|
458
|
+
- `corpus/child/`: 93k everyday-world triples filtered from ConceptNet by a
|
|
458
459
|
child-concept seed. Asked `what is a kettle` cold, tmct loads the term's
|
|
459
460
|
triples into memory (provenance `child:conceptnet:kettle`, ranked below
|
|
460
461
|
anything you teach) and answers from them; the next ask answers from
|
|
461
462
|
memory directly.
|
|
462
|
-
- `corpus/reference
|
|
463
|
+
- `corpus/reference/`: 3,887 Simple English Wikipedia summaries. When the
|
|
463
464
|
triples cannot answer, a matching article answers as a cited read-out
|
|
464
465
|
(`source: reference article "Otter"…, CC BY-SA 4.0`).
|
|
465
466
|
|
|
@@ -495,7 +496,7 @@ by cleaned session logs:
|
|
|
495
496
|
relevance rather than loaded wholesale.
|
|
496
497
|
|
|
497
498
|
Every session also writes its own human-readable transcript,
|
|
498
|
-
`.tmct/session-<id>.md
|
|
499
|
+
`.tmct/session-<id>.md`, a glow-friendly Markdown file with one heading per
|
|
499
500
|
turn, the question as a blockquote, the reply in a fenced block. The
|
|
500
501
|
browser chat page's "export .md" button writes the same shape.
|
|
501
502
|
|
|
@@ -538,7 +539,7 @@ Teaching isn't limited to the ACE grammar's fixed shapes. Tell tmct an
|
|
|
538
539
|
arbitrary fact, like "margo really eats ribs", and it mints a fact you can
|
|
539
540
|
ask about directly: "what does margo eat". New vocabulary compounds as you
|
|
540
541
|
teach: "redis is a cache" mints "redis" even though it was never in the
|
|
541
|
-
built-in lexicon, as long as one side of the sentence is already grounded
|
|
542
|
+
built-in lexicon, as long as one side of the sentence is already grounded.
|
|
542
543
|
tmct never mints a fact between two totally ungrounded terms; it declines and
|
|
543
544
|
nudges you to ground one side first. Quantified teaching stores the
|
|
544
545
|
quantifier ("some functions are risky" … "how many functions are risky" →
|
|
@@ -553,13 +554,13 @@ rather than the code graph. A taught class answers both shapes too: after
|
|
|
553
554
|
|
|
554
555
|
When you ask about a term, the read-back shows each "is a kind of" object with
|
|
555
556
|
its own superclass chain: "what is rover" answers "rover is a kind of dog →
|
|
556
|
-
canine → mammal → animal". If one label carries two unrelated senses
|
|
557
|
-
taught "rover is a dog" and a corpus row says "rover is a scout"
|
|
557
|
+
canine → mammal → animal". If one label carries two unrelated senses (you
|
|
558
|
+
taught "rover is a dog" and a corpus row says "rover is a scout"), the answer
|
|
558
559
|
groups by concept ("rover, the dog:" / "rover, the scout:") instead of listing
|
|
559
560
|
two unrelated lines as if they were one thing. The split is deterministic over
|
|
560
561
|
the stored hierarchy: two senses part when a stored disjointness separates
|
|
561
562
|
their ancestors, when their chains never meet, or when they meet only at the
|
|
562
|
-
very top. When the evidence is thin the answer stays a flat list
|
|
563
|
+
very top. When the evidence is thin the answer stays a flat list. Grouping is
|
|
563
564
|
presentation, and it never retracts or reranks a fact.
|
|
564
565
|
|
|
565
566
|
Teaching doesn't have to be typed, either. `tmct extract` runs a plain text
|
|
@@ -586,14 +587,14 @@ carries an `extracted:<file>` provenance tag at its own trust tier.
|
|
|
586
587
|
recognizer skips: a copula or a known relation verb flanked by two nouns
|
|
587
588
|
becomes a candidate triple, stored under its own `optimistic-extract:<file>`
|
|
588
589
|
provenance (prior 0.35, below every curated pack) with no operator tag riding
|
|
589
|
-
alongside
|
|
590
|
+
alongside, so a fuzzy guess can never corroborate a curated fact. It is an
|
|
590
591
|
attempt, not full NLU: a sentence with no clean pair yields nothing.
|
|
591
592
|
`--canonical` prints each grounded fact as a triple, noting how each endpoint
|
|
592
593
|
already links into the store.
|
|
593
594
|
|
|
594
595
|
The same pipeline is one library seam, `ingestText(text, options)` (exported
|
|
595
596
|
as `@polycode-projects/the-mechanical-code-talker/ingest`), and one cold tool,
|
|
596
|
-
`tmct_ingest
|
|
597
|
+
`tmct_ingest`. A browser page, a script, or a tool-calling agent can ground
|
|
597
598
|
text without the CLI.
|
|
598
599
|
|
|
599
600
|
### Provenance and trust
|
|
@@ -754,7 +755,7 @@ already set up. Its `--graph` flag works differently from the others: it appends
|
|
|
754
755
|
```
|
|
755
756
|
|
|
756
757
|
`tmct extract` is the document route into memory described under "Teach it"
|
|
757
|
-
above
|
|
758
|
+
above: the same teach recognizer, reading a file instead of your typing:
|
|
758
759
|
|
|
759
760
|
```output:help:extract
|
|
760
761
|
tmct extract <text-file> read a plain text file's sentences through the chat's own
|
|
@@ -787,7 +788,7 @@ inference" above:
|
|
|
787
788
|
retractable entailed facts (never on the chat path)
|
|
788
789
|
```
|
|
789
790
|
|
|
790
|
-
`tmct viz` renders the memory graph as the ledger explorer
|
|
791
|
+
`tmct viz` renders the memory graph as the ledger explorer, a single,
|
|
791
792
|
self-contained HTML file you can open in a browser:
|
|
792
793
|
|
|
793
794
|
```output:help:viz
|
|
@@ -867,7 +868,7 @@ aws/python/java) into ~7,380 facts on the default sqlite backend, a working
|
|
|
867
868
|
example to copy from. `init:xl` starts from the large persona tier and adds
|
|
868
869
|
the wordnet-xl corpus (~72,000 facts); `init:xxl` swaps wordnet-xl for the
|
|
869
870
|
full WordNet slice plus namenet (~239,000 facts, the biggest committed
|
|
870
|
-
vocabulary
|
|
871
|
+
vocabulary, so expect its imports to take a minute). The xl chain, spelled out:
|
|
871
872
|
|
|
872
873
|
```bash e2e heavy
|
|
873
874
|
npx tmct init --persona-size large # npm run init:xl runs this whole chain from a clone
|
|
@@ -1171,12 +1172,12 @@ The full tables, judge scores, and transcripts are in the linked write-ups.
|
|
|
1171
1172
|
|
|
1172
1173
|
| What it does | Result (2.7.12) | Read the number with this | Method |
|
|
1173
1174
|
|---|---|---|---|
|
|
1174
|
-
| Multi-hop entailment | 379/379 chat cases and 100/100 kernel cases, 0% fabrication, all bands pass | The case set is unchanged from 2.6.0 (same templates, same counts)
|
|
1175
|
-
| Tool-call planning | 68/68 cases, 100% plan-completion, 100% result-completion, 0% hallucination, every rung A0→C2 | Goal driver. 2.6.0 gated at TOOL-7 (62/66, 94%)
|
|
1175
|
+
| Multi-hop entailment | 379/379 chat cases and 100/100 kernel cases, 0% fabrication, all bands pass | The case set is unchanged from 2.6.0 (same templates, same counts). The one real move this cycle is INF-4's ceiling-graded count dropping 35→30: five cases that now pass as genuine capability instead of against the declared honest-miss floor. | `BENCHMARK_INFERENCE_2.7.12.md` |
|
|
1176
|
+
| Tool-call planning | 68/68 cases, 100% plan-completion, 100% result-completion, 0% hallucination, every rung A0→C2 | Goal driver. 2.6.0 gated at TOOL-7 (62/66, 94%). This cycle's router uplift (a guarded RECOVER step, a tied-candidate composer) cleared it: a real capability move, not a ruler change. | `BENCHMARK_AGENT_2.7.12.md` |
|
|
1176
1177
|
| Groundedness | Every answer carries a source, and an empty graph reports itself empty. Judge-scored mean 1.809/2 over 138 cases, 5 hard fails, 136/138 tier-1. | Judged (`claude-haiku-4-5-20251001`, `judge-prompt-v2`) at N=1. The judge prompt moved v1→v2 since 2.6.0, so this is a measurement, not a clean lever comparison against the prior cycle. The judge runs in the offline eval harness, never in the product. | `BENCHMARK_CEFR_ENGLISH_2.7.12.md` |
|
|
1177
1178
|
| Abstention (the honest miss) | 0% fabrication across 479 inference rows (379 chat + 100 kernel) and 0% hallucination across 272 agent rows | Structural, not a tuned threshold. tmct abstains because nothing matched, so the rows test a property of a no-model design rather than a score. | `BENCHMARK_INFERENCE_2.7.12.md`, `BENCHMARK_AGENT_2.7.12.md` |
|
|
1178
|
-
| Determinism | Byte-identical on rerun
|
|
1179
|
-
| Dialogue robustness (persona sweep) | A 6-persona sweep (textbook logician, casual newcomer, new developer, adversarial sceptic, returning user, planning user) fixed 25 of the prior cycle's 29 routed findings (21 clean, 4 with a residual noted); 4 remain broken, 2 in a shape distinct from the original complaint | Free exploration across all six personas surfaced roughly 60 fresh findings beyond the ratchet check
|
|
1179
|
+
| Determinism | Byte-identical on rerun: a 379-case `--replay` clean across 2 runs, no LLM, no network, $0 per turn | A property of the no-model pipeline. | `BENCHMARK_INFERENCE_2.7.12.md` |
|
|
1180
|
+
| Dialogue robustness (persona sweep) | A 6-persona sweep (textbook logician, casual newcomer, new developer, adversarial sceptic, returning user, planning user) fixed 25 of the prior cycle's 29 routed findings (21 clean, 4 with a residual noted); 4 remain broken, 2 in a shape distinct from the original complaint | Free exploration across all six personas surfaced roughly 60 fresh findings beyond the ratchet check. The single highest-signal pattern: tmct's own suggested repair text was itself frequently broken when followed verbatim (since fixed, see `NEXT.md`). | `BENCHMARK_CONVERSATION_2.7.11.md` |
|
|
1180
1181
|
|
|
1181
1182
|
Three offline benchmark rigs live in a clone (they are not in the npm
|
|
1182
1183
|
package). Each replays a committed case set through the real product and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "2.11.
|
|
3
|
+
"version": "2.11.11",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// research-queue-store.mjs — persists the "research <topic>" queue as one JSON
|
|
2
|
+
// file under the repo's .tmct/ (research-queue.json, beside the memory dir), so
|
|
3
|
+
// a run started in one CLI session resumes in the next: "research next" steps
|
|
4
|
+
// the queue, "research status" reports it, "research stop" clears it, all
|
|
5
|
+
// across process restarts. The file is per-repo and machine-local (.tmct/ is
|
|
6
|
+
// gitignored), exactly like the graph/sqlite store it sits next to.
|
|
7
|
+
//
|
|
8
|
+
// The seam is the memoryDir backend token runTurn already carries:
|
|
9
|
+
// - a repo-path string (Backend A): the queue file is <repo>/.tmct/…;
|
|
10
|
+
// - a sqlite handle carrying dbPath (Backend C, the CLI default): the queue
|
|
11
|
+
// file is the dbPath's .tmct/ sibling;
|
|
12
|
+
// - an in-memory handle (Backend B) or null (the browser session): no path,
|
|
13
|
+
// so persistence is a silent no-op and the in-page queue behaves as before.
|
|
14
|
+
//
|
|
15
|
+
// Fail closed: an absent, unreadable, or invalid file reads as "no run" — never
|
|
16
|
+
// a crash, never a fabricated queue.
|
|
17
|
+
|
|
18
|
+
import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
|
|
21
|
+
const QUEUE_FILE = "research-queue.json";
|
|
22
|
+
|
|
23
|
+
/** The on-disk path for `memoryDir`'s queue file, or null when this session has
|
|
24
|
+
* nowhere to persist (in-memory backend, or a browser session with no store).
|
|
25
|
+
* A repo-path string keys off <repo>/.tmct; a store handle keys off its own
|
|
26
|
+
* dbPath's .tmct/ sibling — any handle without a real dbPath has no home. */
|
|
27
|
+
function researchQueuePath(memoryDir) {
|
|
28
|
+
if (!memoryDir) return null;
|
|
29
|
+
if (typeof memoryDir === "string") return join(memoryDir, ".tmct", QUEUE_FILE);
|
|
30
|
+
if (typeof memoryDir.dbPath === "string") return join(dirname(dirname(memoryDir.dbPath)), QUEUE_FILE);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A parsed value is a resumable queue only if it carries the run identity and
|
|
35
|
+
* the three lists the lifecycle mutates. Anything else fails closed to null. */
|
|
36
|
+
function isQueueState(state) {
|
|
37
|
+
return !!state && typeof state === "object"
|
|
38
|
+
&& typeof state.topic === "string"
|
|
39
|
+
&& typeof state.key === "string"
|
|
40
|
+
&& Array.isArray(state.pending)
|
|
41
|
+
&& Array.isArray(state.done)
|
|
42
|
+
&& Array.isArray(state.skipped);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The persisted queue for `memoryDir`, or null when none is stored, the store
|
|
46
|
+
* cannot persist, or the file is missing/corrupt/ill-shaped. */
|
|
47
|
+
export async function loadResearchQueue(memoryDir) {
|
|
48
|
+
const path = researchQueuePath(memoryDir);
|
|
49
|
+
if (!path) return null;
|
|
50
|
+
let raw;
|
|
51
|
+
try { raw = await readFile(path, "utf8"); } catch { return null; }
|
|
52
|
+
let state;
|
|
53
|
+
try { state = JSON.parse(raw); } catch { return null; }
|
|
54
|
+
return isQueueState(state) ? state : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Write-through the current queue. A null/absent state clears the file, so a
|
|
58
|
+
* stopped or completed-and-cleared run leaves nothing behind. A store with no
|
|
59
|
+
* path is a no-op. A write that fails leaves the in-memory queue standing. */
|
|
60
|
+
export async function saveResearchQueue(memoryDir, state) {
|
|
61
|
+
const path = researchQueuePath(memoryDir);
|
|
62
|
+
if (!path) return;
|
|
63
|
+
if (!state) { await clearResearchQueue(memoryDir); return; }
|
|
64
|
+
try {
|
|
65
|
+
await mkdir(dirname(path), { recursive: true });
|
|
66
|
+
await writeFile(path, JSON.stringify(state), "utf8");
|
|
67
|
+
} catch { /* a queue we can't persist stays in memory for this session */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Delete the persisted queue file (idempotent — an already-absent file is the
|
|
71
|
+
* cleared state we want). A store with no path is a no-op. */
|
|
72
|
+
export async function clearResearchQueue(memoryDir) {
|
|
73
|
+
const path = researchQueuePath(memoryDir);
|
|
74
|
+
if (!path) return;
|
|
75
|
+
try { await unlink(path); } catch { /* already gone */ }
|
|
76
|
+
}
|
|
@@ -641,7 +641,14 @@ export function parseImperative(sentence, lexicon = loadLexicon()) {
|
|
|
641
641
|
|
|
642
642
|
if (verb === "look") {
|
|
643
643
|
if (!rest.length || (rest.length === 1 && lower[0] === "around")) return command({});
|
|
644
|
-
|
|
644
|
+
// "look <object>" — a bare noun after look reads as a close look at that
|
|
645
|
+
// thing, routed through the same object handler examine/talk share (which
|
|
646
|
+
// resolves presence and declines an absent thing by name). "look at <x>"
|
|
647
|
+
// never reaches here — its 2-token synonym prefix already resolved to
|
|
648
|
+
// examine — so this arm only ever sees the bare-noun form.
|
|
649
|
+
const object = imperativeNP(lexicon, rest);
|
|
650
|
+
if (object.term == null) return miss(object.unknown);
|
|
651
|
+
return command({ object: object.term });
|
|
645
652
|
}
|
|
646
653
|
if (verb === "examine" || verb === "talk") {
|
|
647
654
|
if (!rest.length) return null;
|
|
@@ -682,6 +682,13 @@ ${THEME_TOKENS_CSS}
|
|
|
682
682
|
underneath always names the real, specific thing (the cabinet, the
|
|
683
683
|
butler) — chrome around honest content, never instead of it. */
|
|
684
684
|
.sprite-card { display: flex; flex-direction: column; align-items: center; width: 70px; }
|
|
685
|
+
/* a room sprite the visitor can click for a lights-down close look — a
|
|
686
|
+
pointer cursor and a gilt ring on hover. Wall-mounted cards live in a
|
|
687
|
+
pointer-events: none band (so the band never eats a floor click), so a
|
|
688
|
+
clickable one re-enables its own pointer events. */
|
|
689
|
+
.sprite-card.clickable { cursor: pointer; }
|
|
690
|
+
.sprite-card.clickable:hover .sprite-frame { border-color: var(--gilt); box-shadow: inset 0 0 0 3px var(--parchment), 0 0 0 2px var(--gilt), 0 2px 3px rgba(0, 0, 0, .22); }
|
|
691
|
+
.wall-row .sprite-card.clickable { pointer-events: auto; }
|
|
685
692
|
/* a squared inventory-slot tile (the 90s-RPG idiom) instead of the old
|
|
686
693
|
thin circular ring: a raised card with an inner mat, the class color on
|
|
687
694
|
the outer border, and a soft ground shadow so the tile sits ON the
|
|
@@ -759,6 +766,29 @@ ${THEME_TOKENS_CSS}
|
|
|
759
766
|
.roommap .room-node.clickable:hover rect { stroke: var(--gilt); stroke-width: 2.5; }
|
|
760
767
|
.roommap .room-node.selected rect { stroke: var(--alert); stroke-width: 2.5; }
|
|
761
768
|
|
|
769
|
+
/* the object lightbox — the SAME lights-down treatment the map lightbox
|
|
770
|
+
uses (fixed dimmed backdrop, z-index 60, close on backdrop click/Escape),
|
|
771
|
+
framed as a parchment case-file card: the clicked object's large sprite
|
|
772
|
+
(the sprites-page 400px tier, via the same resolveObjectSprite the room
|
|
773
|
+
cards use), its live "look <object>" reply, and an object-scoped chat
|
|
774
|
+
dock — its own affordance pills filtered to this object, plus free text.
|
|
775
|
+
Every dock turn runs through the ONE live session, so the main
|
|
776
|
+
transcript/quest/satchel reflect it. */
|
|
777
|
+
.obj-lightbox { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; padding: 2.4rem; background: rgba(10, 8, 4, .74); }
|
|
778
|
+
.obj-lightbox[hidden] { display: none; }
|
|
779
|
+
.obj-lightbox-inner { width: min(92vw, 560px); max-height: 88vh; overflow-y: auto; background: var(--parchment); color: var(--ink); border: 3px solid var(--gilt); box-shadow: inset 0 0 0 2px var(--parchment-strong), 0 12px 48px rgba(0, 0, 0, .5); padding: 1.1rem 1.2rem 1.2rem; box-sizing: border-box; }
|
|
780
|
+
.obj-title { font-family: ${SERIF_STACK}; font-variant: small-caps; font-size: 1rem; letter-spacing: .06em; color: var(--gilt); font-weight: 600; margin: 0 0 .6rem; padding-bottom: .3rem; border-bottom: 1px solid var(--line); }
|
|
781
|
+
.obj-scene { display: flex; align-items: center; justify-content: center; padding: .5rem 0 1rem; }
|
|
782
|
+
.obj-sprite { width: min(46vmin, 300px); height: min(46vmin, 300px); }
|
|
783
|
+
.obj-sprite svg { width: 100%; height: 100%; display: block; }
|
|
784
|
+
.obj-look { font-size: .9rem; line-height: 1.45; white-space: pre-wrap; background: var(--card); border-left: 3px solid var(--gilt); padding: .55rem .7rem; margin: 0 0 .8rem; }
|
|
785
|
+
.obj-look:empty { display: none; }
|
|
786
|
+
.docklog { display: flex; flex-direction: column; gap: .4rem; max-height: 200px; overflow-y: auto; margin-bottom: .5rem; }
|
|
787
|
+
.docklog:empty { display: none; }
|
|
788
|
+
.docklog .u { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); }
|
|
789
|
+
.docklog .u::before { content: "tmct> "; color: var(--taught); }
|
|
790
|
+
.docklog .a { font-size: .86rem; line-height: 1.4; white-space: pre-wrap; }
|
|
791
|
+
|
|
762
792
|
.goal-status { display: flex; flex-direction: column; gap: .4rem; }
|
|
763
793
|
.goal-status .g { display: flex; align-items: baseline; gap: .4rem; font-size: .86rem; line-height: 1.35; }
|
|
764
794
|
.goal-status .dot { width: .5rem; height: .5rem; border-radius: 50%; flex: none; background: var(--muted); }
|
|
@@ -890,6 +920,19 @@ ${THEME_TOKENS_CSS}
|
|
|
890
920
|
<div class="map-lightbox" id="mapLightbox" role="dialog" aria-modal="true" aria-label="The manor map, enlarged" hidden>
|
|
891
921
|
<div class="map-lightbox-inner roommap" id="mapLightboxInner"></div>
|
|
892
922
|
</div>
|
|
923
|
+
<div class="obj-lightbox" id="objLightbox" role="dialog" aria-modal="true" aria-label="A closer look at an object" hidden>
|
|
924
|
+
<div class="obj-lightbox-inner" id="objLightboxInner">
|
|
925
|
+
<h2 class="obj-title" id="objTitle"></h2>
|
|
926
|
+
<div class="obj-scene" id="objScene"></div>
|
|
927
|
+
<div class="obj-look" id="objLook"></div>
|
|
928
|
+
<div class="pills" id="objPills"></div>
|
|
929
|
+
<div class="docklog" id="objDockLog" aria-live="polite"></div>
|
|
930
|
+
<form class="chatask" id="objForm">
|
|
931
|
+
<span class="prompt mono">tmct></span>
|
|
932
|
+
<input id="objInput" type="text" placeholder="examine it, take it…" aria-label="Type a command for this object">
|
|
933
|
+
</form>
|
|
934
|
+
</div>
|
|
935
|
+
</div>
|
|
893
936
|
|
|
894
937
|
<div class="stage editor-stage" id="editStage" aria-label="The world editor">
|
|
895
938
|
<div class="panel edittext">
|
|
@@ -960,6 +1003,14 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
960
1003
|
const mapViewportEl = el("mapViewport");
|
|
961
1004
|
const mapLightboxEl = el("mapLightbox");
|
|
962
1005
|
const mapLightboxInnerEl = el("mapLightboxInner");
|
|
1006
|
+
const objLightboxEl = el("objLightbox");
|
|
1007
|
+
const objTitleEl = el("objTitle");
|
|
1008
|
+
const objSceneEl = el("objScene");
|
|
1009
|
+
const objLookEl = el("objLook");
|
|
1010
|
+
const objPillsEl = el("objPills");
|
|
1011
|
+
const objDockLogEl = el("objDockLog");
|
|
1012
|
+
const objFormEl = el("objForm");
|
|
1013
|
+
const objInputEl = el("objInput");
|
|
963
1014
|
const goalListEl = el("goalList");
|
|
964
1015
|
const editModeBtn = el("editModeBtn");
|
|
965
1016
|
const editorTextEl = el("editorText");
|
|
@@ -980,6 +1031,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
980
1031
|
let lastTicks = 0;
|
|
981
1032
|
let lastSnapshot = null;
|
|
982
1033
|
let selectedRoomId = null;
|
|
1034
|
+
let objLightboxSubject = null;
|
|
983
1035
|
let editRows = [];
|
|
984
1036
|
let editState = { placements: new Map(), openness: new Map(), exits: new Map() };
|
|
985
1037
|
let allStoreRows = [];
|
|
@@ -1058,8 +1110,15 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1058
1110
|
// the caption text — is never replaced by it.
|
|
1059
1111
|
const CLASS_BADGE = { adventurer: "hero", person: "townsfolk", container: "fixture", furniture: "fixture", portable: "item", room: "room" };
|
|
1060
1112
|
const badgeFor = (cls) => CLASS_BADGE[cls] || cls;
|
|
1061
|
-
|
|
1062
|
-
|
|
1113
|
+
// A truthy subject marks the card clickable and tags it with the object it
|
|
1114
|
+
// names, so the room frame's own delegated handler can open the object
|
|
1115
|
+
// lightbox for it. The player's own "you" card and the edit-mode / legend /
|
|
1116
|
+
// satchel cards pass none, so only real room props are clickable.
|
|
1117
|
+
function clickAttrs(subject) {
|
|
1118
|
+
return subject ? ' clickable" data-look-subject="' + esc(subject) + '"' : '"';
|
|
1119
|
+
}
|
|
1120
|
+
function spriteCardHtml(label, cls, svg, subject) {
|
|
1121
|
+
return '<div class="sprite-card' + clickAttrs(subject) + '><div class="sprite-frame" data-cls="' + esc(cls) + '"><div class="sprite" data-cls="' + esc(cls) + '">' + svg + '</div></div>'
|
|
1063
1122
|
+ '<div class="sprite-label">' + esc(label) + '</div>'
|
|
1064
1123
|
+ '<div class="class-badge" data-cls="' + esc(cls) + '">' + esc(badgeFor(cls)) + "</div></div>";
|
|
1065
1124
|
}
|
|
@@ -1073,8 +1132,8 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1073
1132
|
// spriteCardHtml identification; every item resting on something else
|
|
1074
1133
|
// shows an aria-label in its place, so the name is still available to
|
|
1075
1134
|
// assistive tech even though the sighted layout stays compact.
|
|
1076
|
-
function stackedSpriteCardHtml(label, cls, svg) {
|
|
1077
|
-
return '<div class="sprite-card
|
|
1135
|
+
function stackedSpriteCardHtml(label, cls, svg, subject) {
|
|
1136
|
+
return '<div class="sprite-card' + clickAttrs(subject) + ' aria-label="' + esc(label) + '"><div class="sprite-frame" data-cls="' + esc(cls) + '"><div class="sprite" data-cls="' + esc(cls) + '">' + svg + "</div></div></div>";
|
|
1078
1137
|
}
|
|
1079
1138
|
|
|
1080
1139
|
function captionFor(rows, state, here) {
|
|
@@ -1174,6 +1233,97 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1174
1233
|
mapLightboxEl.addEventListener("click", (e) => { if (e.target === mapLightboxEl) closeMapLightbox(); });
|
|
1175
1234
|
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !mapLightboxEl.hidden) closeMapLightbox(); });
|
|
1176
1235
|
|
|
1236
|
+
// ---- the object lightbox — clicking a room sprite opens a lights-down
|
|
1237
|
+
// close look at that thing (the SAME lights-down/backdrop/Escape pattern as
|
|
1238
|
+
// the map lightbox): its large sprite, its live "look <object>" reply
|
|
1239
|
+
// (adventure.mjs's grounded look, run through THIS session), and an
|
|
1240
|
+
// object-scoped chat dock. The dock's pills are the room's own affordance
|
|
1241
|
+
// list filtered to this object (pillsFor, the same source the main hint
|
|
1242
|
+
// pills read), plus a free-text input. Every dock turn runs through the ONE
|
|
1243
|
+
// live session, echoes into the MAIN transcript and redraws the main page,
|
|
1244
|
+
// so the satchel/quest/room all reflect it after close — no forked state.
|
|
1245
|
+
function addObjDockLine(cls, html) {
|
|
1246
|
+
const d = document.createElement("div");
|
|
1247
|
+
d.className = cls; d.innerHTML = html;
|
|
1248
|
+
objDockLogEl.appendChild(d); objDockLogEl.scrollTop = objDockLogEl.scrollHeight;
|
|
1249
|
+
}
|
|
1250
|
+
function objectPillsFor(rows, state, here, subject) {
|
|
1251
|
+
return pillsFor(rows, state, here).filter((a) => a.split(" ").pop() === subject);
|
|
1252
|
+
}
|
|
1253
|
+
function renderObjScene() {
|
|
1254
|
+
if (!lastSnapshot || !objLightboxSubject) { objSceneEl.innerHTML = ""; return; }
|
|
1255
|
+
const cls = spriteClassForObject(lastSnapshot.rows, objLightboxSubject);
|
|
1256
|
+
const svg = resolveObjectSprite(lastSnapshot.rows, { subject: objLightboxSubject, spriteClass: cls });
|
|
1257
|
+
objSceneEl.innerHTML = '<div class="obj-sprite" data-cls="' + esc(cls) + '">' + svg + "</div>";
|
|
1258
|
+
}
|
|
1259
|
+
function renderObjPills() {
|
|
1260
|
+
if (!lastSnapshot || !objLightboxSubject) { objPillsEl.innerHTML = ""; return; }
|
|
1261
|
+
const actions = objectPillsFor(lastSnapshot.rows, lastSnapshot.state, lastSnapshot.here, objLightboxSubject);
|
|
1262
|
+
objPillsEl.innerHTML = actions.map((a) => '<button type="button" class="pill">' + esc(a) + "</button>").join("");
|
|
1263
|
+
}
|
|
1264
|
+
// A dock turn: paused, echoed into the main transcript AND the dock, run on
|
|
1265
|
+
// the one session, then the main page and the lightbox both redraw off the
|
|
1266
|
+
// fresh snapshot — the board stays open.
|
|
1267
|
+
function runObjTurn(line) {
|
|
1268
|
+
ticker.pause();
|
|
1269
|
+
addChatLine("u", esc(line));
|
|
1270
|
+
addObjDockLine("u", esc(line));
|
|
1271
|
+
return withLock(async () => {
|
|
1272
|
+
const result = await session.turn(line);
|
|
1273
|
+
addChatLine("a", esc(result.answer).replace(/\\n/g, "<br>"));
|
|
1274
|
+
addObjDockLine("a", esc(result.answer).replace(/\\n/g, "<br>"));
|
|
1275
|
+
const snap = await session.snapshot();
|
|
1276
|
+
redraw(snap);
|
|
1277
|
+
renderObjScene();
|
|
1278
|
+
renderObjPills();
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
async function openObjectLightbox(subject) {
|
|
1282
|
+
if (!session || !lastSnapshot) return;
|
|
1283
|
+
ticker.pause();
|
|
1284
|
+
objLightboxSubject = subject;
|
|
1285
|
+
objTitleEl.textContent = "the " + subject;
|
|
1286
|
+
objDockLogEl.innerHTML = "";
|
|
1287
|
+
objLookEl.textContent = "";
|
|
1288
|
+
objInputEl.value = "";
|
|
1289
|
+
renderObjScene();
|
|
1290
|
+
renderObjPills();
|
|
1291
|
+
objLightboxEl.hidden = false;
|
|
1292
|
+
objInputEl.focus();
|
|
1293
|
+
// The initial look is a real, read-only turn on the live session (it writes
|
|
1294
|
+
// nothing, so it needs no main-transcript echo — it IS the board's own
|
|
1295
|
+
// content); pill/typed turns below do echo, since they can change state.
|
|
1296
|
+
await withLock(async () => {
|
|
1297
|
+
const result = await session.turn("look " + subject);
|
|
1298
|
+
objLookEl.textContent = result.answer;
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
function closeObjectLightbox() {
|
|
1302
|
+
objLightboxEl.hidden = true;
|
|
1303
|
+
objLightboxSubject = null;
|
|
1304
|
+
objDockLogEl.innerHTML = "";
|
|
1305
|
+
objInputEl.value = "";
|
|
1306
|
+
}
|
|
1307
|
+
roomFrameEl.addEventListener("click", (e) => {
|
|
1308
|
+
const card = e.target.closest(".sprite-card[data-look-subject]");
|
|
1309
|
+
if (!card) return;
|
|
1310
|
+
openObjectLightbox(card.getAttribute("data-look-subject"));
|
|
1311
|
+
});
|
|
1312
|
+
objPillsEl.addEventListener("click", (e) => {
|
|
1313
|
+
const btn = e.target.closest(".pill");
|
|
1314
|
+
if (!btn) return;
|
|
1315
|
+
runObjTurn(btn.textContent);
|
|
1316
|
+
});
|
|
1317
|
+
objFormEl.addEventListener("submit", (e) => {
|
|
1318
|
+
e.preventDefault();
|
|
1319
|
+
const q = objInputEl.value.trim();
|
|
1320
|
+
if (!q || !session) return;
|
|
1321
|
+
objInputEl.value = "";
|
|
1322
|
+
runObjTurn(q);
|
|
1323
|
+
});
|
|
1324
|
+
objLightboxEl.addEventListener("click", (e) => { if (e.target === objLightboxEl) closeObjectLightbox(); });
|
|
1325
|
+
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !objLightboxEl.hidden) closeObjectLightbox(); });
|
|
1326
|
+
|
|
1177
1327
|
function renderEditMap(rows, state) {
|
|
1178
1328
|
editMapWrapEl.innerHTML = roomMapSvg(visitedRoomGraph(state, allRoomIds(rows)), true) || '<span class="empty-note">this world defines no rooms</span>';
|
|
1179
1329
|
}
|
|
@@ -1268,12 +1418,12 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1268
1418
|
function redraw(snap) {
|
|
1269
1419
|
lastSnapshot = snap;
|
|
1270
1420
|
const layout = roomSceneLayout(snap.rows, snap.state, snap.here);
|
|
1271
|
-
wallRowEl.innerHTML = layout.wall.map((s) => spriteCardHtml(s.subject, s.spriteClass, resolveObjectSprite(snap.rows, s))).join("");
|
|
1421
|
+
wallRowEl.innerHTML = layout.wall.map((s) => spriteCardHtml(s.subject, s.spriteClass, resolveObjectSprite(snap.rows, s), s.subject)).join("");
|
|
1272
1422
|
floorRowEl.innerHTML = layout.floor.map((stack) => {
|
|
1273
1423
|
const baseIndex = stack.items.length - 1;
|
|
1274
1424
|
return '<div class="sprite-stack">' + stack.items.map((s, i) => {
|
|
1275
1425
|
const svg = resolveObjectSprite(snap.rows, s);
|
|
1276
|
-
return i === baseIndex ? spriteCardHtml(s.subject, s.spriteClass, svg) : stackedSpriteCardHtml(s.subject, s.spriteClass, svg);
|
|
1426
|
+
return i === baseIndex ? spriteCardHtml(s.subject, s.spriteClass, svg, s.subject) : stackedSpriteCardHtml(s.subject, s.spriteClass, svg, s.subject);
|
|
1277
1427
|
}).join("") + "</div>";
|
|
1278
1428
|
}).join("");
|
|
1279
1429
|
youSlotEl.innerHTML = spriteCardHtml("you", "adventurer", resolveObjectSprite(snap.rows, { subject: "you", spriteClass: "adventurer" }));
|